diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index a514ad904..261686268 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -24,6 +24,7 @@ import 'package:dpip/features/data/presentation/pages/tonight_page.dart'; import 'package:dpip/features/data/presentation/pages/sun_page.dart'; import 'package:dpip/features/meshtastic/presentation/pages/meshtastic_page.dart'; import 'package:dpip/features/more/presentation/pages/more_page.dart'; +import 'package:dpip/features/notification/presentation/pages/notification_test_page.dart'; import 'package:dpip/features/notification/presentation/pages/notify_page.dart'; import 'package:dpip/features/onboarding/presentation/pages/onboarding_page.dart'; import 'package:dpip/features/settings/presentation/pages/developer_page.dart'; @@ -34,6 +35,8 @@ import 'package:dpip/features/settings/presentation/pages/eew_source_page.dart'; import 'package:dpip/features/settings/presentation/pages/language_page.dart'; import 'package:dpip/features/settings/presentation/pages/permissions_page.dart'; import 'package:dpip/features/sponsor/presentation/pages/sponsor_page.dart'; +import 'package:dpip/features/bug_tracker/presentation/pages/bug_list_page.dart'; +import 'package:dpip/features/bug_tracker/presentation/pages/bug_thread_page.dart'; import 'package:dpip/features/status/presentation/pages/server_status_page.dart'; import 'package:dpip/features/weather/presentation/pages/weather_ranking_page.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; @@ -270,6 +273,13 @@ final GoRouter appRouter = GoRouter( path: AppRoutes.notifySettingsPath, name: AppRoutes.notifySettings, builder: (_, _) => const NotifyPage(), + routes: [ + GoRoute( + path: AppRoutes.notifyTestPath, + name: AppRoutes.notifyTest, + builder: (_, _) => const NotificationTestPage(), + ), + ], ), GoRoute( path: AppRoutes.sponsorPath, @@ -281,6 +291,19 @@ final GoRouter appRouter = GoRouter( name: AppRoutes.serverStatus, builder: (_, _) => const ServerStatusPage(), ), + GoRoute( + path: AppRoutes.bugTrackerPath, + name: AppRoutes.bugTracker, + builder: (_, _) => const BugListPage(), + routes: [ + GoRoute( + path: AppRoutes.bugThreadPath, + name: AppRoutes.bugThread, + builder: (_, state) => + BugThreadPage(id: int.tryParse(state.pathParameters['id']!) ?? 0), + ), + ], + ), ], ); diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 86378df21..bd251e79b 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -66,6 +66,7 @@ import 'package:dpip/features/home/home_providers.dart'; import 'package:dpip/features/meshtastic/meshtastic_providers.dart'; import 'package:dpip/features/notification/notification_providers.dart'; import 'package:dpip/features/sponsor/sponsor_providers.dart'; +import 'package:dpip/features/bug_tracker/bug_tracker_providers.dart'; import 'package:dpip/features/status/status_providers.dart'; import 'package:dpip/features/typhoon/typhoon_providers.dart'; import 'package:dpip/features/weather/weather_providers.dart'; @@ -423,6 +424,7 @@ Future bootstrap() async { ...sponsorProviders(), ...homeProviders(), ...statusProviders(deps), + ...bugTrackerProviders(deps), ], ), ); diff --git a/lib/core/network/api_exception.dart b/lib/core/network/api_exception.dart index b53f3e058..1842d5a70 100644 --- a/lib/core/network/api_exception.dart +++ b/lib/core/network/api_exception.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:dpip/core/error/failure.dart'; import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/logging/log.dart'; /// Runs [body] and folds it into a [Result]: its value on success, a typed /// [Failure] (via [mapException]) on any throw. @@ -12,7 +13,11 @@ import 'package:dpip/core/error/result.dart'; Future> guardResult(Future Function() body) async { try { return Ok(await body()); - } catch (error) { + } catch (error, stackTrace) { + // Silent failures are the worst kind: the UI shows its error state and + // nothing else records WHY. One line per failure, here at the single + // choke point every repository passes through. + Log.handle(error, stackTrace, 'repository fetch/decode'); return Err(mapException(error)); } } diff --git a/lib/core/notifications/notification_channels.dart b/lib/core/notifications/notification_channels.dart index 4a075e0ab..79e73d2cc 100644 --- a/lib/core/notifications/notification_channels.dart +++ b/lib/core/notifications/notification_channels.dart @@ -414,6 +414,28 @@ abstract final class NotificationChannels { ), ]; + /// What [channel] will actually do when it fires. + /// + /// Derived from the definition rather than written out beside it, so a + /// channel whose importance or sound changes cannot end up described by a + /// sentence that used to be true. Coarse on purpose: the question this + /// answers is "will I hear this at three in the morning", and the four + /// outcomes below are the only distinctions that change that answer. + static NotificationBehaviour behaviourOf(NotificationChannel channel) { + // Checked before sound and importance because it outranks both: a critical + // alert sounds at a volume the user does not control, through a silent + // switch and through Do Not Disturb. + if (channel.criticalAlerts ?? false) return NotificationBehaviour.overrides; + if (!(channel.playSound ?? true)) return NotificationBehaviour.silent; + // High is the threshold at which Android shows a heads-up banner, and the + // enum is declared in Android's own IMPORTANCE_* order, so comparing + // indices is comparing the platform constants. + final importance = channel.importance ?? NotificationImportance.Default; + return importance.index >= NotificationImportance.High.index + ? 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. @@ -424,3 +446,41 @@ abstract final class NotificationChannels { return null; } } + +/// The coarse outcome of a channel firing — see +/// [NotificationChannels.behaviourOf]. +enum NotificationBehaviour { + /// Sounds through the silent switch and Do Not Disturb — an iOS critical + /// alert, or Android's alarm stream at Max importance. + /// + /// On iOS this is a promise the channel alone cannot keep: it also needs the + /// critical-alert grant, which the user can refuse. A caller showing this to + /// the user should check [NotificationService.criticalAllowed] before + /// presenting it as fact. + overrides, + + /// Sound, and a banner over whatever is on screen — **while the phone is + /// not silenced**. + /// + /// That caveat is the whole difference from [overrides] and it is not + /// cosmetic. awesome maps importance to an iOS interruption level in + /// `NotificationBuilder.setImportance` — High/Max become `.timeSensitive`, + /// Default becomes `.active` — and only the `.critical` level a + /// `criticalAlerts` channel gets bypasses the mute switch. The code that + /// attaches the sound is otherwise the *same branch* for both, so a channel + /// in this tier is not missing its sound on a muted phone: the sound is + /// attached and the OS declines to play it. + /// + /// Anything showing this to a user has to say so. Reported once as "震度速報 + /// and 地震報告 have no sound" against a muted phone, and every static check + /// — the asset, its format, the bundling, the channel definition — came back + /// clean, because nothing was wrong. + alerts, + + /// Sound but no banner — it waits in the notification list. Silenced with + /// the phone, exactly as [alerts] is. + sounds, + + /// No sound at all. + silent, +} diff --git a/lib/core/notifications/notification_samples.dart b/lib/core/notifications/notification_samples.dart new file mode 100644 index 000000000..88109344c --- /dev/null +++ b/lib/core/notifications/notification_samples.dart @@ -0,0 +1,107 @@ +/// The sample alert each channel's test notification posts. +/// +/// **Deliberately Traditional Chinese, and deliberately not localized.** These +/// are not the app's own words — they are reproductions of what the backend +/// actually sends, and the backend sends Chinese to every device regardless of +/// its language. Translating them would make the test show something the user +/// will never receive, which is the one thing a test must not do. The page's +/// own chrome around them is localized normally. +/// +/// Carried over verbatim from the legacy app's `assets/notify_test.json` so a +/// user who knew the old alerts recognises these. An asset is not needed: 21 +/// fixed strings compile into the binary, cost no I/O, and cannot be missing at +/// runtime the way a mis-declared asset can. +/// +/// Ideographic spaces are written as `\u3000` rather than pasted, because in +/// source they are indistinguishable from an ordinary space — and CWA's real +/// alerts use them for column alignment, so getting one wrong changes how the +/// sample reads. +library; + +/// One channel's sample alert. +typedef NotificationSample = ({String title, String body}); + +/// Sample alerts, keyed by `channelKey`. +/// +/// Covers the 21 push channels one-for-one. The locally-raised mesh channels +/// and the silent `background` service channel are absent on purpose: nothing +/// pushes them, so there is no server message to reproduce. +abstract final class NotificationSamples { + const NotificationSamples._(); + + /// The sample for [channelKey], or null for a channel with nothing to + /// reproduce. + static NotificationSample? of(String channelKey) => byChannel[channelKey]; + + static const Map byChannel = { + 'eew_alert-important-v2': ( + title: '🚨 《緊急地震速報 (氣象署發布) 》', + body: '花蓮縣壽豐鄉發生地震\u3000強烈搖晃警戒\n〈預估強烈搖晃地區〉\n花蓮\u3000南投\u3000臺東\u3000宜蘭', + ), + 'eew_alert-general-v2': ( + title: '🚨 《緊急地震速報 (氣象署發布) 》', + body: '花蓮縣壽豐鄉發生地震\u3000強烈搖晃警戒\n〈預估強烈搖晃地區〉\n花蓮\u3000南投\u3000臺東\u3000宜蘭', + ), + 'eew_alert-silent-v2': ( + title: '🚨 《緊急地震速報 (氣象署發布) 》', + body: '花蓮縣壽豐鄉發生地震\u3000強烈搖晃警戒\n〈預估強烈搖晃地區〉\n花蓮\u3000南投\u3000臺東\u3000宜蘭', + ), + 'eew-important-v2': ( + title: '⚠️ 地震速報', + body: '10:15左右,花蓮縣壽豐鄉發生地震。震源深度10公里,地震規模M6.1,最大預估震度4。', + ), + 'eew-general-v2': ( + title: '⚠️ 地震速報', + body: '10:15左右,花蓮縣壽豐鄉發生地震。震源深度10公里,地震規模M6.1,最大預估震度4。', + ), + 'eew-silence-v2': ( + title: '⚠️ 地震速報', + body: '10:15左右,花蓮縣壽豐鄉發生地震。震源深度10公里,地震規模M6.1,最大預估震度4。', + ), + 'int_report-general-v2': ( + title: '📨 震度速報 [07:36]', + body: '[震度 5弱]\u3000花蓮縣', + ), + 'int_report-silence-v2': ( + title: '📨 震度速報 [07:36]', + body: '[震度 5弱]\u3000花蓮縣', + ), + 'eq-v2': (title: '📡 強震監視器', body: '臺南市歸仁區\u3000偵測到晃動'), + 'report-general-v2': ( + title: '🔔 地震報告 [小區域有感地震]', + body: '00:36左右,花蓮縣近海發生地震。震源深度23.8公里,地震規模M4.0,花蓮縣觀測到最大震度2。', + ), + 'report-silence-v2': ( + title: '🔔 地震報告 [小區域有感地震]', + body: '00:36左右,花蓮縣近海發生地震。震源深度23.8公里,地震規模M4.0,花蓮縣觀測到最大震度2。', + ), + 'thunderstorm-important-v2': ( + title: '⛈️ 山區暴雨', + body: '您所在區域附近有暴雨發生的機率,留意溪水暴漲並儘速遠離溪流,持續至8/4 16:34', + ), + 'thunderstorm-general-v2': ( + title: '⛈️ 雷雨即時訊息', + body: '您所在區域附近有劇烈雷雨或降雨發生,請注意防範,持續至08/26 17:30', + ), + 'weather_major-important-v2': (title: '📊 臺南市歸仁區 天氣特報', body: '[發布]超大豪雨特報'), + 'weather_minor-general-v2': ( + title: '📊 臺南市歸仁區 天氣特報', + body: '[發布]大雨特報\n對流雲系發展旺盛,易有短延時強降雨,新北市已有豪雨發生,今(7日)晚至明(8日)晨基隆北海岸、彰化、雲林、南投、東半部地區及大臺北山區有局部大雨發生的機率,請注意雷擊及強陣風,山區慎防坍方、落石及溪水暴漲。', + ), + 'evacuation_major-important-v2': ( + title: '🌧️ 防災資訊(短時極端降雨紀錄)', + body: '臺南市永康區(CAN040 國一N323K) 1 小時累積雨量達到 91.5 mm/hr,請注意自身安全。', + ), + 'evacuation_minor-general-v2': ( + title: '⚠️ 防災資訊(河川水位-注意)', + body: '北寮橋 (水位 73.5m) 已達二級警戒,提高警覺,並密切注意水情變化。', + ), + 'tsunami-important-v2': (title: '🌊 海嘯警報發布', body: '海嘯警報已發布\n請儘速前往安全區域避難'), + 'tsunami-general-v2': (title: '🌊 海嘯警報發布', body: '海嘯警報已發布\n請儘速前往安全區域避難'), + 'tsunami-silent-v2': ( + title: '🌊 太平洋海嘯消息', + body: '頃獲太平洋海嘯警報中心通報,2024年08月18日03時10分(臺灣時間),俄羅斯\u3000堪察加半島東部外海發生規模7﹒4地震,震央位於東經160﹒10度、北緯52﹒70度。該中心研判可能在太平洋地區引發海嘯威脅,氣象署將嚴密監視海嘯的後續影響,隨時提供最新資訊。', + ), + 'announcement-general-v2': (title: '📢 公告', body: '這是一則測試公告。'), + }; +} diff --git a/lib/core/notifications/notification_service.dart b/lib/core/notifications/notification_service.dart index efd3ff8e2..c04a35af5 100644 --- a/lib/core/notifications/notification_service.dart +++ b/lib/core/notifications/notification_service.dart @@ -8,6 +8,7 @@ import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/permissions/permission_outcome.dart'; import 'package:dpip/core/permissions/system_settings.dart'; import 'package:dpip/core/notifications/notification_channels.dart'; +import 'package:dpip/core/notifications/notification_samples.dart'; import 'package:dpip/core/notifications/notification_taps.dart'; import 'package:dpip/core/notifications/plain_channels.dart'; import 'package:dpip/core/settings/setting_keys.dart'; @@ -251,6 +252,37 @@ class NotificationService { return openNotificationSettingsPage(); } + /// Posts [channelKey]'s sample alert locally, so the user can hear and see + /// what that channel actually does on this device. + /// + /// Local, not a round-trip through the backend: the thing being tested is the + /// last hop — the channel's sound, its importance, whether the OS lets it + /// through — and that hop is identical whether the notification came from + /// APNs or from here. Asking the server would only add a way for the test to + /// fail for a reason that has nothing to do with the answer. + /// + /// Returns false when the channel has no sample or the OS refused it. + /// + /// Two behaviours are deliberate rather than accidental: + /// + /// * **It rings for real.** A channel with `criticalAlerts` overrides the + /// silent switch and Do Not Disturb, at a volume the user does not control. + /// Nothing here softens that, because a test that is quieter than the real + /// alert answers the wrong question. The page warns before the tap instead. + /// * **Tapping it navigates**, through the same [NotificationTaps] route + /// table as a real alert — so the test covers the tap as well, and the user + /// lands wherever the real one would have taken them. + Future showTest(String channelKey) async { + final content = testNotificationContent(channelKey); + if (content == null) return false; + try { + return await AwesomeNotifications().createNotification(content: content); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'test notification for $channelKey'); + return false; + } + } + Future<({int total, int rejected})> _initChannels() async { final channels = NotificationChannels.channels; @@ -489,6 +521,69 @@ class NotificationService { } } +/// The markers legacy stamped on every test notification, kept verbatim. +/// +/// Not decoration, and not optional. The samples reproduce real CWA alerts +/// word for word, so an unmarked test is indistinguishable from the thing +/// itself: a 緊急地震速報 on a lock screen that nobody issued — photographed, +/// forwarded, and believed. The marker is the only thing standing between a +/// settings screen and a fake earthquake warning in circulation. +/// +/// Chinese, like the body it prefixes. The samples are Chinese because the +/// backend only sends Chinese; a localized marker glued to an unlocalized +/// alert would read as two different messages. +const String testTitlePrefix = '[測試] '; +const String testBodyMarker = '***這是測試訊息***'; + +/// The notification a test tap posts for [channelKey], or null when the +/// channel has no sample to reproduce. +/// +/// Split out from [NotificationService.showTest] so the part that decides what +/// the user sees can be tested without a platform channel — the same split +/// [contentFromData] already uses in this file. +NotificationContent? testNotificationContent(String channelKey) { + final sample = NotificationSamples.of(channelKey); + if (sample == null) return null; + return NotificationContent( + id: _testId(channelKey), + channelKey: channelKey, + title: '$testTitlePrefix${sample.title}', + // The separator is `
` on Android and a newline on iOS because awesome + // renders the Android body through `android.text.Html` (`Html.fromHtml` + // and the class itself are both in the shipped dex) and takes the iOS one + // literally. Legacy split it the same way; getting it wrong loses the line + // break rather than failing loudly. + // + // The sample's *own* newlines are left exactly as they are, on both + // platforms. On Android that means a multi-line CWA alert collapses to a + // single line — which is precisely what a real push from the backend does + // there, since it travels the same `Html.fromHtml` path. A test that read + // better than the alert it reproduces would be lying about the one thing + // it exists to show. + body: '$testBodyMarker${Platform.isIOS ? '\n' : '
'} ${sample.body}', + // The alerts this reproduces are several lines long; the default layout + // truncates them to one, which would make every sample look alike in the + // shade — the opposite of what the page is for. + notificationLayout: NotificationLayout.BigText, + ); +} + +/// A stable, per-channel notification id reserved for tests. +/// +/// Negative, and derived from the channel's position in the catalogue, for +/// two reasons. Server alerts carry the backend's own positive ids, so a test +/// can never overwrite a real alert sitting in the shade. And one id *per +/// channel* — rather than legacy's single shared id — means re-testing a +/// channel replaces its own previous sample instead of stacking up, while two +/// different channels stay side by side where they can be compared. Comparing +/// them is most of the point. +int _testId(String channelKey) { + final index = NotificationChannels.channels.indexWhere( + (channel) => channel.channelKey == channelKey, + ); + return -1000 - (index < 0 ? 0 : index); +} + /// Builds notification content from a message's `data` (preferred, legacy /// format) falling back to its `notification` block, or null when there's /// nothing to show. diff --git a/lib/features/bug_tracker/bug_tracker_counter.dart b/lib/features/bug_tracker/bug_tracker_counter.dart new file mode 100644 index 000000000..4b86cbcc3 --- /dev/null +++ b/lib/features/bug_tracker/bug_tracker_counter.dart @@ -0,0 +1,46 @@ +/// The reported-bug count for the More-tab badge. +/// +/// Loaded lazily the first time the More tab builds and cached for the session +/// — the index payload is small and ETag-cached, so a refresh is cheap, but a +/// counter that re-fetched on every rebuild would hammer the tracker host for +/// a number that changes a few times a week. [refresh] exists for the one flow +/// that should resync immediately: pulling to refresh on the list itself. +library; + +import 'package:dpip/features/bug_tracker/domain/bug_repository.dart'; +import 'package:flutter/foundation.dart'; + +class BugTrackerCounter extends ChangeNotifier { + BugTrackerCounter(this._repository); + + final BugRepository _repository; + + int? _count; + bool _loaded = false; + bool _loading = false; + + /// The number of reported bugs, or null while loading / after a failure. + /// + /// A failure stays null rather than caching zero: an unreachable tracker + /// must not read as "no bugs". + int? get count => _count; + + /// Fetches once per session; later calls are no-ops until [refresh]. + Future ensureLoaded() { + if (_loaded || _loading) return Future.value(); + return _load(); + } + + /// Re-fetches unconditionally — the pull-to-refresh path. + Future refresh() => _load(); + + Future _load() async { + if (_loading) return; + _loading = true; + final result = await _repository.threads(); + _loaded = true; + _loading = false; + _count = result.valueOrNull?.length ?? _count; + notifyListeners(); + } +} diff --git a/lib/features/bug_tracker/bug_tracker_providers.dart b/lib/features/bug_tracker/bug_tracker_providers.dart new file mode 100644 index 000000000..553f23394 --- /dev/null +++ b/lib/features/bug_tracker/bug_tracker_providers.dart @@ -0,0 +1,25 @@ +/// Bug-tracker feature providers. +library; + +import 'package:dpip/core/di/shared_deps.dart'; +import 'package:dpip/features/bug_tracker/bug_tracker_counter.dart'; +import 'package:dpip/features/bug_tracker/data/bug_api.dart'; +import 'package:dpip/features/bug_tracker/data/bug_repository_impl.dart'; +import 'package:dpip/features/bug_tracker/domain/bug_repository.dart'; +import 'package:provider/provider.dart'; +import 'package:provider/single_child_widget.dart'; + +/// Exposes the bug-tracker repository for the More → 已回報的錯誤 screen. +/// +/// The page depends on the domain interfaces only; the API-backed +/// implementation lives here, at the feature root, so the page never imports +/// a data layer. +List bugTrackerProviders(SharedDeps deps) { + final repository = BugRepositoryImpl(BugApi(deps.apiClient)); + return [ + Provider.value(value: repository), + ChangeNotifierProvider( + create: (_) => BugTrackerCounter(repository), + ), + ]; +} diff --git a/lib/features/bug_tracker/data/bug_api.dart b/lib/features/bug_tracker/data/bug_api.dart new file mode 100644 index 000000000..8da3ec27d --- /dev/null +++ b/lib/features/bug_tracker/data/bug_api.dart @@ -0,0 +1,32 @@ +/// The Discord bug-tracker mirror API. +library; + +import 'package:dpip/core/network/api_client.dart'; + +/// Reads the reported-bug threads from the tracker host. +/// +/// Absolute URL on purpose: `bamboo.exptech.dev` is a single host outside the +/// region system, and [ApiClient.getAbsolute] still runs the request through +/// the shared Dio stack — ETag revalidation, the SQLite body store, and +/// transparent gzip decoding when the server sends it. +class BugApi { + const BugApi(this._client); + + final ApiClient _client; + + static const String _base = 'https://bamboo.exptech.dev/api/dc/bug'; + + /// Every thread in the index, capped at 50 so the payload stays bounded as + /// the tracker grows. The query rides the URL, so the ETag store keys it as + /// its own resource. + Future list() => + _client.getAbsolute(_base, query: const {'limit': 50}); + + /// One thread with its replies. + Future thread(int id) => _client.getAbsolute('$_base/$id'); + + /// Raw avatar bytes through the shared stack — ETag revalidation with the + /// CDN's own tags, transparent gzip decoding, and the SQLite body store as + /// the offline copy. Same semantics as every other cacheable GET. + Future avatar(String url) => _client.getBytesAbsolute(url); +} diff --git a/lib/features/bug_tracker/data/bug_repository_impl.dart b/lib/features/bug_tracker/data/bug_repository_impl.dart new file mode 100644 index 000000000..cfb821be6 --- /dev/null +++ b/lib/features/bug_tracker/data/bug_repository_impl.dart @@ -0,0 +1,166 @@ +/// [BugRepository] backed by the tracker mirror API, plus the wire → domain +/// parsers exposed for tests. +library; + +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/network/api_exception.dart'; +import 'package:dpip/features/bug_tracker/data/bug_api.dart'; +import 'package:dpip/features/bug_tracker/domain/bug_thread.dart'; +import 'package:dpip/features/bug_tracker/domain/bug_repository.dart'; +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'; + +class BugRepositoryImpl implements BugRepository { + const BugRepositoryImpl(this._api); + + final BugApi _api; + + @override + Future>> threads() => + guardResult(() async => parseBugThreads(await _api.list())); + + @override + Future> thread(int id) => + guardResult(() async => parseBugThreadDetail(await _api.thread(id))); + + @override + Future> avatar(String url) => + guardResult(() async => (await _api.avatar(url)).bytes); +} + +/// Discord custom-emote tokens (`<:name:id>`, ``) read as `:name:`: +/// the numeric part is meaningless outside Discord and the angle brackets make +/// otherwise plain text look like broken HTML. +final RegExp _emoteToken = RegExp(r''); + +String _normalise(String body) => body.replaceAllMapped( + _emoteToken, + (match) => match.group(1) ?? match.input, +); + +Map _asObject(Object? value, String what) { + if (value is Map) return Map.from(value); + throw FormatException('bug tracker: $what expected an object'); +} + +List _parseMessages(Object? raw) { + if (raw == null) return const []; + if (raw is! List) { + throw const FormatException('bug tracker: msg expected an array'); + } + return [ + for (final entry in raw) + _normaliseInto(BugMessage.fromJson(_asObject(entry, 'message'))), + ]; +} + +BugMessage _normaliseInto(BugMessage message) => message.body == null + ? message + : message.copyWith(body: _normalise(message.body!)); + +BugThread _normaliseIntoThread(BugThread thread) => + thread.copyWith(body: _normalise(thread.body)); + +/// Maps the index reply into threads. Tolerates a missing or malformed entry +/// no better than the type boundary does — one broken row means a broken +/// source, and hiding it would silently shorten the list. +/// Resolves the `users` map the updated API ships alongside every payload: +/// author identity moved OUT of thread/message objects into this directory, +/// keyed by Discord snowflake. +/// Resolves the `users` directory the updated API ships alongside every +/// payload: author identity moved OUT of thread/message objects into this +/// map, keyed by Discord snowflake. +Map _parseUsers(Object? raw) { + if (raw is! Map) return const {}; + final users = {}; + for (final entry in raw.entries) { + final value = entry.value; // MapEntry 欄位無法晉升型別,先落地 + final id = int.tryParse('${entry.key}'); + if (id == null || value is! Map) continue; + final user = Map.from(value); + users[id] = ( + name: user['name'] is String ? user['name'] as String : '', + avatar: user['img'] is String ? user['img'] as String : '', + ); + } + return users; +} + +({String name, String avatar}) _authorOf( + Map users, + Object? authorId, +) { + final id = int.tryParse('$authorId'); + final user = id == null ? null : users[id]; + return (name: user?.name ?? '', avatar: user?.avatar ?? ''); +} + +/// Maps the index reply into threads. +/// +/// New contract: the payload carries a `users` directory plus `threads` whose +/// author fields hold only an id — display identity resolves here, in the +/// data layer, so the domain model and UI never see the indirection. +List parseBugThreads(Object? body) { + final map = _asObject(body, 'index'); + final users = _parseUsers(map['users']); + final raw = map['threads']; + if (raw is! List) { + throw const FormatException('bug tracker: threads expected an array'); + } + var threads = [ + 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. + threads.removeWhere( + (thread) => thread.locked || !thread.tags.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 + // 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, + ], + authorName: _authorOf(users, thread.author).name, + authorAvatar: _authorOf(users, thread.author).avatar, + ), + ]; + // The conversation replied to most recently leads. + threads.sort((a, b) => b.lastMessageId.compareTo(a.lastMessageId)); + return threads; +} + +/// Maps the detail reply: thread fields plus its `msg` reply array, resolving +/// authors through the same directory as [parseBugThreads]. +BugThreadDetail parseBugThreadDetail(Object? body) { + final map = _asObject(body, 'thread'); + 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, + ], + authorName: opAuthor.name, + authorAvatar: opAuthor.avatar, + ); + final messages = []; + for (final message in _parseMessages(map['msg'])) { + final info = _authorOf(users, message.author); + messages.add( + message.copyWith(authorName: info.name, authorAvatar: info.avatar), + ); + } + return BugThreadDetail(thread: thread, messages: messages); +} diff --git a/lib/features/bug_tracker/domain/bug_repository.dart b/lib/features/bug_tracker/domain/bug_repository.dart new file mode 100644 index 000000000..dc11656ff --- /dev/null +++ b/lib/features/bug_tracker/domain/bug_repository.dart @@ -0,0 +1,23 @@ +/// Bug-tracker repository contract — read-only by design. +library; + +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/features/bug_tracker/domain/bug_thread.dart'; + +import 'dart:typed_data'; + +/// The reported-bug threads mirrored from the Discord tracker. +/// +/// There is deliberately no way to create, reply, close or tag: the tracker is +/// a window for app users to see what has already been reported and what the +/// team said about it. Writing happens on Discord, where triage lives. +abstract class BugRepository { + /// Every open thread, newest first as the source reports them. + Future>> threads(); + + /// One thread with its full reply history. + Future> thread(int id); + + /// One avatar's bytes — fetched through the shared ETag/gzip stack. + Future> avatar(String url); +} diff --git a/lib/features/bug_tracker/domain/bug_thread.dart b/lib/features/bug_tracker/domain/bug_thread.dart new file mode 100644 index 000000000..5aff7cfc9 --- /dev/null +++ b/lib/features/bug_tracker/domain/bug_thread.dart @@ -0,0 +1,96 @@ +/// 已回報錯誤的資料模型 — the Discord bug-tracker mirror, read-only. +library; + +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'bug_thread.freezed.dart'; +part 'bug_thread.g.dart'; + +/// Reads a wire string that may be absent or null into a displayable one. +/// +/// The mirror reflects Discord state verbatim, and Discord lets an author +/// delete an opening post while the thread survives — such a thread arrives +/// with `"body": null`. One deleted post must not kill the whole index. +class LooseString implements JsonConverter { + const LooseString(); + + @override + String fromJson(Object? value) => value is String ? value : ''; + + @override + Object? toJson(String value) => value; +} + +/// Converts Unix-seconds timestamps from the wire into UTC DateTimes. +class UnixSecondsDateTime implements JsonConverter { + const UnixSecondsDateTime(); + + @override + DateTime fromJson(int seconds) => + DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true); + + @override + 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); + +/// One staff/victim reply inside a reported-bug thread. +@freezed +abstract class BugMessage with _$BugMessage { + const factory BugMessage({ + required int id, + required int author, + + /// Display name as the source shows it — Discord nicknames arrive with + /// their location suffixes (`・ω・ (竹子) ⇛ 新竹竹東`) and are kept verbatim. + @JsonKey(name: 'author_name') @LooseString() required String authorName, + @JsonKey(name: 'author_avatar') @LooseString() required String authorAvatar, + + /// The reply text, with Discord custom-emote tokens normalised to their + /// readable `:name:` form at parse time. + @JsonKey(name: 'msg') required String? body, + @UnixSecondsDateTime() @JsonKey(name: 'time') required DateTime time, + }) = _BugMessage; + + factory BugMessage.fromJson(Map json) => + _$BugMessageFromJson(json); +} + +/// A reported bug, as listed in the tracker index. +@freezed +abstract class BugThread with _$BugThread { + const factory BugThread({ + @JsonKey(name: 'threads_id') required int id, + @LooseString() required String title, + @Default([]) List tags, + @LooseString() required String body, + @JsonKey(name: 'author') required int author, + @JsonKey(name: 'author_name') @LooseString() required String authorName, + @JsonKey(name: 'author_avatar') @LooseString() required String authorAvatar, + @UnixSecondsDateTime() + @JsonKey(name: 'created_at') + required DateTime createdAt, + @JsonKey(name: 'message_count') @Default(0) int messageCount, + @Default(false) bool archived, + @Default(false) bool locked, + @JsonKey(name: 'last_message_id') @Default(0) int lastMessageId, + }) = _BugThread; + + factory BugThread.fromJson(Map json) => + _$BugThreadFromJson(json); +} + +/// A thread plus its full reply history — what the detail endpoint returns. +@freezed +abstract class BugThreadDetail with _$BugThreadDetail { + const factory BugThreadDetail({ + required BugThread thread, + @Default([]) List messages, + }) = _BugThreadDetail; +} diff --git a/lib/features/bug_tracker/domain/bug_thread.freezed.dart b/lib/features/bug_tracker/domain/bug_thread.freezed.dart new file mode 100644 index 000000000..6b3e0a548 --- /dev/null +++ b/lib/features/bug_tracker/domain/bug_thread.freezed.dart @@ -0,0 +1,887 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'bug_thread.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$BugMessage { + + int get id; int get author;/// Display name as the source shows it — Discord nicknames arrive with +/// their location suffixes (`・ω・ (竹子) ⇛ 新竹竹東`) and are kept verbatim. +@JsonKey(name: 'author_name')@LooseString() String get authorName;@JsonKey(name: 'author_avatar')@LooseString() String get authorAvatar;/// The reply text, with Discord custom-emote tokens normalised to their +/// readable `:name:` form at parse time. +@JsonKey(name: 'msg') String? get body;@UnixSecondsDateTime()@JsonKey(name: 'time') DateTime get time; +/// Create a copy of BugMessage +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BugMessageCopyWith get copyWith => _$BugMessageCopyWithImpl(this as BugMessage, _$identity); + + /// Serializes this BugMessage to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BugMessage&&(identical(other.id, id) || other.id == id)&&(identical(other.author, author) || other.author == author)&&(identical(other.authorName, authorName) || other.authorName == authorName)&&(identical(other.authorAvatar, authorAvatar) || other.authorAvatar == authorAvatar)&&(identical(other.body, body) || other.body == body)&&(identical(other.time, time) || other.time == time)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,author,authorName,authorAvatar,body,time); + +@override +String toString() { + return 'BugMessage(id: $id, author: $author, authorName: $authorName, authorAvatar: $authorAvatar, body: $body, time: $time)'; +} + + +} + +/// @nodoc +abstract mixin class $BugMessageCopyWith<$Res> { + factory $BugMessageCopyWith(BugMessage value, $Res Function(BugMessage) _then) = _$BugMessageCopyWithImpl; +@useResult +$Res call({ + int id, int author,@JsonKey(name: 'author_name')@LooseString() String authorName,@JsonKey(name: 'author_avatar')@LooseString() String authorAvatar,@JsonKey(name: 'msg') String? body,@UnixSecondsDateTime()@JsonKey(name: 'time') DateTime time +}); + + + + +} +/// @nodoc +class _$BugMessageCopyWithImpl<$Res> + implements $BugMessageCopyWith<$Res> { + _$BugMessageCopyWithImpl(this._self, this._then); + + final BugMessage _self; + final $Res Function(BugMessage) _then; + +/// Create a copy of BugMessage +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? author = null,Object? authorName = null,Object? authorAvatar = null,Object? body = freezed,Object? time = null,}) { + return _then(BugMessage( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,author: null == author ? _self.author : author // ignore: cast_nullable_to_non_nullable +as int,authorName: null == authorName ? _self.authorName : authorName // ignore: cast_nullable_to_non_nullable +as String,authorAvatar: null == authorAvatar ? _self.authorAvatar : authorAvatar // ignore: cast_nullable_to_non_nullable +as String,body: freezed == body ? _self.body : body // ignore: cast_nullable_to_non_nullable +as String?,time: null == time ? _self.time : time // ignore: cast_nullable_to_non_nullable +as DateTime, + )); +} + +} + + +/// Adds pattern-matching-related methods to [BugMessage]. +extension BugMessagePatterns on BugMessage { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _BugMessage value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _BugMessage() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _BugMessage value) $default,){ +final _that = this; +switch (_that) { +case _BugMessage(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _BugMessage value)? $default,){ +final _that = this; +switch (_that) { +case _BugMessage() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( int id, int author, @JsonKey(name: 'author_name')@LooseString() String authorName, @JsonKey(name: 'author_avatar')@LooseString() String authorAvatar, @JsonKey(name: 'msg') String? body, @UnixSecondsDateTime()@JsonKey(name: 'time') DateTime time)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _BugMessage() when $default != null: +return $default(_that.id,_that.author,_that.authorName,_that.authorAvatar,_that.body,_that.time);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( int id, int author, @JsonKey(name: 'author_name')@LooseString() String authorName, @JsonKey(name: 'author_avatar')@LooseString() String authorAvatar, @JsonKey(name: 'msg') String? body, @UnixSecondsDateTime()@JsonKey(name: 'time') DateTime time) $default,) {final _that = this; +switch (_that) { +case _BugMessage(): +return $default(_that.id,_that.author,_that.authorName,_that.authorAvatar,_that.body,_that.time);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( int id, int author, @JsonKey(name: 'author_name')@LooseString() String authorName, @JsonKey(name: 'author_avatar')@LooseString() String authorAvatar, @JsonKey(name: 'msg') String? body, @UnixSecondsDateTime()@JsonKey(name: 'time') DateTime time)? $default,) {final _that = this; +switch (_that) { +case _BugMessage() when $default != null: +return $default(_that.id,_that.author,_that.authorName,_that.authorAvatar,_that.body,_that.time);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _BugMessage implements BugMessage { + const _BugMessage({required this.id, required this.author, @JsonKey(name: 'author_name')@LooseString() required this.authorName, @JsonKey(name: 'author_avatar')@LooseString() required this.authorAvatar, @JsonKey(name: 'msg') required this.body, @UnixSecondsDateTime()@JsonKey(name: 'time') required this.time}); + factory _BugMessage.fromJson(Map json) => _$BugMessageFromJson(json); + +@override final int id; +@override final int author; +/// Display name as the source shows it — Discord nicknames arrive with +/// their location suffixes (`・ω・ (竹子) ⇛ 新竹竹東`) and are kept verbatim. +@override@JsonKey(name: 'author_name')@LooseString() final String authorName; +@override@JsonKey(name: 'author_avatar')@LooseString() final String authorAvatar; +/// The reply text, with Discord custom-emote tokens normalised to their +/// readable `:name:` form at parse time. +@override@JsonKey(name: 'msg') final String? body; +@override@UnixSecondsDateTime()@JsonKey(name: 'time') final DateTime time; + +/// Create a copy of BugMessage +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$BugMessageCopyWith<_BugMessage> get copyWith => __$BugMessageCopyWithImpl<_BugMessage>(this, _$identity); + +@override +Map toJson() { + return _$BugMessageToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _BugMessage&&(identical(other.id, id) || other.id == id)&&(identical(other.author, author) || other.author == author)&&(identical(other.authorName, authorName) || other.authorName == authorName)&&(identical(other.authorAvatar, authorAvatar) || other.authorAvatar == authorAvatar)&&(identical(other.body, body) || other.body == body)&&(identical(other.time, time) || other.time == time)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,author,authorName,authorAvatar,body,time); + +@override +String toString() { + return 'BugMessage(id: $id, author: $author, authorName: $authorName, authorAvatar: $authorAvatar, body: $body, time: $time)'; +} + + +} + +/// @nodoc +abstract mixin class _$BugMessageCopyWith<$Res> implements $BugMessageCopyWith<$Res> { + factory _$BugMessageCopyWith(_BugMessage value, $Res Function(_BugMessage) _then) = __$BugMessageCopyWithImpl; +@override @useResult +$Res call({ + int id, int author,@JsonKey(name: 'author_name')@LooseString() String authorName,@JsonKey(name: 'author_avatar')@LooseString() String authorAvatar,@JsonKey(name: 'msg') String? body,@UnixSecondsDateTime()@JsonKey(name: 'time') DateTime time +}); + + + + +} +/// @nodoc +class __$BugMessageCopyWithImpl<$Res> + implements _$BugMessageCopyWith<$Res> { + __$BugMessageCopyWithImpl(this._self, this._then); + + final _BugMessage _self; + final $Res Function(_BugMessage) _then; + +/// Create a copy of BugMessage +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? author = null,Object? authorName = null,Object? authorAvatar = null,Object? body = freezed,Object? time = null,}) { + return _then(_BugMessage( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,author: null == author ? _self.author : author // ignore: cast_nullable_to_non_nullable +as int,authorName: null == authorName ? _self.authorName : authorName // ignore: cast_nullable_to_non_nullable +as String,authorAvatar: null == authorAvatar ? _self.authorAvatar : authorAvatar // ignore: cast_nullable_to_non_nullable +as String,body: freezed == body ? _self.body : body // ignore: cast_nullable_to_non_nullable +as String?,time: null == time ? _self.time : time // ignore: cast_nullable_to_non_nullable +as DateTime, + )); +} + + +} + + +/// @nodoc +mixin _$BugThread { + +@JsonKey(name: 'threads_id') int get id;@LooseString() String get title; List get tags;@LooseString() String get body;@JsonKey(name: 'author') int get author;@JsonKey(name: 'author_name')@LooseString() String get authorName;@JsonKey(name: 'author_avatar')@LooseString() String get authorAvatar;@UnixSecondsDateTime()@JsonKey(name: 'created_at') DateTime get createdAt;@JsonKey(name: 'message_count') int get messageCount; bool get archived; bool get locked;@JsonKey(name: 'last_message_id') int get lastMessageId; +/// Create a copy of BugThread +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BugThreadCopyWith get copyWith => _$BugThreadCopyWithImpl(this as BugThread, _$identity); + + /// Serializes this BugThread to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BugThread&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&const DeepCollectionEquality().equals(other.tags, tags)&&(identical(other.body, body) || other.body == body)&&(identical(other.author, author) || other.author == author)&&(identical(other.authorName, authorName) || other.authorName == authorName)&&(identical(other.authorAvatar, authorAvatar) || other.authorAvatar == authorAvatar)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.messageCount, messageCount) || other.messageCount == messageCount)&&(identical(other.archived, archived) || other.archived == archived)&&(identical(other.locked, locked) || other.locked == locked)&&(identical(other.lastMessageId, lastMessageId) || other.lastMessageId == lastMessageId)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,title,const DeepCollectionEquality().hash(tags),body,author,authorName,authorAvatar,createdAt,messageCount,archived,locked,lastMessageId); + +@override +String toString() { + return 'BugThread(id: $id, title: $title, tags: $tags, body: $body, author: $author, authorName: $authorName, authorAvatar: $authorAvatar, createdAt: $createdAt, messageCount: $messageCount, archived: $archived, locked: $locked, lastMessageId: $lastMessageId)'; +} + + +} + +/// @nodoc +abstract mixin class $BugThreadCopyWith<$Res> { + factory $BugThreadCopyWith(BugThread value, $Res Function(BugThread) _then) = _$BugThreadCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: 'threads_id') int id,@LooseString() String title, List tags,@LooseString() String body,@JsonKey(name: 'author') int author,@JsonKey(name: 'author_name')@LooseString() String authorName,@JsonKey(name: 'author_avatar')@LooseString() String authorAvatar,@UnixSecondsDateTime()@JsonKey(name: 'created_at') DateTime createdAt,@JsonKey(name: 'message_count') int messageCount, bool archived, bool locked,@JsonKey(name: 'last_message_id') int lastMessageId +}); + + + + +} +/// @nodoc +class _$BugThreadCopyWithImpl<$Res> + implements $BugThreadCopyWith<$Res> { + _$BugThreadCopyWithImpl(this._self, this._then); + + final BugThread _self; + final $Res Function(BugThread) _then; + +/// Create a copy of BugThread +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = null,Object? tags = null,Object? body = null,Object? author = null,Object? authorName = null,Object? authorAvatar = null,Object? createdAt = null,Object? messageCount = null,Object? archived = null,Object? locked = null,Object? lastMessageId = null,}) { + return _then(BugThread( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,tags: null == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable +as List,body: null == body ? _self.body : body // ignore: cast_nullable_to_non_nullable +as String,author: null == author ? _self.author : author // ignore: cast_nullable_to_non_nullable +as int,authorName: null == authorName ? _self.authorName : authorName // ignore: cast_nullable_to_non_nullable +as String,authorAvatar: null == authorAvatar ? _self.authorAvatar : authorAvatar // ignore: cast_nullable_to_non_nullable +as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,messageCount: null == messageCount ? _self.messageCount : messageCount // ignore: cast_nullable_to_non_nullable +as int,archived: null == archived ? _self.archived : archived // ignore: cast_nullable_to_non_nullable +as bool,locked: null == locked ? _self.locked : locked // ignore: cast_nullable_to_non_nullable +as bool,lastMessageId: null == lastMessageId ? _self.lastMessageId : lastMessageId // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [BugThread]. +extension BugThreadPatterns on BugThread { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _BugThread value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _BugThread() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _BugThread value) $default,){ +final _that = this; +switch (_that) { +case _BugThread(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _BugThread value)? $default,){ +final _that = this; +switch (_that) { +case _BugThread() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: 'threads_id') int id, @LooseString() String title, List tags, @LooseString() String body, @JsonKey(name: 'author') int author, @JsonKey(name: 'author_name')@LooseString() String authorName, @JsonKey(name: 'author_avatar')@LooseString() String authorAvatar, @UnixSecondsDateTime()@JsonKey(name: 'created_at') DateTime createdAt, @JsonKey(name: 'message_count') int messageCount, bool archived, bool locked, @JsonKey(name: 'last_message_id') int lastMessageId)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _BugThread() when $default != null: +return $default(_that.id,_that.title,_that.tags,_that.body,_that.author,_that.authorName,_that.authorAvatar,_that.createdAt,_that.messageCount,_that.archived,_that.locked,_that.lastMessageId);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: 'threads_id') int id, @LooseString() String title, List tags, @LooseString() String body, @JsonKey(name: 'author') int author, @JsonKey(name: 'author_name')@LooseString() String authorName, @JsonKey(name: 'author_avatar')@LooseString() String authorAvatar, @UnixSecondsDateTime()@JsonKey(name: 'created_at') DateTime createdAt, @JsonKey(name: 'message_count') int messageCount, bool archived, bool locked, @JsonKey(name: 'last_message_id') int lastMessageId) $default,) {final _that = this; +switch (_that) { +case _BugThread(): +return $default(_that.id,_that.title,_that.tags,_that.body,_that.author,_that.authorName,_that.authorAvatar,_that.createdAt,_that.messageCount,_that.archived,_that.locked,_that.lastMessageId);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: 'threads_id') int id, @LooseString() String title, List tags, @LooseString() String body, @JsonKey(name: 'author') int author, @JsonKey(name: 'author_name')@LooseString() String authorName, @JsonKey(name: 'author_avatar')@LooseString() String authorAvatar, @UnixSecondsDateTime()@JsonKey(name: 'created_at') DateTime createdAt, @JsonKey(name: 'message_count') int messageCount, bool archived, bool locked, @JsonKey(name: 'last_message_id') int lastMessageId)? $default,) {final _that = this; +switch (_that) { +case _BugThread() when $default != null: +return $default(_that.id,_that.title,_that.tags,_that.body,_that.author,_that.authorName,_that.authorAvatar,_that.createdAt,_that.messageCount,_that.archived,_that.locked,_that.lastMessageId);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _BugThread implements BugThread { + const _BugThread({@JsonKey(name: 'threads_id') required this.id, @LooseString() required this.title, List tags = const [], @LooseString() required this.body, @JsonKey(name: 'author') required this.author, @JsonKey(name: 'author_name')@LooseString() required this.authorName, @JsonKey(name: 'author_avatar')@LooseString() required this.authorAvatar, @UnixSecondsDateTime()@JsonKey(name: 'created_at') required this.createdAt, @JsonKey(name: 'message_count') this.messageCount = 0, this.archived = false, this.locked = false, @JsonKey(name: 'last_message_id') this.lastMessageId = 0}): _tags = tags; + factory _BugThread.fromJson(Map json) => _$BugThreadFromJson(json); + +@override@JsonKey(name: 'threads_id') final int id; +@override@LooseString() final String title; + final List _tags; +@override@JsonKey() List get tags { + if (_tags is EqualUnmodifiableListView) return _tags; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_tags); +} + +@override@LooseString() final String body; +@override@JsonKey(name: 'author') final int author; +@override@JsonKey(name: 'author_name')@LooseString() final String authorName; +@override@JsonKey(name: 'author_avatar')@LooseString() final String authorAvatar; +@override@UnixSecondsDateTime()@JsonKey(name: 'created_at') final DateTime createdAt; +@override@JsonKey(name: 'message_count') final int messageCount; +@override@JsonKey() final bool archived; +@override@JsonKey() final bool locked; +@override@JsonKey(name: 'last_message_id') final int lastMessageId; + +/// Create a copy of BugThread +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$BugThreadCopyWith<_BugThread> get copyWith => __$BugThreadCopyWithImpl<_BugThread>(this, _$identity); + +@override +Map toJson() { + return _$BugThreadToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _BugThread&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&const DeepCollectionEquality().equals(other._tags, _tags)&&(identical(other.body, body) || other.body == body)&&(identical(other.author, author) || other.author == author)&&(identical(other.authorName, authorName) || other.authorName == authorName)&&(identical(other.authorAvatar, authorAvatar) || other.authorAvatar == authorAvatar)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.messageCount, messageCount) || other.messageCount == messageCount)&&(identical(other.archived, archived) || other.archived == archived)&&(identical(other.locked, locked) || other.locked == locked)&&(identical(other.lastMessageId, lastMessageId) || other.lastMessageId == lastMessageId)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,title,const DeepCollectionEquality().hash(_tags),body,author,authorName,authorAvatar,createdAt,messageCount,archived,locked,lastMessageId); + +@override +String toString() { + return 'BugThread(id: $id, title: $title, tags: $tags, body: $body, author: $author, authorName: $authorName, authorAvatar: $authorAvatar, createdAt: $createdAt, messageCount: $messageCount, archived: $archived, locked: $locked, lastMessageId: $lastMessageId)'; +} + + +} + +/// @nodoc +abstract mixin class _$BugThreadCopyWith<$Res> implements $BugThreadCopyWith<$Res> { + factory _$BugThreadCopyWith(_BugThread value, $Res Function(_BugThread) _then) = __$BugThreadCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: 'threads_id') int id,@LooseString() String title, List tags,@LooseString() String body,@JsonKey(name: 'author') int author,@JsonKey(name: 'author_name')@LooseString() String authorName,@JsonKey(name: 'author_avatar')@LooseString() String authorAvatar,@UnixSecondsDateTime()@JsonKey(name: 'created_at') DateTime createdAt,@JsonKey(name: 'message_count') int messageCount, bool archived, bool locked,@JsonKey(name: 'last_message_id') int lastMessageId +}); + + + + +} +/// @nodoc +class __$BugThreadCopyWithImpl<$Res> + implements _$BugThreadCopyWith<$Res> { + __$BugThreadCopyWithImpl(this._self, this._then); + + final _BugThread _self; + final $Res Function(_BugThread) _then; + +/// Create a copy of BugThread +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = null,Object? tags = null,Object? body = null,Object? author = null,Object? authorName = null,Object? authorAvatar = null,Object? createdAt = null,Object? messageCount = null,Object? archived = null,Object? locked = null,Object? lastMessageId = null,}) { + return _then(_BugThread( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as int,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,tags: null == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable +as List,body: null == body ? _self.body : body // ignore: cast_nullable_to_non_nullable +as String,author: null == author ? _self.author : author // ignore: cast_nullable_to_non_nullable +as int,authorName: null == authorName ? _self.authorName : authorName // ignore: cast_nullable_to_non_nullable +as String,authorAvatar: null == authorAvatar ? _self.authorAvatar : authorAvatar // ignore: cast_nullable_to_non_nullable +as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,messageCount: null == messageCount ? _self.messageCount : messageCount // ignore: cast_nullable_to_non_nullable +as int,archived: null == archived ? _self.archived : archived // ignore: cast_nullable_to_non_nullable +as bool,locked: null == locked ? _self.locked : locked // ignore: cast_nullable_to_non_nullable +as bool,lastMessageId: null == lastMessageId ? _self.lastMessageId : lastMessageId // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +/// @nodoc +mixin _$BugThreadDetail { + + BugThread get thread; List get messages; +/// Create a copy of BugThreadDetail +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BugThreadDetailCopyWith get copyWith => _$BugThreadDetailCopyWithImpl(this as BugThreadDetail, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BugThreadDetail&&(identical(other.thread, thread) || other.thread == thread)&&const DeepCollectionEquality().equals(other.messages, messages)); +} + + +@override +int get hashCode => Object.hash(runtimeType,thread,const DeepCollectionEquality().hash(messages)); + +@override +String toString() { + return 'BugThreadDetail(thread: $thread, messages: $messages)'; +} + + +} + +/// @nodoc +abstract mixin class $BugThreadDetailCopyWith<$Res> { + factory $BugThreadDetailCopyWith(BugThreadDetail value, $Res Function(BugThreadDetail) _then) = _$BugThreadDetailCopyWithImpl; +@useResult +$Res call({ + BugThread thread, List messages +}); + + +$BugThreadCopyWith<$Res> get thread; + +} +/// @nodoc +class _$BugThreadDetailCopyWithImpl<$Res> + implements $BugThreadDetailCopyWith<$Res> { + _$BugThreadDetailCopyWithImpl(this._self, this._then); + + final BugThreadDetail _self; + final $Res Function(BugThreadDetail) _then; + +/// Create a copy of BugThreadDetail +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? thread = null,Object? messages = null,}) { + return _then(BugThreadDetail( +thread: null == thread ? _self.thread : thread // ignore: cast_nullable_to_non_nullable +as BugThread,messages: null == messages ? _self.messages : messages // ignore: cast_nullable_to_non_nullable +as List, + )); +} +/// Create a copy of BugThreadDetail +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$BugThreadCopyWith<$Res> get thread { + + return $BugThreadCopyWith<$Res>(_self.thread, (value) { + return _then(_self.copyWith(thread: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [BugThreadDetail]. +extension BugThreadDetailPatterns on BugThreadDetail { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _BugThreadDetail value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _BugThreadDetail() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _BugThreadDetail value) $default,){ +final _that = this; +switch (_that) { +case _BugThreadDetail(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _BugThreadDetail value)? $default,){ +final _that = this; +switch (_that) { +case _BugThreadDetail() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( BugThread thread, List messages)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _BugThreadDetail() when $default != null: +return $default(_that.thread,_that.messages);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( BugThread thread, List messages) $default,) {final _that = this; +switch (_that) { +case _BugThreadDetail(): +return $default(_that.thread,_that.messages);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( BugThread thread, List messages)? $default,) {final _that = this; +switch (_that) { +case _BugThreadDetail() when $default != null: +return $default(_that.thread,_that.messages);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _BugThreadDetail implements BugThreadDetail { + const _BugThreadDetail({required this.thread, List messages = const []}): _messages = messages; + + +@override final BugThread thread; + final List _messages; +@override@JsonKey() List get messages { + if (_messages is EqualUnmodifiableListView) return _messages; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_messages); +} + + +/// Create a copy of BugThreadDetail +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$BugThreadDetailCopyWith<_BugThreadDetail> get copyWith => __$BugThreadDetailCopyWithImpl<_BugThreadDetail>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _BugThreadDetail&&(identical(other.thread, thread) || other.thread == thread)&&const DeepCollectionEquality().equals(other._messages, _messages)); +} + + +@override +int get hashCode => Object.hash(runtimeType,thread,const DeepCollectionEquality().hash(_messages)); + +@override +String toString() { + return 'BugThreadDetail(thread: $thread, messages: $messages)'; +} + + +} + +/// @nodoc +abstract mixin class _$BugThreadDetailCopyWith<$Res> implements $BugThreadDetailCopyWith<$Res> { + factory _$BugThreadDetailCopyWith(_BugThreadDetail value, $Res Function(_BugThreadDetail) _then) = __$BugThreadDetailCopyWithImpl; +@override @useResult +$Res call({ + BugThread thread, List messages +}); + + +@override $BugThreadCopyWith<$Res> get thread; + +} +/// @nodoc +class __$BugThreadDetailCopyWithImpl<$Res> + implements _$BugThreadDetailCopyWith<$Res> { + __$BugThreadDetailCopyWithImpl(this._self, this._then); + + final _BugThreadDetail _self; + final $Res Function(_BugThreadDetail) _then; + +/// Create a copy of BugThreadDetail +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? thread = null,Object? messages = null,}) { + return _then(_BugThreadDetail( +thread: null == thread ? _self.thread : thread // ignore: cast_nullable_to_non_nullable +as BugThread,messages: null == messages ? _self._messages : messages // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +/// Create a copy of BugThreadDetail +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$BugThreadCopyWith<$Res> get thread { + + return $BugThreadCopyWith<$Res>(_self.thread, (value) { + return _then(_self.copyWith(thread: value)); + }); +} +} + +// dart format on diff --git a/lib/features/bug_tracker/domain/bug_thread.g.dart b/lib/features/bug_tracker/domain/bug_thread.g.dart new file mode 100644 index 000000000..7ce5eccb5 --- /dev/null +++ b/lib/features/bug_tracker/domain/bug_thread.g.dart @@ -0,0 +1,61 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'bug_thread.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_BugMessage _$BugMessageFromJson(Map json) => _BugMessage( + id: (json['id'] as num).toInt(), + author: (json['author'] as num).toInt(), + authorName: const LooseString().fromJson(json['author_name']), + authorAvatar: const LooseString().fromJson(json['author_avatar']), + body: json['msg'] as String?, + time: const UnixSecondsDateTime().fromJson((json['time'] as num).toInt()), +); + +Map _$BugMessageToJson(_BugMessage instance) => + { + 'id': instance.id, + 'author': instance.author, + 'author_name': const LooseString().toJson(instance.authorName), + 'author_avatar': const LooseString().toJson(instance.authorAvatar), + 'msg': instance.body, + 'time': const UnixSecondsDateTime().toJson(instance.time), + }; + +_BugThread _$BugThreadFromJson(Map json) => _BugThread( + id: (json['threads_id'] as num).toInt(), + title: const LooseString().fromJson(json['title']), + tags: + (json['tags'] as List?)?.map((e) => e as String).toList() ?? + const [], + body: const LooseString().fromJson(json['body']), + author: (json['author'] as num).toInt(), + authorName: const LooseString().fromJson(json['author_name']), + authorAvatar: const LooseString().fromJson(json['author_avatar']), + createdAt: const UnixSecondsDateTime().fromJson( + (json['created_at'] as num).toInt(), + ), + messageCount: (json['message_count'] as num?)?.toInt() ?? 0, + archived: json['archived'] as bool? ?? false, + locked: json['locked'] as bool? ?? false, + lastMessageId: (json['last_message_id'] as num?)?.toInt() ?? 0, +); + +Map _$BugThreadToJson(_BugThread instance) => + { + 'threads_id': instance.id, + 'title': const LooseString().toJson(instance.title), + 'tags': instance.tags, + 'body': const LooseString().toJson(instance.body), + 'author': instance.author, + 'author_name': const LooseString().toJson(instance.authorName), + 'author_avatar': const LooseString().toJson(instance.authorAvatar), + 'created_at': const UnixSecondsDateTime().toJson(instance.createdAt), + 'message_count': instance.messageCount, + 'archived': instance.archived, + 'locked': instance.locked, + 'last_message_id': instance.lastMessageId, + }; diff --git a/lib/features/bug_tracker/presentation/pages/bug_list_page.dart b/lib/features/bug_tracker/presentation/pages/bug_list_page.dart new file mode 100644 index 000000000..9b5c99b99 --- /dev/null +++ b/lib/features/bug_tracker/presentation/pages/bug_list_page.dart @@ -0,0 +1,436 @@ +/// 已回報的錯誤 — a read-only, GitHub-issues-style view of the bug threads +/// mirrored from the Discord tracker. +/// +/// Every thread row carries its tags as badges, a two-line body preview and +/// the reply count; tapping opens the full discussion. There is deliberately +/// no composer anywhere: triage happens on Discord, this is a window. +library; + +import 'dart:typed_data'; + +import 'package:dpip/app/theme/app_radius.dart'; +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/features/bug_tracker/bug_tracker_counter.dart'; +import 'package:dpip/features/bug_tracker/domain/bug_repository.dart'; +import 'package:dpip/features/bug_tracker/domain/bug_thread.dart'; +import 'package:dpip/features/bug_tracker/presentation/widgets/bug_avatar_image.dart'; +import 'package:dpip/features/bug_tracker/presentation/widgets/bug_tag_badge.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/navigation/app_routes.dart'; +import 'package:dpip/shared/navigation/refresh_on_appear.dart'; +import 'package:dpip/shared/widgets/async_view.dart'; +import 'package:dpip/shared/widgets/empty_view.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Fetches one avatar through the shared ETag/gzip Dio stack. +typedef AvatarFetch = Future Function(String url); + +/// The two sort modes the tracker offers. +enum BugSort { + /// Threads whose conversation was replied to most recently. + lastActivity, + + /// Threads with the most replies. + mostDiscussed, +} + +class BugListPage extends StatefulWidget { + const BugListPage({super.key, this.repository}); + + /// Injectable for tests; defaults to the provider-registered implementation. + final BugRepository? repository; + + @override + State createState() => _BugListPageState(); +} + +class _BugListPageState extends State { + final RefreshSignal _refresh = RefreshSignal(); + + /// Sort mode — switched from the chip bar above the list. + BugSort _sort = BugSort.lastActivity; + + /// Active tag filters — a thread must carry every one of these to show. + /// Toggled by tapping the filter chips; empty means no filter. + final Set _tagFilters = {}; + + void _toggleTagFilter(String tag) { + setState(() { + _tagFilters.contains(tag) + ? _tagFilters.remove(tag) + : _tagFilters.add(tag); + }); + } + + /// Threads passing the active filters and sort mode, in display order. + List _visibleThreads(List threads) { + var list = threads; + if (_tagFilters.isNotEmpty) { + list = [ + for (final thread in list) + if (_tagFilters.every(thread.tags.contains)) thread, + ]; + } + final sorted = [...list]; + switch (_sort) { + case BugSort.lastActivity: + sorted.sort((a, b) => b.lastMessageId.compareTo(a.lastMessageId)); + case BugSort.mostDiscussed: + sorted.sort((a, b) => b.messageCount.compareTo(a.messageCount)); + } + return sorted; + } + + /// Every distinct cleaned tag present in [threads], most-used first — the + /// filter-chip row's contents. + List _knownTags(List threads) { + final count = {}; + for (final thread in threads) { + for (final tag in thread.tags) { + count[tag] = (count[tag] ?? 0) + 1; + } + } + final entries = count.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + return [for (final e in entries) e.key]; + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final repo = widget.repository ?? context.read(); + Future avatarFor(String url) => + repo.avatar(url).then((result) => result.valueOrNull); + return Scaffold( + appBar: AppBar(title: Text(l10n.moreBugReports)), + body: Column( + children: [ + Padding( + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + 0, + ), + child: const _DiscordReportButton(), + ), + const SizedBox(height: AppSpacing.sm), + Expanded( + child: AsyncView>( + future: repo.threads, + refreshSignal: _refresh, + isEmpty: (threads) => threads.isEmpty, + builder: (context, threads) { + final theme = Theme.of(context); + final visible = _visibleThreads(threads); + final knownTags = _knownTags(threads); + // Tag filtering happens at display time — the repository stays + // a pure mirror of the source, and toggling is instant without + // a re-fetch. + return Column( + children: [ + // Sort chips (neutral) then a divider, then the coloured + // tag filters — two groups, one bar. + SizedBox( + height: 40, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + ), + children: [ + ChoiceChip( + label: Text(l10n.bugTrackerSortLast), + selected: _sort == BugSort.lastActivity, + onSelected: (_) => + setState(() => _sort = BugSort.lastActivity), + ), + const SizedBox(width: AppSpacing.xs), + ChoiceChip( + label: Text(l10n.bugTrackerSortMostDiscussed), + selected: _sort == BugSort.mostDiscussed, + onSelected: (_) => + setState(() => _sort = BugSort.mostDiscussed), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + ), + child: SizedBox( + height: 20, + child: VerticalDivider( + width: 1, + color: theme.colorScheme.onSurfaceVariant + .withValues(alpha: 0.3), + ), + ), + ), + for (final tag in knownTags) + Padding( + padding: const EdgeInsets.only( + right: AppSpacing.xs, + ), + child: BugTagFilterChip( + tag: tag, + selected: _tagFilters.contains(tag), + onSelected: (_) => _toggleTagFilter(tag), + ), + ), + ], + ), + ), + Expanded( + child: visible.isEmpty && _tagFilters.isNotEmpty + ? ListView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.xl, + AppSpacing.lg, + AppSpacing.xl, + ), + children: [ + EmptyView( + icon: Icons.filter_alt_off_outlined, + message: l10n.bugTrackerNoMatch, + ), + ], + ) + : RefreshIndicator( + onRefresh: () async { + _refresh.fire(); + await context + .read() + .refresh(); + }, + child: ListView.separated( + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.sm, + AppSpacing.lg, + AppSpacing.xl + + MediaQuery.paddingOf(context).bottom, + ), + itemCount: visible.length, + separatorBuilder: (_, _) => + const SizedBox(height: AppSpacing.sm), + itemBuilder: (context, index) => _ThreadCard( + thread: visible[index], + avatarFor: avatarFor, + ), + ), + ), + ), + ], + ); + }, + ), + ), + ], + ), + ); + } +} + +/// The report call-to-action above the list — links to the Discord channel +/// where new bugs are reported, since this screen is read-only by design. +class _DiscordReportButton extends StatelessWidget { + const _DiscordReportButton(); + + static const String _url = 'https://exptech.com.tw/dc'; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Material( + color: colors.secondaryContainer, + borderRadius: AppRadius.large, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () async { + final failed = l10n.moreLinkOpenFailed; + try { + final ok = await launchUrl( + Uri.parse(_url), + mode: LaunchMode.externalApplication, + ); + if (!ok && context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(failed))); + } + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(failed))); + } + } + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + child: Row( + children: [ + Icon( + Icons.forum_outlined, + size: 20, + color: colors.onSecondaryContainer, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + l10n.bugTrackerGoToDiscord, + style: theme.textTheme.labelLarge?.copyWith( + color: colors.onSecondaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Icon( + Icons.arrow_forward_outlined, + size: 18, + color: colors.onSecondaryContainer, + ), + ], + ), + ), + ), + ); + } +} + +/// A plain-text two-line preview of a markdown body. +/// +/// [MarkdownBody] cannot truncate to N lines, and a half-rendered heading or +/// link inside a two-line preview reads as noise anyway — so the syntax is +/// stripped here and the result flows as ordinary text. Images vanish, link +/// labels survive, headings lose their `#`, emphasis markers come off. +String _bugPreview(String body) => body + .replaceAllMapped(RegExp(r'!\[([^\]]*)\]\([^)]*\)'), (_) => '') + .replaceAllMapped(RegExp(r'\[([^\]]+)\]\([^)]*\)'), (m) => m.group(1)!) + .replaceAll(RegExp(r'```[a-zA-Z]*'), ' ') + .replaceAll(RegExp(r'^#{1,6}\s*', multiLine: true), '') + .replaceAll(RegExp(r'^\s*[-+*]\s+', multiLine: true), '• ') + .replaceAll(RegExp(r'[*_~`]'), '') + .replaceAll('\n', ' ') + .trim(); + +/// One thread row — title, tag badges, body preview, author and reply count. +class _ThreadCard extends StatelessWidget { + const _ThreadCard({required this.thread, required this.avatarFor}); + + final BugThread thread; + final AvatarFetch avatarFor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final date = DateFormat('yyyy/MM/dd').format(thread.createdAt.toLocal()); + return Card( + margin: EdgeInsets.zero, + color: colors.surfaceContainerHigh, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: AppRadius.medium), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => context.pushNamed( + AppRoutes.bugThread, + pathParameters: {'id': '${thread.id}'}, + ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + thread.title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + if (thread.tags.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.xs), + Wrap( + spacing: AppSpacing.xs, + runSpacing: AppSpacing.xs, + children: [ + for (final tag in thread.tags) BugTagBadge(tag: tag), + ], + ), + ], + const SizedBox(height: AppSpacing.xs), + Text( + _bugPreview(thread.body), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + height: 1.4, + ), + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + CircleAvatar( + radius: 9, + // 部分作者的 id 不在 users 目錄(已刪除帳號等)——解析出 + // 空 URL,此時顯示人形佔位而非對空 URI 發請求。 + backgroundImage: thread.authorAvatar.isEmpty + ? null + : BugAvatarImage(thread.authorAvatar, avatarFor), + onBackgroundImageError: thread.authorAvatar.isEmpty + ? null + : (_, _) {}, + child: thread.authorAvatar.isEmpty + ? const Icon(Icons.person_outline, size: 10) + : null, + ), + const SizedBox(width: AppSpacing.xs + 2), + Expanded( + child: Text( + thread.authorName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Icon( + Icons.mode_comment_outlined, + size: 14, + color: colors.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + '${thread.messageCount}', + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(width: AppSpacing.sm), + Text( + date, + style: theme.textTheme.labelSmall?.copyWith( + 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 new file mode 100644 index 000000000..add2c4ae0 --- /dev/null +++ b/lib/features/bug_tracker/presentation/pages/bug_thread_page.dart @@ -0,0 +1,450 @@ +/// One reported bug, read-only — the opening post plus the replies, laid out +/// like a Discord thread: header card first, then chat-style messages, and +/// where a chat's input would sit, a button handing discussion back to +/// Discord (this screen is read-only by design). +/// +/// Staff authors ([bugTrackerAdminIds]) carry a developer badge so official +/// replies are visually distinct from user chatter. A reply whose text is +/// null (deleted, or attachments/embeds only) shows a view-on-Discord +/// placeholder instead of an empty bubble. Bodies render as markdown — the +/// tracker mirrors Discord text, which is written in it. +library; + +import 'package:dpip/app/theme/app_radius.dart'; +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/features/bug_tracker/domain/bug_repository.dart'; +import 'package:dpip/features/bug_tracker/domain/bug_thread.dart'; +import 'package:dpip/features/bug_tracker/presentation/widgets/bug_avatar_image.dart'; +import 'package:dpip/features/bug_tracker/presentation/widgets/bug_tag_badge.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/async_view.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Fetches one avatar through the shared ETag/gzip Dio stack. +typedef AvatarFetch = Future Function(String url); + +/// Compact markdown sheet shared by the opening post and every reply — one +/// definition so the two never drift apart visually. +MarkdownStyleSheet _markdownSheet(BuildContext context) { + final theme = Theme.of(context); + return MarkdownStyleSheet( + p: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + height: 1.5, + ), + strong: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface, + ), + em: theme.textTheme.bodyMedium?.copyWith(fontStyle: FontStyle.italic), + code: theme.textTheme.bodySmall?.copyWith( + backgroundColor: theme.colorScheme.surfaceContainerHighest, + ), + codeblockDecoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: AppRadius.small, + ), + listIndent: AppSpacing.sm, + ); +} + +class BugThreadPage extends StatelessWidget { + const BugThreadPage({super.key, required this.id, this.repository}); + + /// The tracker thread id from the route. + final int id; + + /// Injectable for tests; defaults to the provider-registered implementation. + final BugRepository? repository; + + static const String _discordUrl = 'https://exptech.com.tw/dc'; + + Future _openDiscord(BuildContext context) async { + final failed = AppLocalizations.of(context).moreLinkOpenFailed; + try { + final ok = await launchUrl( + Uri.parse(_discordUrl), + mode: LaunchMode.externalApplication, + ); + if (!ok && context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(failed))); + } + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(failed))); + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final repo = repository ?? context.read(); + Future avatarFor(String url) => + repo.avatar(url).then((result) => result.valueOrNull); + return Scaffold( + appBar: AppBar(title: Text(l10n.moreBugReports)), + body: AsyncView( + future: () => repo.thread(id), + builder: (context, detail) { + return Column( + children: [ + Expanded( + child: ListView( + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + AppSpacing.sm, + ), + children: [ + _OpeningPost(detail: detail, avatarFor: avatarFor), + const SizedBox(height: AppSpacing.lg), + if (detail.messages.isNotEmpty) ...[ + Text( + l10n.bugTrackerReplies, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: AppSpacing.sm), + for (final message in detail.messages) ...[ + _ChatReply(message: message, avatarFor: avatarFor), + const SizedBox(height: AppSpacing.md), + ], + ], + ], + ), + ), + // Pinned: where a chat's input would sit, the hand-off back to + // Discord stays put while the thread scrolls above it. + Padding( + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.sm, + AppSpacing.lg, + MediaQuery.paddingOf(context).bottom + AppSpacing.xs, + ), + child: _JoinDiscussionButton( + onOpen: () => _openDiscord(context), + ), + ), + ], + ); + }, + ), + ); + } +} + +/// The opening post: title, tag badges, author row, body as markdown. +class _OpeningPost extends StatelessWidget { + const _OpeningPost({required this.detail, required this.avatarFor}); + + final BugThreadDetail detail; + final AvatarFetch avatarFor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final thread = detail.thread; + 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); + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: AppRadius.large, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + thread.title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + ), + if (thread.locked) + Icon( + Icons.lock_outline, + size: 16, + color: colors.onSurfaceVariant, + ), + ], + ), + if (thread.tags.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.xs), + Wrap( + spacing: AppSpacing.xs, + runSpacing: AppSpacing.xs, + children: [for (final tag in thread.tags) BugTagBadge(tag: tag)], + ), + ], + const SizedBox(height: AppSpacing.md), + Row( + children: [ + CircleAvatar( + radius: 14, + backgroundImage: thread.authorAvatar.isEmpty + ? null + : BugAvatarImage(thread.authorAvatar, avatarFor), + onBackgroundImageError: thread.authorAvatar.isEmpty + ? null + : (_, _) {}, + child: thread.authorAvatar.isEmpty + ? const Icon(Icons.person_outline, size: 18) + : null, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + thread.authorName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + color: staff ? colors.primary : null, + ), + ), + ), + if (staff) ...[ + const SizedBox(width: AppSpacing.xs), + const _DeveloperBadge(), + ], + ], + ), + Text( + created, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + if (thread.body.isNotEmpty) + MarkdownBody( + data: thread.body, + selectable: true, + styleSheet: _markdownSheet(context), + ), + ], + ), + ); + } +} + +/// One reply, chat-style — avatar left, name/time/body right, no card chrome. +class _ChatReply extends StatelessWidget { + const _ChatReply({required this.message, required this.avatarFor}); + + final BugMessage message; + final AvatarFetch avatarFor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final staff = isBugTrackerStaff(message.author); + final time = DateFormat('yyyy/MM/dd HH:mm').format(message.time.toLocal()); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 15, + backgroundImage: message.authorAvatar.isEmpty + ? null + : BugAvatarImage(message.authorAvatar, avatarFor), + onBackgroundImageError: message.authorAvatar.isEmpty + ? null + : (_, _) {}, + child: message.authorAvatar.isEmpty + ? const Icon(Icons.person_outline, size: 16) + : null, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + message.authorName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + color: staff ? colors.primary : null, + ), + ), + ), + if (staff) ...[ + const SizedBox(width: AppSpacing.xs), + const _DeveloperBadge(), + ], + const SizedBox(width: AppSpacing.xs), + Text( + time, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 2), + // Null text = deleted / attachments-only on Discord; point at + // the source instead of rendering an empty bubble. + message.body == null + ? const _CannotDisplay() + : MarkdownBody( + data: message.body!, + selectable: true, + softLineBreak: true, + styleSheet: _markdownSheet(context), + ), + ], + ), + ), + ], + ); + } +} + +/// The view-on-Discord placeholder for messages without renderable text. +class _CannotDisplay extends StatelessWidget { + const _CannotDisplay(); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + return InkWell( + onTap: () => launchUrl( + Uri.parse('https://exptech.com.tw/dc'), + mode: LaunchMode.externalApplication, + ), + borderRadius: AppRadius.small, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.info_outline, + size: 13, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: AppSpacing.xs), + Text( + l10n.bugTrackerCannotDisplay, + style: theme.textTheme.bodySmall, + ), + Icon( + Icons.chevron_right, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ); + } +} + +/// The small「開發人員」tag beside staff names. +class _DeveloperBadge extends StatelessWidget { + const _DeveloperBadge(); + + @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.primary.withValues(alpha: 0.15), + borderRadius: AppRadius.small, + ), + child: Text( + AppLocalizations.of(context).bugTrackerDeveloper, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +/// Where a chat's composer would sit — a quiet hand-off to Discord instead. +class _JoinDiscussionButton extends StatelessWidget { + const _JoinDiscussionButton({required this.onOpen}); + + final VoidCallback onOpen; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Material( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(28), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onOpen, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + child: Row( + children: [ + Icon(Icons.edit_note_outlined, color: colors.onSurfaceVariant), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + AppLocalizations.of(context).bugTrackerJoinDiscussion, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + Icon( + Icons.arrow_forward_outlined, + size: 18, + color: colors.onSurfaceVariant, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/bug_tracker/presentation/widgets/bug_avatar_image.dart b/lib/features/bug_tracker/presentation/widgets/bug_avatar_image.dart new file mode 100644 index 000000000..1ff8ede5b --- /dev/null +++ b/lib/features/bug_tracker/presentation/widgets/bug_avatar_image.dart @@ -0,0 +1,54 @@ +/// An avatar [ImageProvider] that fetches through the app's own HTTP stack. +/// +/// `NetworkImage` bypasses Dio entirely — no ETag revalidation, no SQLite body +/// store, no traffic accounting. This provider hands the fetch to the bug +/// repository instead, so a Discord avatar is fetched once, stored, and then +/// revalidated with the CDN's own ETag like every other cacheable GET. +/// +/// Equality is the URL alone: the same avatar URL must hit Flutter's image +/// cache as one entry regardless of which widget asked for it. +library; + +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; + +class BugAvatarImage extends ImageProvider { + const BugAvatarImage(this.url, this.fetch); + + /// The avatar URL — also the cache key and the ETag identity. + final String url; + + /// The shared fetcher: repository `avatar(url)` mapped to raw bytes. + final Future Function(String url) fetch; + + @override + Future obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage( + BugAvatarImage key, + ImageDecoderCallback decode, + ) { + return MultiFrameImageStreamCompleter(codec: _codec(key), scale: 1); + } + + Future _codec(BugAvatarImage key) async { + final bytes = await fetch(key.url); + if (bytes == null || bytes.isEmpty) { + // CircleAvatar paints its background colour; nothing else to do. + throw StateError('avatar unavailable: ${key.url}'); + } + final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); + final descriptor = await ui.ImageDescriptor.encoded(buffer); + return descriptor.instantiateCodec(); + } + + @override + bool operator ==(Object other) => other is BugAvatarImage && other.url == url; + + @override + int get hashCode => url.hashCode; +} diff --git a/lib/features/bug_tracker/presentation/widgets/bug_tag_badge.dart b/lib/features/bug_tracker/presentation/widgets/bug_tag_badge.dart new file mode 100644 index 000000000..153efb997 --- /dev/null +++ b/lib/features/bug_tracker/presentation/widgets/bug_tag_badge.dart @@ -0,0 +1,227 @@ +/// 標籤徽章與篩選 chip — the tracker's known tags, drawn GitHub-issues-style. +/// +/// Two renderings share one vocabulary: +/// +/// - [BugTagBadge] — the coloured display badge used on cards and headers. +/// - [BugTagFilterChip] — the interactive filter above the list: **unselected +/// reads as quiet neutral grey** so an entire row of filters stays calm; +/// **selected adopts the display badge's colouring** (alpha background + +/// matching border + accent text), referencing the changelog's 測試版 chip +/// construction. The state change is unmistakable without shouting. +library; + +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. +enum BugTagKind { + bug, + duplicate, + confirmed, + improvement, + api, + needsInfo, + inProgress, + 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. + static BugTagKind of(String tag) { + final t = tag.trim().toLowerCase(); + // l10n-ignore: server tag value + if (t.contains('duplicate') || tag == '重複') { + return duplicate; + } + // l10n-ignore: server tag value + if (t.contains('confirmed') || tag == '已確認') { + return confirmed; + } + // l10n-ignore: server tag value + if (t.contains('fixed') || tag == '已解決') { + return fixed; + } + // l10n-ignore: server tag value + if (t.contains('wontfix') || tag == '無法解決') { + return wontfix; + } + // l10n-ignore: server tag value + if (t.contains('invalid') || tag == '無效') { + return invalid; + } + // l10n-ignore: server tag value + if (t.contains('improvement') || tag == '增強') { + return improvement; + } + // l10n-ignore: server tag value + if (t.contains('triage') || tag == '需要更多資訊') { + return needsInfo; + } + // l10n-ignore: server tag value + if (t.contains('progress') || tag == '處理中') { + return inProgress; + } + // l10n-ignore: server tag value + if (t.contains('vulnerab') || tag == '漏洞') { + return vulnerability; + } + // l10n-ignore: server tag value + if (t == 'api') { + return api; + } + // l10n-ignore: server tag value + if (t.contains('bug') || tag == '臭蟲') { + return bug; + } + return other; + } +} + +/// The accent colour each kind renders with — one hue per state, close to +/// GitHub's label palette. +Color bugTagAccent(BugTagKind kind) => switch (kind) { + BugTagKind.bug => const Color(0xFFD1242F), + BugTagKind.vulnerability => const Color(0xFFBC4C00), + BugTagKind.confirmed => const Color(0xFF0969DA), + 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.duplicate => const Color(0xFF6E7781), + BugTagKind.wontfix => const Color(0xFF57606A), + BugTagKind.invalid => const Color(0xFF0550AE), + BugTagKind.other => const Color(0xFF6E7781), +}; + +IconData? _iconFor(BugTagKind kind) => switch (kind) { + BugTagKind.bug => Icons.bug_report_outlined, + BugTagKind.vulnerability => Icons.gpp_maybe_outlined, + BugTagKind.confirmed => Icons.task_alt, + 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.duplicate => Icons.content_copy, + BugTagKind.wontfix => Icons.block, + BugTagKind.invalid => Icons.cancel_outlined, + BugTagKind.other => null, +}; + +/// The coloured display badge — icon plus the tag verbatim on a soft tint of +/// its own accent. Used wherever tags are metadata (cards, headers). +class BugTagBadge extends StatelessWidget { + const BugTagBadge({super.key, required this.tag}); + + final String tag; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final kind = BugTagKind.of(tag); + final accent = bugTagAccent(kind); + final icon = _iconFor(kind); + // Same construction as the changelog's type chips: soft alpha fill, a + // matching hairline border, and the label itself painted in the accent — + // the colouring effect the 測試版 badge established. + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: accent.withValues(alpha: 0.45)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 11, color: accent), + const SizedBox(width: 4), + ], + Text( + tag, + style: theme.textTheme.labelSmall?.copyWith( + color: accent, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + ), + ), + ], + ), + ); + } +} + +/// The interactive filter chip above the list. +/// +/// Unselected is deliberately quiet — neutral grey, matching how GitHub dims +/// labels you haven't picked. Selected adopts the display badge's colouring +/// ([bugTagAccent] tint plus accent text and a check mark), so the active +/// filter reads at a glance. +class BugTagFilterChip extends StatelessWidget { + const BugTagFilterChip({ + super.key, + required this.tag, + required this.selected, + required this.onSelected, + }); + + final String tag; + final bool selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final kind = BugTagKind.of(tag); + final accent = bugTagAccent(kind); + final icon = _iconFor(kind); + return InkWell( + onTap: () => onSelected(!selected), + borderRadius: AppRadius.small, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + // 選中採測試版 chip 的上色效果(alpha 底+同色邊框+accent 文字), + // 與卡片上的顯示徽章同一族;未選維持安靜的中性灰。 + color: selected + ? accent.withValues(alpha: 0.14) + : colors.surfaceContainerHighest, + borderRadius: AppRadius.small, + border: Border.all( + color: selected ? accent : colors.outlineVariant, + width: selected ? 1.2 : 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon( + icon, + size: 12, + color: selected ? accent : colors.onSurfaceVariant, + ), + const SizedBox(width: 4), + ], + Text( + tag, + style: theme.textTheme.labelMedium?.copyWith( + color: selected ? accent : colors.onSurfaceVariant, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + letterSpacing: selected ? 0.2 : null, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart b/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart index a8eb9f71b..82e71cb6a 100644 --- a/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart +++ b/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart @@ -559,21 +559,35 @@ class _RainOnCardState extends State // Water sits above the card's top edge and must not be cut off. clipBehavior: Clip.none, children: [ - // Gated the same as the splash below, not just by widget.glass on - // its own: the card sliding up toward the toolbar is exactly when - // its content is about to scroll away, and refracting droplets - // over content that is mid-scroll (or already off past the fold) - // has nothing left to sensibly distort. - widget.glass && _gateOpen + // **The gate rides the effect, never the tree's shape.** + // + // [RainOnGlass] documents this rule for its own subtree and this + // is the same rule one level up: [content] carries [_captureKey], + // and the gate is re-evaluated on scroll, so branching the tree on + // it re-parented a GlobalKey subtree mid-drag. That was not a + // cosmetic problem — it threw + // `'!child.attached': is not true` out of `flushSemantics` on + // every frame of the drag that flipped it, and killed the in-flight + // gesture the same way the note inside `RainOnGlass` describes. + // + // So the wrapper stays mounted and the gate only zeroes what it + // feeds: an intensity below `RainOnGlass`'s own threshold makes it + // an `ImageFiltered(enabled: false)` and stops its ticker, which is + // the same nothing the unwrapped branch used to render. + // + // [widget.glass] may still branch — it is a fixed property of each + // call site, decided at construction and never toggled while the + // card is alive. + widget.glass ? RainOnGlass( - intensity: widget.intensity, + // A card sliding up toward the toolbar is about to scroll + // away, and refracting droplets over content that is + // mid-scroll has nothing left to sensibly distort. Running + // the shader filter every frame just to skip it wastes the + // GPU too. Reopening is free — the droplets resume from + // their own clock. + intensity: _gateOpen ? widget.intensity : 0, opacity: widget.opacity, - // The position gate closes the card's refracting droplets - // along with its splash — a card sliding up toward the - // toolbar has nothing left to sensibly distort, and - // running the shader filter every frame just to skip it - // wastes the GPU. Restarting when the gate reopens is free: - // the droplets resume from their own clock. active: widget.active && _gateOpen, child: content, ) diff --git a/lib/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index 3676b8cc9..ce3414226 100644 --- a/lib/features/more/presentation/pages/more_page.dart +++ b/lib/features/more/presentation/pages/more_page.dart @@ -7,6 +7,7 @@ import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/meshtastic/mesh_unread.dart'; +import 'package:dpip/features/bug_tracker/bug_tracker_counter.dart'; import 'package:dpip/core/network/endpoint_health.dart'; import 'package:dpip/core/settings/default_map_layer_controller.dart'; import 'package:dpip/core/settings/eew_cwa_only_settings.dart'; @@ -158,6 +159,19 @@ class MorePage extends StatelessWidget { title: l10n.changelogTitle, onTap: () => context.pushNamed(AppRoutes.changelog), ), + _MoreTile( + icon: Icons.bug_report_outlined, + title: l10n.moreBugReports, + // The count rides the same ETag-cached index the page reads; + // loaded once per session here, resynced by the list's own + // pull-to-refresh. + trailing: _BugReportCount( + counter: context.watch(), + onLoad: () => + context.read().ensureLoaded(), + ), + onTap: () => context.pushNamed(AppRoutes.bugTracker), + ), _MoreTile( icon: Icons.article_outlined, title: l10n.appLogs, @@ -1099,6 +1113,43 @@ class _AnnouncementCard extends StatelessWidget { } } +/// The bug-tracker tile's trailing count — the number of open threads the +/// tracker index reports, in GitHub's speech-bubble-plus-number shape. Null +/// while loading or after a failure: an unreachable tracker must not read as +/// "no bugs", and a missing count draws the plain chevron instead. +class _BugReportCount extends StatelessWidget { + const _BugReportCount({required this.counter, required this.onLoad}); + + final BugTrackerCounter counter; + final VoidCallback onLoad; + + @override + Widget build(BuildContext context) { + // Kick the one-shot load from outside build — notifyListeners during the + // same frame's build would throw. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) onLoad(); + }); + final count = counter.count; + if (count == null) return const SizedBox.shrink(); + final theme = Theme.of(context); + final color = theme.colorScheme.onSurfaceVariant; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.mode_comment_outlined, size: 16, color: color), + const SizedBox(width: AppSpacing.xs), + Text( + '$count', + style: theme.textTheme.labelMedium?.copyWith(color: color), + ), + const SizedBox(width: AppSpacing.xs), + Icon(Icons.chevron_right, size: 20, color: color), + ], + ); + } +} + /// Server status, directly under the announcement card — the same flat, /// quiet construction, because a status check is a passive read and needs no /// more weight than a link. diff --git a/lib/features/notification/presentation/pages/notification_test_page.dart b/lib/features/notification/presentation/pages/notification_test_page.dart new file mode 100644 index 000000000..2e1540006 --- /dev/null +++ b/lib/features/notification/presentation/pages/notification_test_page.dart @@ -0,0 +1,365 @@ +/// The test-notification page: fire one sample of each alert channel and see +/// what it actually does on this device. +library; + +import 'dart:async'; + +import 'package:awesome_notifications/awesome_notifications.dart'; +import 'package:dpip/app/theme/app_radius.dart'; +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/notifications/notification_channels.dart'; +import 'package:dpip/core/notifications/notification_samples.dart'; +import 'package:dpip/core/notifications/notification_service.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/loading_view.dart'; +import 'package:dpip/shared/widgets/section_header.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// Lets the user hear and see each alert channel on their own device. +/// +/// The question this page exists to answer is "will this actually wake me at +/// three in the morning" — and nothing else in the app can answer it, because +/// every part of the chain is invisible until an alert arrives: the OS grant, +/// the channel's sound, whether the phone's silent switch applies. So each row +/// states what the channel will do *before* it is tapped, and tapping fires the +/// real thing. +/// +/// It lists the **OS notification channels**, not the nine [NotifyChannel] +/// filters on the settings page in front of it. Those are two different things +/// that both get called "notification settings": the filters decide whether the +/// backend sends anything, while these decide what the phone does when it +/// arrives. Sound lives here. +/// +/// Reachable even when the settings page behind it has no push token, and +/// deliberately so: a device that cannot be reached by push is exactly the +/// device whose owner wants to know whether notifications work at all. +class NotificationTestPage extends StatefulWidget { + const NotificationTestPage({super.key}); + + @override + State createState() => _NotificationTestPageState(); +} + +class _NotificationTestPageState extends State { + /// The legacy cooldown, kept: long enough that a double-tap cannot stack two + /// alarms, short enough not to feel broken. + static const _cooldown = Duration(seconds: 2); + + /// Whether the OS lets this app post at all — null until checked. + bool? _allowed; + + /// Whether iOS granted the critical-alert entitlement. Null on Android, where + /// the permission does not exist and the channel carries the override. + bool? _criticalAllowed; + + /// The channel currently in its cooldown, if any. + String? _cooling; + Timer? _cooldownTimer; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _refreshPermissions()); + } + + @override + void dispose() { + _cooldownTimer?.cancel(); + super.dispose(); + } + + Future _refreshPermissions() async { + final notifications = context.read(); + final allowed = await notifications.isAllowed(); + final critical = notifications.criticalApplies + ? await notifications.criticalAllowed() + : null; + if (!mounted) return; + setState(() { + _allowed = allowed; + _criticalAllowed = critical; + }); + } + + Future _fire(String channelKey) async { + final l10n = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + final notifications = context.read(); + + setState(() => _cooling = channelKey); + _cooldownTimer?.cancel(); + _cooldownTimer = Timer(_cooldown, () { + if (mounted) setState(() => _cooling = null); + }); + + final sent = await notifications.showTest(channelKey); + if (sent || !mounted) return; + messenger + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(l10n.notifyTestFailed))); + } + + Future _openSettings() async { + await context.read().openSystemSettings(); + // The grant may have changed while the app was away; re-reading on return + // is what stops the banner from outliving the problem it describes. + await _refreshPermissions(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final allowed = _allowed; + + return Scaffold( + appBar: AppBar(title: Text(l10n.notifyTestTitle)), + body: allowed == null + ? const LoadingView() + : ListView( + padding: const EdgeInsets.only(bottom: AppSpacing.xl), + children: [ + // Exactly one notice, always. Each state has one thing most + // worth saying, and stacking a standing explanation above a + // live problem buries the problem. + if (!allowed) + _Notice( + icon: Icons.notifications_off_outlined, + text: l10n.notifyTestPermissionOff, + tone: _NoticeTone.problem, + actionLabel: l10n.permissionOpenSettings, + onAction: _openSettings, + ) + // Only once notifications work at all: naming the missing + // critical grant while nothing can be posted in the first place + // describes the second problem and hides the first. + else if (_criticalAllowed == false) + _Notice( + icon: Icons.volume_off_outlined, + text: l10n.notifyTestCriticalDenied, + tone: _NoticeTone.problem, + actionLabel: l10n.permissionOpenSettings, + onAction: _openSettings, + ) + else + _Notice( + icon: Icons.volume_up_outlined, + text: l10n.notifyTestIntro, + ), + for (final section in _sections(l10n)) ...[ + SectionHeader(section.title), + for (final channel in section.channels) + _ChannelRow( + channel: channel, + enabled: allowed, + cooling: _cooling == channel.channelKey, + criticalGranted: _criticalAllowed, + onFire: () => _fire(channel.channelKey!), + ), + ], + ], + ), + ); + } + + /// The testable channels, grouped in catalogue order. + /// + /// Testable means "has a sample" — which is the same set as "the backend + /// pushes it". The mesh channels the app raises itself from the LoRa link and + /// the silent `background` service channel are left out: there is no server + /// message to reproduce for them, and a row that fired an invented alert + /// would be demonstrating something the user will never actually receive. + List<({String title, List channels})> _sections( + AppLocalizations l10n, + ) { + // The settings page one screen back labels its categories with these exact + // strings. Two different labels for one grouping would read as two + // different groupings. + final order = <(String, String)>[ + ('group_eew', l10n.notifySectionEew), + ('group_eq', l10n.notifySectionEarthquake), + ('group_info', l10n.notifySectionWeather), + ('group_tsunami', l10n.notifySectionTsunami), + ('group_other', l10n.notifySectionOther), + ]; + + final grouped = >{}; + for (final channel in NotificationChannels.channels) { + final key = channel.channelKey; + if (key == null || NotificationSamples.of(key) == null) continue; + final group = channel.channelGroupKey; + if (group == null) continue; + grouped.putIfAbsent(group, () => []).add(channel); + } + + return [ + for (final (group, title) in order) + if (grouped[group] case final channels?) + (title: title, channels: channels), + ]; + } +} + +/// One channel: its name, what it will do, and a tap that does it. +/// +/// The subtitle is the point of the row. The catalogue holds nine fields per +/// channel and all nine were candidates here, but only the ones that change the +/// answer earn a line: sound file, LED colour, and vibration pattern do not +/// change whether you wake up, and listing them would bury the one that does. +class _ChannelRow extends StatelessWidget { + const _ChannelRow({ + required this.channel, + required this.enabled, + required this.cooling, + required this.criticalGranted, + required this.onFire, + }); + + final NotificationChannel channel; + final bool enabled; + final bool cooling; + + /// iOS's critical-alert grant; null where the concept does not apply. + final bool? criticalGranted; + + final VoidCallback onFire; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + var behaviour = NotificationChannels.behaviourOf(channel); + + // A channel that claims to override silent mode only does so on iOS if the + // user granted the critical alert. Saying otherwise on a device that + // refused it would be the page's single worst failure — it is the exact + // claim someone checks this screen to confirm. + if (behaviour == NotificationBehaviour.overrides && + criticalGranted == false) { + behaviour = NotificationBehaviour.alerts; + } + + return ListTile( + leading: Icon(_icon(behaviour)), + title: Text(channel.channelName ?? ''), + subtitle: Text(_label(behaviour, l10n)), + trailing: cooling + ? const InlineLoading(size: 20) + : Icon( + Icons.play_circle_outline, + color: enabled ? theme.colorScheme.primary : null, + ), + enabled: enabled && !cooling, + onTap: onFire, + ); + } + + /// The same "how much will this interrupt me" vocabulary the settings page's + /// option sheet uses, so the two screens read as one idea. + static IconData _icon(NotificationBehaviour behaviour) => switch (behaviour) { + NotificationBehaviour.overrides => Icons.notification_important_outlined, + NotificationBehaviour.alerts => Icons.notifications_active_outlined, + NotificationBehaviour.sounds => Icons.notifications_outlined, + NotificationBehaviour.silent => Icons.notifications_off_outlined, + }; + + static String _label( + NotificationBehaviour behaviour, + AppLocalizations l10n, + ) => switch (behaviour) { + NotificationBehaviour.overrides => l10n.notifyTestBehaviourOverrides, + NotificationBehaviour.alerts => l10n.notifyTestBehaviourAlerts, + NotificationBehaviour.sounds => l10n.notifyTestBehaviourSounds, + NotificationBehaviour.silent => l10n.notifyTestBehaviourSilent, + }; +} + +enum _NoticeTone { info, problem } + +/// A calm inline note above the list. +/// +/// Not a `SnackBar` and not a dialog: both of those interrupt, and everything +/// this page has to say is something you want to read *before* choosing a row, +/// not something that arrives after you have already tapped one. +class _Notice extends StatelessWidget { + const _Notice({ + required this.icon, + required this.text, + this.tone = _NoticeTone.info, + this.actionLabel, + this.onAction, + }); + + final IconData icon; + final String text; + final _NoticeTone tone; + final String? actionLabel; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final (background, foreground) = switch (tone) { + _NoticeTone.info => ( + theme.colorScheme.surfaceContainerHigh, + theme.colorScheme.onSurfaceVariant, + ), + _NoticeTone.problem => ( + theme.colorScheme.errorContainer, + theme.colorScheme.onErrorContainer, + ), + }; + + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + 0, + ), + child: Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: background, + borderRadius: AppRadius.medium, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 20, color: foreground), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + text, + style: theme.textTheme.bodyMedium?.copyWith( + color: foreground, + ), + ), + if (actionLabel != null && onAction != null) + Padding( + padding: const EdgeInsets.only(top: AppSpacing.xs), + child: TextButton( + onPressed: onAction, + style: TextButton.styleFrom( + foregroundColor: foreground, + // Visually tight, but the 48dp minimum tap target + // that comes with the default `tapTargetSize` stays. + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + ), + ), + child: Text(actionLabel!), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/notification/presentation/pages/notify_page.dart b/lib/features/notification/presentation/pages/notify_page.dart index 673550021..b04084762 100644 --- a/lib/features/notification/presentation/pages/notify_page.dart +++ b/lib/features/notification/presentation/pages/notify_page.dart @@ -8,11 +8,13 @@ import 'package:dpip/features/notification/domain/notify_settings.dart'; import 'package:dpip/features/notification/presentation/notify_controller.dart'; import 'package:dpip/features/notification/presentation/notify_labels.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:dpip/shared/widgets/empty_view.dart'; import 'package:dpip/shared/widgets/error_view.dart'; import 'package:dpip/shared/widgets/loading_view.dart'; import 'package:dpip/shared/widgets/section_header.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; /// Lets the user tune each push channel's filter. Settings live server-side @@ -43,7 +45,20 @@ class _NotifyView extends StatelessWidget { final controller = context.watch(); return Scaffold( - appBar: AppBar(title: Text(l10n.notifyTitle)), + appBar: AppBar( + title: Text(l10n.notifyTitle), + actions: [ + // Outside the status switch below on purpose. The settings on this + // page need a push token; hearing whether this phone rings at all + // does not — and a device with no token is precisely the one whose + // owner wants to check. + IconButton( + tooltip: l10n.notifyTestTitle, + icon: const Icon(Icons.notifications_active_outlined), + onPressed: () => context.pushNamed(AppRoutes.notifyTest), + ), + ], + ), body: switch (controller.status) { NotifyLoadStatus.noToken => EmptyView( icon: Icons.notifications_off_outlined, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bc5e184f9..09ae5a5e6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3874,5 +3874,81 @@ }, "@rainScaleCoarse": { "description": "Rainfall colour scale option: wide-spaced thresholds (10-1500 mm), for multi-day totals" + }, + "notifyTestTitle": "Test notifications", + "@notifyTestTitle": { + "description": "Title of the page that sends sample notifications" + }, + "notifyTestIntro": "Tapping a row sends that alert for real. Major alerts play at full volume and sound through the silent switch and Do Not Disturb.", + "@notifyTestIntro": { + "description": "Explains that a tap fires a real alert, and warns that major alerts ignore silent mode" + }, + "notifyTestCriticalDenied": "Critical alerts aren't allowed on this device, so major alerts stay silent when your phone is.", + "@notifyTestCriticalDenied": { + "description": "Shown on iOS when the critical-alert permission was refused" + }, + "notifyTestPermissionOff": "Notifications are turned off, so a test won't show anything.", + "@notifyTestPermissionOff": { + "description": "Shown when notification permission is not granted, so a test does nothing" + }, + "notifyTestBehaviourOverrides": "Sounds through silent and Do Not Disturb", + "@notifyTestBehaviourOverrides": { + "description": "Channel behaviour: sounds through silent mode and Do Not Disturb" + }, + "notifyTestBehaviourAlerts": "Sound and a banner, unless your phone is silenced", + "@notifyTestBehaviourAlerts": { + "description": "Channel behaviour: sound plus a heads-up banner, but the silent switch still applies" + }, + "notifyTestBehaviourSounds": "Sound but no banner, unless your phone is silenced", + "@notifyTestBehaviourSounds": { + "description": "Channel behaviour: sound but no banner, and the silent switch still applies" + }, + "notifyTestBehaviourSilent": "Silent — notification list only", + "@notifyTestBehaviourSilent": { + "description": "Channel behaviour: no sound, appears only in the notification list" + }, + "notifyTestFailed": "Couldn't send the test notification.", + "@notifyTestFailed": { + "description": "Snackbar shown when the test notification could not be posted" + }, + "moreBugReports": "Bug reports", + "bugTrackerEmpty": "No reported bugs yet", + "@moreBugReports": { + "description": "Title of the read-only reported-bugs screen and its More-tab entry" + }, + "@bugTrackerEmpty": { + "description": "Shown when the bug-tracker index has no threads" + }, + "bugTrackerReplies": "Replies", + "@bugTrackerReplies": { + "description": "Header above the reply thread on a bug detail page" + }, + "bugTrackerGoToDiscord": "Can't find your issue? Report it on Discord!", + "@bugTrackerGoToDiscord": { + "description": "Call-to-action above the bug list, linking to the Discord report channel" + }, + "bugTrackerNoMatch": "No threads match the selected tags", + "@bugTrackerNoMatch": { + "description": "Shown when the active tag filters match no thread" + }, + "bugTrackerDeveloper": "Developer", + "bugTrackerCannotDisplay": "This content can't be displayed here — view it on Discord", + "bugTrackerJoinDiscussion": "Join the discussion on Discord", + "@bugTrackerDeveloper": { + "description": "Badge beside staff names on bug threads" + }, + "@bugTrackerCannotDisplay": { + "description": "Placeholder for a reply whose text is unavailable" + }, + "@bugTrackerJoinDiscussion": { + "description": "Button handing discussion back to Discord" + }, + "bugTrackerSortLast": "Latest activity", + "bugTrackerSortMostDiscussed": "Most discussed", + "@bugTrackerSortLast": { + "description": "Sort chip: threads with the most recent reply first" + }, + "@bugTrackerSortMostDiscussed": { + "description": "Sort chip: threads with the most replies first" } } diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 6f21e5c4c..1ec030482 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -1961,5 +1961,24 @@ "statusLegendUnsupported": "Hindi suportado", "rainScaleSection": "Antas ng kulay", "rainScaleFine": "Pino", - "rainScaleCoarse": "Magaspang" + "rainScaleCoarse": "Magaspang", + "notifyTestTitle": "Subukan ang mga notipikasyon", + "notifyTestIntro": "Ang pag-tap sa isang row ay talagang magpapadala ng alertong iyon. Ang mga mahalagang alerto ay tutunog nang pinakamalakas at dadaan sa silent switch at Do Not Disturb.", + "notifyTestCriticalDenied": "Hindi pinapayagan ang critical alerts sa device na ito, kaya mananatiling tahimik ang mga mahalagang alerto kapag naka-silent ang telepono.", + "notifyTestPermissionOff": "Naka-off ang mga notipikasyon, kaya walang lalabas kapag sinubukan.", + "notifyTestBehaviourOverrides": "Tutunog kahit naka-silent o Do Not Disturb", + "notifyTestBehaviourAlerts": "May tunog at banner, maliban kung naka-silent ang telepono", + "notifyTestBehaviourSounds": "May tunog, walang banner, maliban kung naka-silent ang telepono", + "notifyTestBehaviourSilent": "Tahimik — sa listahan ng notipikasyon lang", + "notifyTestFailed": "Hindi naipadala ang pansubok na notipikasyon.", + "moreBugReports": "Mga naulat na bug", + "bugTrackerEmpty": "Wala pang naulat na bug", + "bugTrackerReplies": "Mga sagot", + "bugTrackerGoToDiscord": "Hindi mo makita ang iyong problema? Iulat ito sa Discord!", + "bugTrackerNoMatch": "Walang bug na tumutugma sa mga piling tag", + "bugTrackerDeveloper": "Developer", + "bugTrackerCannotDisplay": "Hindi maipakita ang nilalaman na ito — tingnan sa Discord", + "bugTrackerJoinDiscussion": "Makilahok sa talakayan sa Discord", + "bugTrackerSortLast": "Pinakabagong aktibidad", + "bugTrackerSortMostDiscussed": "Pinakamaraming talakayan" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 845190876..abc1af994 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1961,5 +1961,24 @@ "statusLegendUnsupported": "Tidak tersedia", "rainScaleSection": "Skala warna", "rainScaleFine": "Halus", - "rainScaleCoarse": "Kasar" + "rainScaleCoarse": "Kasar", + "notifyTestTitle": "Uji notifikasi", + "notifyTestIntro": "Mengetuk baris akan benar-benar mengirim peringatan itu. Peringatan penting berbunyi pada volume penuh dan menembus mode senyap serta Jangan Ganggu.", + "notifyTestCriticalDenied": "Perangkat ini belum mengizinkan peringatan kritis, jadi peringatan penting tetap senyap saat ponsel disenyapkan.", + "notifyTestPermissionOff": "Notifikasi dimatikan, jadi pengujian tidak akan menampilkan apa pun.", + "notifyTestBehaviourOverrides": "Menembus mode senyap dan Jangan Ganggu", + "notifyTestBehaviourAlerts": "Suara dan banner, kecuali ponsel sedang disenyapkan", + "notifyTestBehaviourSounds": "Suara tanpa banner, kecuali ponsel sedang disenyapkan", + "notifyTestBehaviourSilent": "Senyap — hanya di daftar notifikasi", + "notifyTestFailed": "Tidak dapat mengirim notifikasi uji.", + "moreBugReports": "Bug yang dilaporkan", + "bugTrackerEmpty": "Belum ada bug yang dilaporkan", + "bugTrackerReplies": "Balasan", + "bugTrackerGoToDiscord": "Tidak menemukan masalahmu? Laporkan di Discord!", + "bugTrackerNoMatch": "Tidak ada bug yang cocok dengan tag terpilih", + "bugTrackerDeveloper": "Pengembang", + "bugTrackerCannotDisplay": "Konten ini tidak dapat ditampilkan — lihat di Discord", + "bugTrackerJoinDiscussion": "Ikuti diskusi di Discord", + "bugTrackerSortLast": "Aktivitas terbaru", + "bugTrackerSortMostDiscussed": "Paling banyak dibahas" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 81fbc3ba8..00f89f532 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1961,5 +1961,24 @@ "statusLegendUnsupported": "非対応", "rainScaleSection": "色階の間隔", "rainScaleFine": "細かい", - "rainScaleCoarse": "粗い" + "rainScaleCoarse": "粗い", + "notifyTestTitle": "通知テスト", + "notifyTestIntro": "行をタップすると、そのアラートが実際に送信されます。重大な警報は最大音量で鳴り、消音スイッチとおやすみモードを貫通します。", + "notifyTestCriticalDenied": "この端末では「緊急アラート」が許可されていないため、重大な警報も消音時には音が鳴りません。", + "notifyTestPermissionOff": "通知がオフのため、テストしても何も表示されません。", + "notifyTestBehaviourOverrides": "消音・おやすみモードを貫通", + "notifyTestBehaviourAlerts": "音とバナー(消音中は鳴りません)", + "notifyTestBehaviourSounds": "音のみ、バナーなし(消音中は鳴りません)", + "notifyTestBehaviourSilent": "無音 — 通知センターのみ", + "notifyTestFailed": "テスト通知を送信できませんでした。", + "moreBugReports": "報告済みのバグ", + "bugTrackerEmpty": "報告されたバグはまだありません", + "bugTrackerReplies": "返信", + "bugTrackerGoToDiscord": "見つからない問題はDiscordで報告してください!", + "bugTrackerNoMatch": "選択したタグに一致する報告はありません", + "bugTrackerDeveloper": "開発者", + "bugTrackerCannotDisplay": "この内容は表示できません — Discord でご確認ください", + "bugTrackerJoinDiscussion": "Discord で議論に参加する", + "bugTrackerSortLast": "最新の返信", + "bugTrackerSortMostDiscussed": "返信が多い順" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 1510a0b4a..cbd563a66 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1961,5 +1961,24 @@ "statusLegendUnsupported": "미지원", "rainScaleSection": "색상 간격", "rainScaleFine": "좁게", - "rainScaleCoarse": "넓게" + "rainScaleCoarse": "넓게", + "notifyTestTitle": "알림 테스트", + "notifyTestIntro": "항목을 누르면 해당 알림이 실제로 전송됩니다. 중대 경보는 최대 음량으로 울리며 무음 스위치와 방해 금지 모드를 무시합니다.", + "notifyTestCriticalDenied": "이 기기에서 긴급 알림이 허용되지 않아 중대 경보도 무음일 때는 소리가 나지 않습니다.", + "notifyTestPermissionOff": "알림이 꺼져 있어 테스트해도 아무것도 표시되지 않습니다.", + "notifyTestBehaviourOverrides": "무음·방해 금지 모드에서도 울림", + "notifyTestBehaviourAlerts": "소리와 배너 (무음 모드에서는 울리지 않음)", + "notifyTestBehaviourSounds": "소리만, 배너 없음 (무음 모드에서는 울리지 않음)", + "notifyTestBehaviourSilent": "무음 — 알림 목록에만 표시", + "notifyTestFailed": "테스트 알림을 보내지 못했습니다.", + "moreBugReports": "보고된 버그", + "bugTrackerEmpty": "아직 보고된 버그가 없습니다", + "bugTrackerReplies": "답글", + "bugTrackerGoToDiscord": "문제를 찾을 수 없나요? Discord에서 신고해 주세요!", + "bugTrackerNoMatch": "선택한 태그와 일치하는 버그가 없습니다", + "bugTrackerDeveloper": "개발자", + "bugTrackerCannotDisplay": "이 내용을 표시할 수 없습니다 — Discord에서 확인하세요", + "bugTrackerJoinDiscussion": "Discord에서 논의에 참여하기", + "bugTrackerSortLast": "최근 활동", + "bugTrackerSortMostDiscussed": "답글 많은 순" } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index d9f8632ca..b13f92e37 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -1961,5 +1961,24 @@ "statusLegendUnsupported": "ไม่รองรับ", "rainScaleSection": "ช่วงระดับสี", "rainScaleFine": "ละเอียด", - "rainScaleCoarse": "หยาบ" + "rainScaleCoarse": "หยาบ", + "notifyTestTitle": "ทดสอบการแจ้งเตือน", + "notifyTestIntro": "แตะที่รายการเพื่อส่งการแจ้งเตือนนั้นจริง ๆ การแจ้งเตือนสำคัญจะดังด้วยระดับเสียงสูงสุด และดังทะลุโหมดปิดเสียงและห้ามรบกวน", + "notifyTestCriticalDenied": "อุปกรณ์นี้ไม่ได้อนุญาตการแจ้งเตือนฉุกเฉิน การแจ้งเตือนสำคัญจึงเงียบเมื่อปิดเสียงเครื่อง", + "notifyTestPermissionOff": "การแจ้งเตือนถูกปิดอยู่ การทดสอบจะไม่แสดงอะไรเลย", + "notifyTestBehaviourOverrides": "ดังทะลุโหมดปิดเสียงและห้ามรบกวน", + "notifyTestBehaviourAlerts": "มีเสียงและแบนเนอร์ แต่จะเงียบเมื่อปิดเสียงเครื่อง", + "notifyTestBehaviourSounds": "มีเสียง ไม่มีแบนเนอร์ และจะเงียบเมื่อปิดเสียงเครื่อง", + "notifyTestBehaviourSilent": "เงียบ — แสดงในรายการแจ้งเตือนเท่านั้น", + "notifyTestFailed": "ส่งการแจ้งเตือนทดสอบไม่สำเร็จ", + "moreBugReports": "บั๊กที่รายงานแล้ว", + "bugTrackerEmpty": "ยังไม่มีบั๊กที่รายงาน", + "bugTrackerReplies": "การตอบกลับ", + "bugTrackerGoToDiscord": "ไม่พบปัญหาของคุณ? ไปแจ้งบั๊กที่ Discord!", + "bugTrackerNoMatch": "ไม่มีบั๊กที่ตรงกับแท็กที่เลือก", + "bugTrackerDeveloper": "นักพัฒนา", + "bugTrackerCannotDisplay": "ไม่สามารถแสดงเนื้อหานี้ได้ — ดูได้ที่ Discord", + "bugTrackerJoinDiscussion": "ร่วมพูดคุยที่ Discord", + "bugTrackerSortLast": "ล่าสุด", + "bugTrackerSortMostDiscussed": "พูดคุยมากที่สุด" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index a918c954e..26f9f5402 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1961,5 +1961,24 @@ "statusLegendUnsupported": "Không có", "rainScaleSection": "Thang màu", "rainScaleFine": "Mịn", - "rainScaleCoarse": "Thô" + "rainScaleCoarse": "Thô", + "notifyTestTitle": "Thử thông báo", + "notifyTestIntro": "Chạm vào một mục sẽ gửi cảnh báo đó thật sự. Cảnh báo nghiêm trọng phát ở âm lượng tối đa và vang lên bất chấp chế độ im lặng và Không làm phiền.", + "notifyTestCriticalDenied": "Thiết bị này chưa cho phép cảnh báo khẩn cấp, nên cảnh báo nghiêm trọng vẫn im lặng khi máy đang tắt tiếng.", + "notifyTestPermissionOff": "Thông báo đang tắt nên thử nghiệm sẽ không hiện gì cả.", + "notifyTestBehaviourOverrides": "Vang lên qua chế độ im lặng và Không làm phiền", + "notifyTestBehaviourAlerts": "Có âm thanh và biểu ngữ, trừ khi máy đang tắt tiếng", + "notifyTestBehaviourSounds": "Có âm thanh, không biểu ngữ, trừ khi máy đang tắt tiếng", + "notifyTestBehaviourSilent": "Im lặng — chỉ hiện trong danh sách thông báo", + "notifyTestFailed": "Không gửi được thông báo thử.", + "moreBugReports": "Lỗi đã báo cáo", + "bugTrackerEmpty": "Chưa có lỗi nào được báo cáo", + "bugTrackerReplies": "Phản hồi", + "bugTrackerGoToDiscord": "Không tìm thấy vấn đề của bạn? Hãy báo cáo trên Discord!", + "bugTrackerNoMatch": "Không có lỗi nào khớp với thẻ đã chọn", + "bugTrackerDeveloper": "Nhà phát triển", + "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" } diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index cea976906..ceca4e183 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -1961,5 +1961,24 @@ "statusLegendUnsupported": "唔支援", "rainScaleSection": "色階間距", "rainScaleFine": "小間距", - "rainScaleCoarse": "大間距" + "rainScaleCoarse": "大間距", + "notifyTestTitle": "測試通知", + "notifyTestIntro": "撳一下就會真係發送嗰則警報。重大警報會用最大音量播放,仲會穿透靜音同勿擾模式。", + "notifyTestCriticalDenied": "呢部裝置未允許「重要警告」,重大警報喺靜音時一樣唔會出聲。", + "notifyTestPermissionOff": "通知已關閉,測試唔會有任何反應。", + "notifyTestBehaviourOverrides": "會穿透靜音同勿擾模式", + "notifyTestBehaviourAlerts": "有聲音仲會彈橫幅,但手機靜音時唔會響", + "notifyTestBehaviourSounds": "有聲音、唔會彈橫幅,手機靜音時唔會響", + "notifyTestBehaviourSilent": "無聲,只會出現喺通知中心", + "notifyTestFailed": "無法發送測試通知。", + "moreBugReports": "已回報嘅錯誤", + "bugTrackerEmpty": "仲未有已回報嘅錯誤", + "bugTrackerReplies": "回覆", + "bugTrackerGoToDiscord": "搵唔到你嘅問題?快啲去 Discord 回報!", + "bugTrackerNoMatch": "冇符合所選標籤嘅錯誤回報", + "bugTrackerDeveloper": "開發人員", + "bugTrackerCannotDisplay": "無法顯示呢個內容,請去 Discord 查看", + "bugTrackerJoinDiscussion": "去 Discord 一齊傾", + "bugTrackerSortLast": "最後傾偈", + "bugTrackerSortMostDiscussed": "最多討論" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a2914c446..77032a60f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1953,5 +1953,24 @@ "statusLegendUnsupported": "不支援", "rainScaleSection": "色階間距", "rainScaleFine": "小間距", - "rainScaleCoarse": "大間距" + "rainScaleCoarse": "大間距", + "notifyTestTitle": "測試通知", + "notifyTestIntro": "點一下就會實際發送該則警報。重大警報會以最大音量播放,並穿透靜音與勿擾模式。", + "notifyTestCriticalDenied": "這台裝置未允許「重要警告」,重大警報在靜音時同樣不會發出聲音。", + "notifyTestPermissionOff": "通知已關閉,測試不會有任何反應。", + "notifyTestBehaviourOverrides": "會穿透靜音與勿擾模式", + "notifyTestBehaviourAlerts": "有聲音並跳出橫幅,但手機靜音時不會響", + "notifyTestBehaviourSounds": "有聲音、不跳出橫幅,手機靜音時不會響", + "notifyTestBehaviourSilent": "無聲,只出現在通知中心", + "notifyTestFailed": "無法發送測試通知。", + "moreBugReports": "已回报的错误", + "bugTrackerEmpty": "还没有已回报的错误", + "bugTrackerReplies": "回复", + "bugTrackerGoToDiscord": "找不到你的问题?快前往 Discord 回报!", + "bugTrackerNoMatch": "没有符合所选标签的错误回报", + "bugTrackerDeveloper": "开发人员", + "bugTrackerCannotDisplay": "无法显示此内容,请在 Discord 上查看", + "bugTrackerJoinDiscussion": "至 Discord 参与讨论", + "bugTrackerSortLast": "最后讨论", + "bugTrackerSortMostDiscussed": "最多讨论" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 0adafe4a4..3b1b1a3a2 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1961,5 +1961,24 @@ "statusLegendUnsupported": "不支持", "rainScaleSection": "色阶间距", "rainScaleFine": "小间距", - "rainScaleCoarse": "大间距" + "rainScaleCoarse": "大间距", + "notifyTestTitle": "测试通知", + "notifyTestIntro": "点一下就会实际发送该则警报。重大警报会以最大音量播放,并穿透静音与勿扰模式。", + "notifyTestCriticalDenied": "这台设备未允许「重要警告」,重大警报在静音时同样不会发出声音。", + "notifyTestPermissionOff": "通知已关闭,测试不会有任何反应。", + "notifyTestBehaviourOverrides": "会穿透静音与勿扰模式", + "notifyTestBehaviourAlerts": "有声音并弹出横幅,但手机静音时不会响", + "notifyTestBehaviourSounds": "有声音、不弹出横幅,手机静音时不会响", + "notifyTestBehaviourSilent": "无声,只出现在通知中心", + "notifyTestFailed": "无法发送测试通知。", + "moreBugReports": "已回报的错误", + "bugTrackerEmpty": "还没有已回报的错误", + "bugTrackerReplies": "回复", + "bugTrackerGoToDiscord": "找不到你的问题?快前往 Discord 回报!", + "bugTrackerNoMatch": "没有符合所选标签的错误回报", + "bugTrackerDeveloper": "开发人员", + "bugTrackerCannotDisplay": "无法显示此内容,请在 Discord 上查看", + "bugTrackerJoinDiscussion": "至 Discord 参与讨论", + "bugTrackerSortLast": "最后讨论", + "bugTrackerSortMostDiscussed": "最多讨论" } diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 147925713..4a3e20ad1 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -1961,5 +1961,24 @@ "statusLegendUnsupported": "不支援", "rainScaleSection": "色階間距", "rainScaleFine": "小間距", - "rainScaleCoarse": "大間距" + "rainScaleCoarse": "大間距", + "notifyTestTitle": "測試通知", + "notifyTestIntro": "點一下就會實際發送該則警報。重大警報會以最大音量播放,並穿透靜音與勿擾模式。", + "notifyTestCriticalDenied": "這台裝置未允許「重要警告」,重大警報在靜音時同樣不會發出聲音。", + "notifyTestPermissionOff": "通知已關閉,測試不會有任何反應。", + "notifyTestBehaviourOverrides": "會穿透靜音與勿擾模式", + "notifyTestBehaviourAlerts": "有聲音並跳出橫幅,但手機靜音時不會響", + "notifyTestBehaviourSounds": "有聲音、不跳出橫幅,手機靜音時不會響", + "notifyTestBehaviourSilent": "無聲,只出現在通知中心", + "notifyTestFailed": "無法發送測試通知。", + "moreBugReports": "已回報的錯誤", + "bugTrackerEmpty": "還沒有已回報的錯誤", + "bugTrackerReplies": "回覆", + "bugTrackerGoToDiscord": "找不到你的問題?快前往 Discord 回報!", + "bugTrackerNoMatch": "沒有符合所選標籤的錯誤回報", + "bugTrackerDeveloper": "開發人員", + "bugTrackerCannotDisplay": "無法顯示此內容,請在 Discord 上查看", + "bugTrackerJoinDiscussion": "至 Discord 參與討論", + "bugTrackerSortLast": "最後討論", + "bugTrackerSortMostDiscussed": "最多討論" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 5fc562bd7..5dfbfb93a 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -1961,5 +1961,24 @@ "statusLegendUnsupported": "不支援", "rainScaleSection": "色階間距", "rainScaleFine": "小間距", - "rainScaleCoarse": "大間距" + "rainScaleCoarse": "大間距", + "notifyTestTitle": "測試通知", + "notifyTestIntro": "點一下就會實際發送該則警報。重大警報會以最大音量播放,並穿透靜音與勿擾模式。", + "notifyTestCriticalDenied": "這台裝置未允許「重要警告」,重大警報在靜音時同樣不會發出聲音。", + "notifyTestPermissionOff": "通知已關閉,測試不會有任何反應。", + "notifyTestBehaviourOverrides": "會穿透靜音與勿擾模式", + "notifyTestBehaviourAlerts": "有聲音並跳出橫幅,但手機靜音時不會響", + "notifyTestBehaviourSounds": "有聲音、不跳出橫幅,手機靜音時不會響", + "notifyTestBehaviourSilent": "無聲,只出現在通知中心", + "notifyTestFailed": "無法發送測試通知。", + "moreBugReports": "已回報的錯誤", + "bugTrackerEmpty": "還沒有已回報的錯誤", + "bugTrackerReplies": "回覆", + "bugTrackerGoToDiscord": "找不到你的問題?快前往 Discord 回報!", + "bugTrackerNoMatch": "沒有符合所選標籤的錯誤回報", + "bugTrackerDeveloper": "開發人員", + "bugTrackerCannotDisplay": "無法顯示此內容,請在 Discord 上查看", + "bugTrackerJoinDiscussion": "至 Discord 參與討論", + "bugTrackerSortLast": "最後討論", + "bugTrackerSortMostDiscussed": "最多討論" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 9088c8bfa..6181f6206 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -6148,6 +6148,120 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Coarse'** String get rainScaleCoarse; + + /// Title of the page that sends sample notifications + /// + /// In en, this message translates to: + /// **'Test notifications'** + String get notifyTestTitle; + + /// Explains that a tap fires a real alert, and warns that major alerts ignore silent mode + /// + /// In en, this message translates to: + /// **'Tapping a row sends that alert for real. Major alerts play at full volume and sound through the silent switch and Do Not Disturb.'** + String get notifyTestIntro; + + /// Shown on iOS when the critical-alert permission was refused + /// + /// In en, this message translates to: + /// **'Critical alerts aren\'t allowed on this device, so major alerts stay silent when your phone is.'** + String get notifyTestCriticalDenied; + + /// Shown when notification permission is not granted, so a test does nothing + /// + /// In en, this message translates to: + /// **'Notifications are turned off, so a test won\'t show anything.'** + String get notifyTestPermissionOff; + + /// Channel behaviour: sounds through silent mode and Do Not Disturb + /// + /// In en, this message translates to: + /// **'Sounds through silent and Do Not Disturb'** + String get notifyTestBehaviourOverrides; + + /// Channel behaviour: sound plus a heads-up banner, but the silent switch still applies + /// + /// In en, this message translates to: + /// **'Sound and a banner, unless your phone is silenced'** + String get notifyTestBehaviourAlerts; + + /// Channel behaviour: sound but no banner, and the silent switch still applies + /// + /// In en, this message translates to: + /// **'Sound but no banner, unless your phone is silenced'** + String get notifyTestBehaviourSounds; + + /// Channel behaviour: no sound, appears only in the notification list + /// + /// In en, this message translates to: + /// **'Silent — notification list only'** + String get notifyTestBehaviourSilent; + + /// Snackbar shown when the test notification could not be posted + /// + /// In en, this message translates to: + /// **'Couldn\'t send the test notification.'** + String get notifyTestFailed; + + /// Title of the read-only reported-bugs screen and its More-tab entry + /// + /// In en, this message translates to: + /// **'Bug reports'** + String get moreBugReports; + + /// Shown when the bug-tracker index has no threads + /// + /// In en, this message translates to: + /// **'No reported bugs yet'** + String get bugTrackerEmpty; + + /// Header above the reply thread on a bug detail page + /// + /// In en, this message translates to: + /// **'Replies'** + String get bugTrackerReplies; + + /// Call-to-action above the bug list, linking to the Discord report channel + /// + /// In en, this message translates to: + /// **'Can\'t find your issue? Report it on Discord!'** + String get bugTrackerGoToDiscord; + + /// Shown when the active tag filters match no thread + /// + /// In en, this message translates to: + /// **'No threads match the selected tags'** + String get bugTrackerNoMatch; + + /// Badge beside staff names on bug threads + /// + /// In en, this message translates to: + /// **'Developer'** + String get bugTrackerDeveloper; + + /// Placeholder for a reply whose text is unavailable + /// + /// In en, this message translates to: + /// **'This content can\'t be displayed here — view it on Discord'** + String get bugTrackerCannotDisplay; + + /// Button handing discussion back to Discord + /// + /// In en, this message translates to: + /// **'Join the discussion on Discord'** + String get bugTrackerJoinDiscussion; + + /// Sort chip: threads with the most recent reply first + /// + /// In en, this message translates to: + /// **'Latest activity'** + String get bugTrackerSortLast; + + /// Sort chip: threads with the most replies first + /// + /// In en, this message translates to: + /// **'Most discussed'** + String get bugTrackerSortMostDiscussed; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index b361b12b1..c5c74f096 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -3229,4 +3229,69 @@ class AppLocalizationsEn extends AppLocalizations { @override String get rainScaleCoarse => 'Coarse'; + + @override + String get notifyTestTitle => 'Test notifications'; + + @override + String get notifyTestIntro => + 'Tapping a row sends that alert for real. Major alerts play at full volume and sound through the silent switch and Do Not Disturb.'; + + @override + String get notifyTestCriticalDenied => + 'Critical alerts aren\'t allowed on this device, so major alerts stay silent when your phone is.'; + + @override + String get notifyTestPermissionOff => + 'Notifications are turned off, so a test won\'t show anything.'; + + @override + String get notifyTestBehaviourOverrides => + 'Sounds through silent and Do Not Disturb'; + + @override + String get notifyTestBehaviourAlerts => + 'Sound and a banner, unless your phone is silenced'; + + @override + String get notifyTestBehaviourSounds => + 'Sound but no banner, unless your phone is silenced'; + + @override + String get notifyTestBehaviourSilent => 'Silent — notification list only'; + + @override + String get notifyTestFailed => 'Couldn\'t send the test notification.'; + + @override + String get moreBugReports => 'Bug reports'; + + @override + String get bugTrackerEmpty => 'No reported bugs yet'; + + @override + String get bugTrackerReplies => 'Replies'; + + @override + String get bugTrackerGoToDiscord => + 'Can\'t find your issue? Report it on Discord!'; + + @override + String get bugTrackerNoMatch => 'No threads match the selected tags'; + + @override + String get bugTrackerDeveloper => 'Developer'; + + @override + String get bugTrackerCannotDisplay => + 'This content can\'t be displayed here — view it on Discord'; + + @override + String get bugTrackerJoinDiscussion => 'Join the discussion on Discord'; + + @override + String get bugTrackerSortLast => 'Latest activity'; + + @override + String get bugTrackerSortMostDiscussed => 'Most discussed'; } diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 1c66146e0..75859137a 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -3245,4 +3245,71 @@ class AppLocalizationsFil extends AppLocalizations { @override String get rainScaleCoarse => 'Magaspang'; + + @override + String get notifyTestTitle => 'Subukan ang mga notipikasyon'; + + @override + String get notifyTestIntro => + 'Ang pag-tap sa isang row ay talagang magpapadala ng alertong iyon. Ang mga mahalagang alerto ay tutunog nang pinakamalakas at dadaan sa silent switch at Do Not Disturb.'; + + @override + String get notifyTestCriticalDenied => + 'Hindi pinapayagan ang critical alerts sa device na ito, kaya mananatiling tahimik ang mga mahalagang alerto kapag naka-silent ang telepono.'; + + @override + String get notifyTestPermissionOff => + 'Naka-off ang mga notipikasyon, kaya walang lalabas kapag sinubukan.'; + + @override + String get notifyTestBehaviourOverrides => + 'Tutunog kahit naka-silent o Do Not Disturb'; + + @override + String get notifyTestBehaviourAlerts => + 'May tunog at banner, maliban kung naka-silent ang telepono'; + + @override + String get notifyTestBehaviourSounds => + 'May tunog, walang banner, maliban kung naka-silent ang telepono'; + + @override + String get notifyTestBehaviourSilent => + 'Tahimik — sa listahan ng notipikasyon lang'; + + @override + String get notifyTestFailed => + 'Hindi naipadala ang pansubok na notipikasyon.'; + + @override + String get moreBugReports => 'Mga naulat na bug'; + + @override + String get bugTrackerEmpty => 'Wala pang naulat na bug'; + + @override + String get bugTrackerReplies => 'Mga sagot'; + + @override + String get bugTrackerGoToDiscord => + 'Hindi mo makita ang iyong problema? Iulat ito sa Discord!'; + + @override + String get bugTrackerNoMatch => 'Walang bug na tumutugma sa mga piling tag'; + + @override + String get bugTrackerDeveloper => 'Developer'; + + @override + String get bugTrackerCannotDisplay => + 'Hindi maipakita ang nilalaman na ito — tingnan sa Discord'; + + @override + String get bugTrackerJoinDiscussion => 'Makilahok sa talakayan sa Discord'; + + @override + String get bugTrackerSortLast => 'Pinakabagong aktibidad'; + + @override + String get bugTrackerSortMostDiscussed => 'Pinakamaraming talakayan'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 15a38f4b6..79b755199 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -3239,4 +3239,70 @@ class AppLocalizationsId extends AppLocalizations { @override String get rainScaleCoarse => 'Kasar'; + + @override + String get notifyTestTitle => 'Uji notifikasi'; + + @override + String get notifyTestIntro => + 'Mengetuk baris akan benar-benar mengirim peringatan itu. Peringatan penting berbunyi pada volume penuh dan menembus mode senyap serta Jangan Ganggu.'; + + @override + String get notifyTestCriticalDenied => + 'Perangkat ini belum mengizinkan peringatan kritis, jadi peringatan penting tetap senyap saat ponsel disenyapkan.'; + + @override + String get notifyTestPermissionOff => + 'Notifikasi dimatikan, jadi pengujian tidak akan menampilkan apa pun.'; + + @override + String get notifyTestBehaviourOverrides => + 'Menembus mode senyap dan Jangan Ganggu'; + + @override + String get notifyTestBehaviourAlerts => + 'Suara dan banner, kecuali ponsel sedang disenyapkan'; + + @override + String get notifyTestBehaviourSounds => + 'Suara tanpa banner, kecuali ponsel sedang disenyapkan'; + + @override + String get notifyTestBehaviourSilent => 'Senyap — hanya di daftar notifikasi'; + + @override + String get notifyTestFailed => 'Tidak dapat mengirim notifikasi uji.'; + + @override + String get moreBugReports => 'Bug yang dilaporkan'; + + @override + String get bugTrackerEmpty => 'Belum ada bug yang dilaporkan'; + + @override + String get bugTrackerReplies => 'Balasan'; + + @override + String get bugTrackerGoToDiscord => + 'Tidak menemukan masalahmu? Laporkan di Discord!'; + + @override + String get bugTrackerNoMatch => + 'Tidak ada bug yang cocok dengan tag terpilih'; + + @override + String get bugTrackerDeveloper => 'Pengembang'; + + @override + String get bugTrackerCannotDisplay => + 'Konten ini tidak dapat ditampilkan — lihat di Discord'; + + @override + String get bugTrackerJoinDiscussion => 'Ikuti diskusi di Discord'; + + @override + String get bugTrackerSortLast => 'Aktivitas terbaru'; + + @override + String get bugTrackerSortMostDiscussed => 'Paling banyak dibahas'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 19544a6a9..2f9c4701c 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -3174,4 +3174,63 @@ class AppLocalizationsJa extends AppLocalizations { @override String get rainScaleCoarse => '粗い'; + + @override + String get notifyTestTitle => '通知テスト'; + + @override + String get notifyTestIntro => + '行をタップすると、そのアラートが実際に送信されます。重大な警報は最大音量で鳴り、消音スイッチとおやすみモードを貫通します。'; + + @override + String get notifyTestCriticalDenied => + 'この端末では「緊急アラート」が許可されていないため、重大な警報も消音時には音が鳴りません。'; + + @override + String get notifyTestPermissionOff => '通知がオフのため、テストしても何も表示されません。'; + + @override + String get notifyTestBehaviourOverrides => '消音・おやすみモードを貫通'; + + @override + String get notifyTestBehaviourAlerts => '音とバナー(消音中は鳴りません)'; + + @override + String get notifyTestBehaviourSounds => '音のみ、バナーなし(消音中は鳴りません)'; + + @override + String get notifyTestBehaviourSilent => '無音 — 通知センターのみ'; + + @override + String get notifyTestFailed => 'テスト通知を送信できませんでした。'; + + @override + String get moreBugReports => '報告済みのバグ'; + + @override + String get bugTrackerEmpty => '報告されたバグはまだありません'; + + @override + String get bugTrackerReplies => '返信'; + + @override + String get bugTrackerGoToDiscord => '見つからない問題はDiscordで報告してください!'; + + @override + String get bugTrackerNoMatch => '選択したタグに一致する報告はありません'; + + @override + String get bugTrackerDeveloper => '開発者'; + + @override + String get bugTrackerCannotDisplay => 'この内容は表示できません — Discord でご確認ください'; + + @override + String get bugTrackerJoinDiscussion => 'Discord で議論に参加する'; + + @override + String get bugTrackerSortLast => '最新の返信'; + + @override + String get bugTrackerSortMostDiscussed => '返信が多い順'; } diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 55621f74c..840341bb0 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -3174,4 +3174,63 @@ class AppLocalizationsKo extends AppLocalizations { @override String get rainScaleCoarse => '넓게'; + + @override + String get notifyTestTitle => '알림 테스트'; + + @override + String get notifyTestIntro => + '항목을 누르면 해당 알림이 실제로 전송됩니다. 중대 경보는 최대 음량으로 울리며 무음 스위치와 방해 금지 모드를 무시합니다.'; + + @override + String get notifyTestCriticalDenied => + '이 기기에서 긴급 알림이 허용되지 않아 중대 경보도 무음일 때는 소리가 나지 않습니다.'; + + @override + String get notifyTestPermissionOff => '알림이 꺼져 있어 테스트해도 아무것도 표시되지 않습니다.'; + + @override + String get notifyTestBehaviourOverrides => '무음·방해 금지 모드에서도 울림'; + + @override + String get notifyTestBehaviourAlerts => '소리와 배너 (무음 모드에서는 울리지 않음)'; + + @override + String get notifyTestBehaviourSounds => '소리만, 배너 없음 (무음 모드에서는 울리지 않음)'; + + @override + String get notifyTestBehaviourSilent => '무음 — 알림 목록에만 표시'; + + @override + String get notifyTestFailed => '테스트 알림을 보내지 못했습니다.'; + + @override + String get moreBugReports => '보고된 버그'; + + @override + String get bugTrackerEmpty => '아직 보고된 버그가 없습니다'; + + @override + String get bugTrackerReplies => '답글'; + + @override + String get bugTrackerGoToDiscord => '문제를 찾을 수 없나요? Discord에서 신고해 주세요!'; + + @override + String get bugTrackerNoMatch => '선택한 태그와 일치하는 버그가 없습니다'; + + @override + String get bugTrackerDeveloper => '개발자'; + + @override + String get bugTrackerCannotDisplay => '이 내용을 표시할 수 없습니다 — Discord에서 확인하세요'; + + @override + String get bugTrackerJoinDiscussion => 'Discord에서 논의에 참여하기'; + + @override + String get bugTrackerSortLast => '최근 활동'; + + @override + String get bugTrackerSortMostDiscussed => '답글 많은 순'; } diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 9c148e0dc..d1a225ae3 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -3222,4 +3222,69 @@ class AppLocalizationsTh extends AppLocalizations { @override String get rainScaleCoarse => 'หยาบ'; + + @override + String get notifyTestTitle => 'ทดสอบการแจ้งเตือน'; + + @override + String get notifyTestIntro => + 'แตะที่รายการเพื่อส่งการแจ้งเตือนนั้นจริง ๆ การแจ้งเตือนสำคัญจะดังด้วยระดับเสียงสูงสุด และดังทะลุโหมดปิดเสียงและห้ามรบกวน'; + + @override + String get notifyTestCriticalDenied => + 'อุปกรณ์นี้ไม่ได้อนุญาตการแจ้งเตือนฉุกเฉิน การแจ้งเตือนสำคัญจึงเงียบเมื่อปิดเสียงเครื่อง'; + + @override + String get notifyTestPermissionOff => + 'การแจ้งเตือนถูกปิดอยู่ การทดสอบจะไม่แสดงอะไรเลย'; + + @override + String get notifyTestBehaviourOverrides => 'ดังทะลุโหมดปิดเสียงและห้ามรบกวน'; + + @override + String get notifyTestBehaviourAlerts => + 'มีเสียงและแบนเนอร์ แต่จะเงียบเมื่อปิดเสียงเครื่อง'; + + @override + String get notifyTestBehaviourSounds => + 'มีเสียง ไม่มีแบนเนอร์ และจะเงียบเมื่อปิดเสียงเครื่อง'; + + @override + String get notifyTestBehaviourSilent => + 'เงียบ — แสดงในรายการแจ้งเตือนเท่านั้น'; + + @override + String get notifyTestFailed => 'ส่งการแจ้งเตือนทดสอบไม่สำเร็จ'; + + @override + String get moreBugReports => 'บั๊กที่รายงานแล้ว'; + + @override + String get bugTrackerEmpty => 'ยังไม่มีบั๊กที่รายงาน'; + + @override + String get bugTrackerReplies => 'การตอบกลับ'; + + @override + String get bugTrackerGoToDiscord => + 'ไม่พบปัญหาของคุณ? ไปแจ้งบั๊กที่ Discord!'; + + @override + String get bugTrackerNoMatch => 'ไม่มีบั๊กที่ตรงกับแท็กที่เลือก'; + + @override + String get bugTrackerDeveloper => 'นักพัฒนา'; + + @override + String get bugTrackerCannotDisplay => + 'ไม่สามารถแสดงเนื้อหานี้ได้ — ดูได้ที่ Discord'; + + @override + String get bugTrackerJoinDiscussion => 'ร่วมพูดคุยที่ Discord'; + + @override + String get bugTrackerSortLast => 'ล่าสุด'; + + @override + String get bugTrackerSortMostDiscussed => 'พูดคุยมากที่สุด'; } diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 057a81b02..f22be1d17 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -3229,4 +3229,70 @@ class AppLocalizationsVi extends AppLocalizations { @override String get rainScaleCoarse => 'Thô'; + + @override + String get notifyTestTitle => 'Thử thông báo'; + + @override + String get notifyTestIntro => + 'Chạm vào một mục sẽ gửi cảnh báo đó thật sự. Cảnh báo nghiêm trọng phát ở âm lượng tối đa và vang lên bất chấp chế độ im lặng và Không làm phiền.'; + + @override + String get notifyTestCriticalDenied => + 'Thiết bị này chưa cho phép cảnh báo khẩn cấp, nên cảnh báo nghiêm trọng vẫn im lặng khi máy đang tắt tiếng.'; + + @override + String get notifyTestPermissionOff => + 'Thông báo đang tắt nên thử nghiệm sẽ không hiện gì cả.'; + + @override + String get notifyTestBehaviourOverrides => + 'Vang lên qua chế độ im lặng và Không làm phiền'; + + @override + String get notifyTestBehaviourAlerts => + 'Có âm thanh và biểu ngữ, trừ khi máy đang tắt tiếng'; + + @override + String get notifyTestBehaviourSounds => + 'Có âm thanh, không biểu ngữ, trừ khi máy đang tắt tiếng'; + + @override + String get notifyTestBehaviourSilent => + 'Im lặng — chỉ hiện trong danh sách thông báo'; + + @override + String get notifyTestFailed => 'Không gửi được thông báo thử.'; + + @override + String get moreBugReports => 'Lỗi đã báo cáo'; + + @override + String get bugTrackerEmpty => 'Chưa có lỗi nào được báo cáo'; + + @override + String get bugTrackerReplies => 'Phản hồi'; + + @override + String get bugTrackerGoToDiscord => + 'Không tìm thấy vấn đề của bạn? Hãy báo cáo trên Discord!'; + + @override + String get bugTrackerNoMatch => 'Không có lỗi nào khớp với thẻ đã chọn'; + + @override + String get bugTrackerDeveloper => 'Nhà phát triển'; + + @override + String get bugTrackerCannotDisplay => + 'Không thể hiển thị nội dung này — xem trên Discord'; + + @override + String get bugTrackerJoinDiscussion => 'Tham gia thảo luận trên Discord'; + + @override + String get bugTrackerSortLast => 'Hoạt động mới nhất'; + + @override + String get bugTrackerSortMostDiscussed => 'Nhiều thảo luận nhất'; } diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index e158ee75b..fc826496a 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -3159,4 +3159,61 @@ class AppLocalizationsYue extends AppLocalizations { @override String get rainScaleCoarse => '大間距'; + + @override + String get notifyTestTitle => '測試通知'; + + @override + String get notifyTestIntro => '撳一下就會真係發送嗰則警報。重大警報會用最大音量播放,仲會穿透靜音同勿擾模式。'; + + @override + String get notifyTestCriticalDenied => '呢部裝置未允許「重要警告」,重大警報喺靜音時一樣唔會出聲。'; + + @override + String get notifyTestPermissionOff => '通知已關閉,測試唔會有任何反應。'; + + @override + String get notifyTestBehaviourOverrides => '會穿透靜音同勿擾模式'; + + @override + String get notifyTestBehaviourAlerts => '有聲音仲會彈橫幅,但手機靜音時唔會響'; + + @override + String get notifyTestBehaviourSounds => '有聲音、唔會彈橫幅,手機靜音時唔會響'; + + @override + String get notifyTestBehaviourSilent => '無聲,只會出現喺通知中心'; + + @override + String get notifyTestFailed => '無法發送測試通知。'; + + @override + String get moreBugReports => '已回報嘅錯誤'; + + @override + String get bugTrackerEmpty => '仲未有已回報嘅錯誤'; + + @override + String get bugTrackerReplies => '回覆'; + + @override + String get bugTrackerGoToDiscord => '搵唔到你嘅問題?快啲去 Discord 回報!'; + + @override + String get bugTrackerNoMatch => '冇符合所選標籤嘅錯誤回報'; + + @override + String get bugTrackerDeveloper => '開發人員'; + + @override + String get bugTrackerCannotDisplay => '無法顯示呢個內容,請去 Discord 查看'; + + @override + String get bugTrackerJoinDiscussion => '去 Discord 一齊傾'; + + @override + String get bugTrackerSortLast => '最後傾偈'; + + @override + String get bugTrackerSortMostDiscussed => '最多討論'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index b1ea4ef81..eac3d6ba7 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -3159,6 +3159,63 @@ class AppLocalizationsZh extends AppLocalizations { @override String get rainScaleCoarse => '大間距'; + + @override + String get notifyTestTitle => '測試通知'; + + @override + String get notifyTestIntro => '點一下就會實際發送該則警報。重大警報會以最大音量播放,並穿透靜音與勿擾模式。'; + + @override + String get notifyTestCriticalDenied => '這台裝置未允許「重要警告」,重大警報在靜音時同樣不會發出聲音。'; + + @override + String get notifyTestPermissionOff => '通知已關閉,測試不會有任何反應。'; + + @override + String get notifyTestBehaviourOverrides => '會穿透靜音與勿擾模式'; + + @override + String get notifyTestBehaviourAlerts => '有聲音並跳出橫幅,但手機靜音時不會響'; + + @override + String get notifyTestBehaviourSounds => '有聲音、不跳出橫幅,手機靜音時不會響'; + + @override + String get notifyTestBehaviourSilent => '無聲,只出現在通知中心'; + + @override + String get notifyTestFailed => '無法發送測試通知。'; + + @override + String get moreBugReports => '已回报的错误'; + + @override + String get bugTrackerEmpty => '还没有已回报的错误'; + + @override + String get bugTrackerReplies => '回复'; + + @override + String get bugTrackerGoToDiscord => '找不到你的问题?快前往 Discord 回报!'; + + @override + String get bugTrackerNoMatch => '没有符合所选标签的错误回报'; + + @override + String get bugTrackerDeveloper => '开发人员'; + + @override + String get bugTrackerCannotDisplay => '无法显示此内容,请在 Discord 上查看'; + + @override + String get bugTrackerJoinDiscussion => '至 Discord 参与讨论'; + + @override + String get bugTrackerSortLast => '最后讨论'; + + @override + String get bugTrackerSortMostDiscussed => '最多讨论'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -6315,6 +6372,63 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get rainScaleCoarse => '大间距'; + + @override + String get notifyTestTitle => '测试通知'; + + @override + String get notifyTestIntro => '点一下就会实际发送该则警报。重大警报会以最大音量播放,并穿透静音与勿扰模式。'; + + @override + String get notifyTestCriticalDenied => '这台设备未允许「重要警告」,重大警报在静音时同样不会发出声音。'; + + @override + String get notifyTestPermissionOff => '通知已关闭,测试不会有任何反应。'; + + @override + String get notifyTestBehaviourOverrides => '会穿透静音与勿扰模式'; + + @override + String get notifyTestBehaviourAlerts => '有声音并弹出横幅,但手机静音时不会响'; + + @override + String get notifyTestBehaviourSounds => '有声音、不弹出横幅,手机静音时不会响'; + + @override + String get notifyTestBehaviourSilent => '无声,只出现在通知中心'; + + @override + String get notifyTestFailed => '无法发送测试通知。'; + + @override + String get moreBugReports => '已回报的错误'; + + @override + String get bugTrackerEmpty => '还没有已回报的错误'; + + @override + String get bugTrackerReplies => '回复'; + + @override + String get bugTrackerGoToDiscord => '找不到你的问题?快前往 Discord 回报!'; + + @override + String get bugTrackerNoMatch => '没有符合所选标签的错误回报'; + + @override + String get bugTrackerDeveloper => '开发人员'; + + @override + String get bugTrackerCannotDisplay => '无法显示此内容,请在 Discord 上查看'; + + @override + String get bugTrackerJoinDiscussion => '至 Discord 参与讨论'; + + @override + String get bugTrackerSortLast => '最后讨论'; + + @override + String get bugTrackerSortMostDiscussed => '最多讨论'; } /// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). @@ -9471,6 +9585,63 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get rainScaleCoarse => '大間距'; + + @override + String get notifyTestTitle => '測試通知'; + + @override + String get notifyTestIntro => '點一下就會實際發送該則警報。重大警報會以最大音量播放,並穿透靜音與勿擾模式。'; + + @override + String get notifyTestCriticalDenied => '這台裝置未允許「重要警告」,重大警報在靜音時同樣不會發出聲音。'; + + @override + String get notifyTestPermissionOff => '通知已關閉,測試不會有任何反應。'; + + @override + String get notifyTestBehaviourOverrides => '會穿透靜音與勿擾模式'; + + @override + String get notifyTestBehaviourAlerts => '有聲音並跳出橫幅,但手機靜音時不會響'; + + @override + String get notifyTestBehaviourSounds => '有聲音、不跳出橫幅,手機靜音時不會響'; + + @override + String get notifyTestBehaviourSilent => '無聲,只出現在通知中心'; + + @override + String get notifyTestFailed => '無法發送測試通知。'; + + @override + String get moreBugReports => '已回報的錯誤'; + + @override + String get bugTrackerEmpty => '還沒有已回報的錯誤'; + + @override + String get bugTrackerReplies => '回覆'; + + @override + String get bugTrackerGoToDiscord => '找不到你的問題?快前往 Discord 回報!'; + + @override + String get bugTrackerNoMatch => '沒有符合所選標籤的錯誤回報'; + + @override + String get bugTrackerDeveloper => '開發人員'; + + @override + String get bugTrackerCannotDisplay => '無法顯示此內容,請在 Discord 上查看'; + + @override + String get bugTrackerJoinDiscussion => '至 Discord 參與討論'; + + @override + String get bugTrackerSortLast => '最後討論'; + + @override + String get bugTrackerSortMostDiscussed => '最多討論'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -12627,4 +12798,61 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get rainScaleCoarse => '大間距'; + + @override + String get notifyTestTitle => '測試通知'; + + @override + String get notifyTestIntro => '點一下就會實際發送該則警報。重大警報會以最大音量播放,並穿透靜音與勿擾模式。'; + + @override + String get notifyTestCriticalDenied => '這台裝置未允許「重要警告」,重大警報在靜音時同樣不會發出聲音。'; + + @override + String get notifyTestPermissionOff => '通知已關閉,測試不會有任何反應。'; + + @override + String get notifyTestBehaviourOverrides => '會穿透靜音與勿擾模式'; + + @override + String get notifyTestBehaviourAlerts => '有聲音並跳出橫幅,但手機靜音時不會響'; + + @override + String get notifyTestBehaviourSounds => '有聲音、不跳出橫幅,手機靜音時不會響'; + + @override + String get notifyTestBehaviourSilent => '無聲,只出現在通知中心'; + + @override + String get notifyTestFailed => '無法發送測試通知。'; + + @override + String get moreBugReports => '已回報的錯誤'; + + @override + String get bugTrackerEmpty => '還沒有已回報的錯誤'; + + @override + String get bugTrackerReplies => '回覆'; + + @override + String get bugTrackerGoToDiscord => '找不到你的問題?快前往 Discord 回報!'; + + @override + String get bugTrackerNoMatch => '沒有符合所選標籤的錯誤回報'; + + @override + String get bugTrackerDeveloper => '開發人員'; + + @override + String get bugTrackerCannotDisplay => '無法顯示此內容,請在 Discord 上查看'; + + @override + String get bugTrackerJoinDiscussion => '至 Discord 參與討論'; + + @override + String get bugTrackerSortLast => '最後討論'; + + @override + String get bugTrackerSortMostDiscussed => '最多討論'; } diff --git a/lib/shared/navigation/app_routes.dart b/lib/shared/navigation/app_routes.dart index dcbad4285..9c0b46d91 100644 --- a/lib/shared/navigation/app_routes.dart +++ b/lib/shared/navigation/app_routes.dart @@ -148,6 +148,12 @@ abstract final class AppRoutes { static const String notifySettings = 'notifySettings'; static const String notifySettingsPath = '/notify-settings'; + /// Sample alerts, fired locally. A child of the settings page because it is + /// the same subject seen from the other side: settings decide what gets sent, + /// this shows what the phone does with it. + static const String notifyTest = 'notifyTest'; + static const String notifyTestPath = 'test'; + // Support / in-app-purchase page. static const String sponsor = 'sponsor'; static const String sponsorPath = '/sponsor'; @@ -155,4 +161,12 @@ abstract final class AppRoutes { /// ExpTech server status dashboard — pushed from the More hero cards. static const String serverStatus = 'serverStatus'; static const String serverStatusPath = '/server-status'; + + /// The read-only bug-tracker mirror, pushed from the More list. + static const String bugTracker = 'bugTracker'; + static const String bugTrackerPath = '/bug-tracker'; + + /// One bug thread with its replies. + static const String bugThread = 'bugThread'; + static const String bugThreadPath = 'thread/:id'; } diff --git a/test/core/notifications/notification_samples_test.dart b/test/core/notifications/notification_samples_test.dart new file mode 100644 index 000000000..10cde439e --- /dev/null +++ b/test/core/notifications/notification_samples_test.dart @@ -0,0 +1,122 @@ +/// The test-notification catalogue's two silent failure modes. +/// +/// Neither of these breaks anything loudly. A sample whose channel key was +/// renamed simply stops being reachable; a pushed channel added without a +/// sample simply never appears on the test page — the list still renders, the +/// other rows still work, and the missing one looks like a channel nobody +/// thought to include. Both are exactly the kind of thing this app cannot +/// afford to discover from a user saying "I never heard that one". +library; + +import 'package:awesome_notifications/awesome_notifications.dart'; +import 'package:dpip/core/notifications/notification_channels.dart'; +import 'package:dpip/core/notifications/notification_samples.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The groups whose channels arrive from the backend, and therefore have a +/// server message worth reproducing. `group_mesh` is raised locally from the +/// LoRa link and `background` carries no group at all. +const _pushedGroups = { + 'group_eew', + 'group_eq', + 'group_info', + 'group_tsunami', + 'group_other', +}; + +NotificationChannel _channel(String key) => NotificationChannels.channels + .firstWhere((channel) => channel.channelKey == key); + +void main() { + group('sample coverage', () { + test('every pushed channel has a sample', () { + final missing = [ + for (final channel in NotificationChannels.channels) + if (_pushedGroups.contains(channel.channelGroupKey) && + NotificationSamples.of(channel.channelKey!) == null) + channel.channelKey, + ]; + expect( + missing, + isEmpty, + reason: + 'these channels would silently vanish from the test page: $missing', + ); + }); + + test('every sample belongs to a real channel', () { + final keys = { + for (final channel in NotificationChannels.channels) channel.channelKey, + }; + final orphans = NotificationSamples.byChannel.keys + .where((key) => !keys.contains(key)) + .toList(); + expect( + orphans, + isEmpty, + reason: 'samples that can never be fired: $orphans', + ); + }); + + test('the locally-raised and service channels are left out', () { + // Asserted rather than assumed: if mesh ever gains a sample, the page's + // "testable means pushed" rule has quietly changed and its doc is wrong. + expect(NotificationSamples.of('mesh_message'), isNull); + expect(NotificationSamples.of('mesh_node'), isNull); + expect(NotificationSamples.of('background'), isNull); + }); + + test('no sample is blank', () { + for (final entry in NotificationSamples.byChannel.entries) { + expect(entry.value.title, isNotEmpty, reason: entry.key); + expect(entry.value.body, isNotEmpty, reason: entry.key); + } + }); + }); + + group('behaviourOf', () { + test('a critical alert outranks everything else', () { + // Max importance and a sound too — the point is that `criticalAlerts` + // decides, because it is the only one of the three that pierces the + // silent switch. + expect( + NotificationChannels.behaviourOf(_channel('eew_alert-important-v2')), + NotificationBehaviour.overrides, + ); + }); + + test('no sound reads as silent whatever the importance says', () { + expect( + NotificationChannels.behaviourOf(_channel('eew_alert-silent-v2')), + NotificationBehaviour.silent, + ); + expect( + NotificationChannels.behaviourOf(_channel('report-silence-v2')), + NotificationBehaviour.silent, + ); + }); + + test('High and above earns a banner, Default does not', () { + expect( + NotificationChannels.behaviourOf(_channel('int_report-general-v2')), + NotificationBehaviour.alerts, + reason: 'High importance shows a heads-up banner', + ); + expect( + NotificationChannels.behaviourOf(_channel('report-general-v2')), + NotificationBehaviour.sounds, + reason: 'Default importance is audible but stays in the shade', + ); + }); + + test('every pushed channel resolves to some behaviour', () { + for (final channel in NotificationChannels.channels) { + expect( + () => NotificationChannels.behaviourOf(channel), + returnsNormally, + reason: channel.channelKey, + ); + } + }); + }); +} diff --git a/test/core/notifications/test_notification_content_test.dart b/test/core/notifications/test_notification_content_test.dart new file mode 100644 index 000000000..0dd20b00a --- /dev/null +++ b/test/core/notifications/test_notification_content_test.dart @@ -0,0 +1,86 @@ +/// The test notification must never be mistakable for a real one. +/// +/// Every sample reproduces an actual CWA alert word for word — "花蓮縣壽豐鄉發生 +/// 地震 強烈搖晃警戒" is what the real 緊急地震速報 says. Strip the markers and +/// what lands on a lock screen is an earthquake warning nobody issued, ready to +/// be photographed and forwarded. That is not a cosmetic regression, and it is +/// exactly the kind that survives review because the screen still looks right. +library; + +import 'dart:io'; + +import 'package:dpip/core/notifications/notification_channels.dart'; +import 'package:dpip/core/notifications/notification_samples.dart'; +import 'package:dpip/core/notifications/notification_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('every sample is marked as a test, in both the title and the body', () { + for (final key in NotificationSamples.byChannel.keys) { + final content = testNotificationContent(key); + expect(content, isNotNull, reason: key); + expect( + content!.title, + startsWith(testTitlePrefix), + reason: '$key: an unmarked title is a real alert', + ); + expect( + content.body, + startsWith(testBodyMarker), + reason: '$key: an unmarked body is a real alert', + ); + } + }); + + test('the sample survives the markers intact', () { + final sample = NotificationSamples.of('eew_alert-important-v2')!; + final content = testNotificationContent('eew_alert-important-v2')!; + expect(content.title, '$testTitlePrefix${sample.title}'); + expect(content.body, endsWith(sample.body)); + // The sample's own newlines are deliberately untouched — a real push + // renders through the same path, so converting them would make the test + // read better than the alert it reproduces. + expect(content.body, contains('\n〈預估強烈搖晃地區〉')); + }); + + test('the marker separator follows the platform that renders it', () { + final body = testNotificationContent('announcement-general-v2')!.body!; + // Android puts the body through `android.text.Html`, where a newline + // collapses to a space; iOS takes the text literally. + expect( + body, + startsWith('$testBodyMarker${Platform.isIOS ? '\n' : '
'} '), + ); + }); + + test('a channel with nothing to reproduce builds no notification', () { + expect(testNotificationContent('mesh_message'), isNull); + expect(testNotificationContent('background'), isNull); + expect(testNotificationContent('not-a-channel'), isNull); + }); + + test('each channel gets its own id, clear of the backend range', () { + final ids = []; + for (final key in NotificationSamples.byChannel.keys) { + final id = testNotificationContent(key)!.id!; + // Server alerts carry the backend's positive ids; a test must never + // overwrite a real alert sitting in the shade. + expect(id, lessThan(0), reason: key); + ids.add(id); + } + expect( + ids.toSet(), + hasLength(ids.length), + reason: 'a shared id would let one test replace another mid-comparison', + ); + }); + + test('the notification lands on the channel it is testing', () { + for (final channel in NotificationChannels.channels) { + final key = channel.channelKey!; + final content = testNotificationContent(key); + if (content == null) continue; + expect(content.channelKey, key); + } + }); +} diff --git a/test/features/bug_tracker/bug_repository_test.dart b/test/features/bug_tracker/bug_repository_test.dart new file mode 100644 index 000000000..3d54b51d6 --- /dev/null +++ b/test/features/bug_tracker/bug_repository_test.dart @@ -0,0 +1,183 @@ +/// The bug-tracker wire → domain parsers, against payloads shaped like the +/// live tracker's updated contract (2026-08-24): a `users` directory keyed by +/// Discord snowflake plus `threads`/`msg` entries that reference it by id. +library; + +import 'package:dpip/features/bug_tracker/data/bug_repository_impl.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _chenId = 1470269242916999282; +const _staffId = 879008115696230430; + +Map _user(int id, String name) => { + 'name': name, + 'img': 'https://cdn.discordapp.com/avatars/$id/x.png', +}; + +void main() { + test('the index parses into threads and resolves authors', () { + final threads = parseBugThreads({ + 'users': {_chenId: _user(_chenId, '陳')}, + 'threads': [ + { + 'threads_id': 1541158207970349066, + 'title': 'ET-2026-0122 DPIP3.9過段時間,程式會重製', + 'tags': ['DPIP', '臭蟲 bug'], + 'body': '我的dpip放一段時間,在按進去它就會回到是否同意以上...', + 'author': _chenId, + 'created_at': 1787511150, + 'message_count': 2, + 'archived': false, + 'locked': false, + 'last_message_id': 1541358858713178132, + }, + ], + }); + + expect(threads, hasLength(1)); + expect(threads.first.id, 1541158207970349066); + // The routing marker is stripped; the bilingual label keeps its head. + expect(threads.first.tags, ['臭蟲']); + expect(threads.first.authorName, '陳'); + expect( + threads.first.createdAt.toUtc(), + DateTime.utc(2026, 8, 23, 18, 52, 30), + ); + }); + + test('threads without the DPIP routing tag are dropped', () { + Map thread(int id, List tags) => { + 'threads_id': id, + 'title': 't$id', + 'tags': tags, + 'body': 'b', + 'author': _chenId, + 'created_at': 1787511150, + 'last_message_id': id, + }; + + final threads = parseBugThreads({ + 'users': {}, + 'threads': [ + thread(1, ['DPIP']), + thread(2, []), + thread(3, ['OTHER']), + ], + }); + + expect([for (final t in threads) t.id], [1]); + }); + + test('the index is ordered by last activity, not source order', () { + Map thread(int id) => { + 'threads_id': id, + 'title': 't$id', + 'tags': ['DPIP'], + 'body': 'b', + 'author': _chenId, + 'created_at': 1787511150, + 'last_message_id': 1000000 + id, + }; + + // Arrives out of order; the newest conversation leads. + final threads = parseBugThreads({ + 'users': {}, + 'threads': [thread(3), thread(1), thread(2)], + }); + + expect([for (final t in threads) t.id], [3, 2, 1]); + }); + + test('locked threads are hidden from the index', () { + Map thread(int id, {required bool locked}) => { + 'threads_id': id, + 'title': 't$id', + 'tags': ['DPIP'], + 'body': 'b', + 'author': _chenId, + 'created_at': 1787511150, + 'locked': locked, + 'last_message_id': id, + }; + + final threads = parseBugThreads({ + 'users': {}, + 'threads': [thread(1, locked: false), thread(2, locked: true)], + }); + + expect([for (final t in threads) t.id], [1]); + }); + + test('a deleted opening post arrives as null and reads as empty', () { + // Discord lets an author delete the OP while the thread survives; the + // mirror then carries "body": null. One such thread must not kill the + // whole index. + final threads = parseBugThreads({ + 'users': {}, + 'threads': [ + { + 'threads_id': 9, + 'title': 't9', + 'tags': ['DPIP'], + 'body': null, + 'author': _chenId, + 'created_at': 1787511150, + 'locked': false, + 'last_message_id': 9, + }, + ], + }); + + expect(threads, hasLength(1)); + expect(threads.first.body, isEmpty); + expect(threads.first.title, 't9'); + }); + + test('Discord custom-emote tokens are normalised to :name:', () { + final staff = _staffId; + final detail = parseBugThreadDetail({ + ..._threadShell(id: 1541158207970349066, author: staff), + 'msg': [ + { + 'id': 1541164231909576726, + 'author': _staffId, + 'msg': + '建議提供設備型號 <:biliPleased:927265154171813958> 與 ' + ' 資訊', + 'time': 1787512586, + }, + ], + }); + + expect(detail.messages, hasLength(1)); + expect(detail.messages.first.body, '建議提供設備型號 :biliPleased: 與 :shake: 資訊'); + }); + + test('a reply without renderable text keeps a null body', () { + final detail = parseBugThreadDetail({ + ..._threadShell(id: 1541158207970349066, author: staff0()), + 'msg': [ + {'id': 1, 'author': _staffId, 'msg': null, 'time': 1787512586}, + ], + }); + + expect(detail.messages.single.body, isNull); + }); +} + +int staff0() => 879008115696230430; + +Map _threadShell({required int id, required int author}) => { + 'threads_id': id, + 'title': 't$id', + 'tags': ['DPIP'], + 'body': 'b', + 'author': author, + 'created_at': 1787511150, + 'message_count': 1, + 'locked': false, + 'last_message_id': id, + 'users': { + author: {'name': 'a', 'img': ''}, + }, +}; diff --git a/test/features/more/more_page_test.dart b/test/features/more/more_page_test.dart index 8fc120138..b58ce9124 100644 --- a/test/features/more/more_page_test.dart +++ b/test/features/more/more_page_test.dart @@ -24,6 +24,10 @@ import 'package:dpip/core/settings/experimental_settings.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/core/version/app_build.dart'; +import 'package:dpip/features/bug_tracker/bug_tracker_counter.dart'; +import 'package:dpip/features/bug_tracker/domain/bug_repository.dart'; +import 'package:dpip/features/bug_tracker/domain/bug_thread.dart'; +import 'package:dpip/features/bug_tracker/data/bug_repository_impl.dart'; import 'package:dpip/features/changelog/domain/changelog_repository.dart'; import 'package:dpip/features/changelog/domain/release_note.dart'; import 'package:dpip/features/more/domain/developer_note.dart'; @@ -173,6 +177,11 @@ Future _pump( ), // The version card's contributor strip reads the changelog. Provider(create: (_) => changelog), + // The bug-tracker tile watches this counter for its badge; an empty + // index keeps the tile unbadged, which is what these tests assume. + ChangeNotifierProvider( + create: (_) => BugTrackerCounter(const _EmptyBugRepository()), + ), ], child: MaterialApp.router( localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -184,6 +193,42 @@ Future _pump( await tester.pump(); } +/// A bug repository answering an empty index — the More tile only reads the +/// counter, which never fires without an explicit load. +class _EmptyBugRepository implements BugRepository { + const _EmptyBugRepository(); + + @override + Future>> threads() async => const Ok([]); + + @override + Future> thread(int id) async { + final parsed = parseBugThreads(_emptyIndex()); + return Ok(BugThreadDetail(thread: parsed.first, messages: const [])); + } + + @override + Future> avatar(String url) async => + Ok(Uint8List.fromList([])); +} + +Map _emptyIndex() => { + 'users': {}, + 'threads': >[ + { + 'threads_id': 0, + 'title': '', + 'tags': ['DPIP'], + 'body': '', + 'author': 0, + 'created_at': 1787511150, + 'message_count': 0, + 'locked': false, + 'last_message_id': 0, + }, + ], +}; + void main() { testWidgets('offers every in-app destination', (tester) async { await _pump(tester, _router([]));