diff --git a/lib/core/version/app_build.dart b/lib/core/version/app_build.dart index e68658b97..09f8b36f1 100644 --- a/lib/core/version/app_build.dart +++ b/lib/core/version/app_build.dart @@ -53,6 +53,20 @@ abstract final class AppBuild { /// page version card shows it as the big number, above the label. static String get train => _train; + /// The release cycle this build belongs to, written `26.x`. + /// + /// Every train in a cycle ships the same highlights, so the two pages that + /// present them name the cycle rather than whichever train happens to be + /// installed: `26.1` and `26.2` both read `26.x`, and the trains after them + /// read `27.x`. Anything naming the *build* still uses [train] — the More + /// page version card and Apple's marketing version both need the real + /// number. + static String get cycle { + if (_train.isEmpty) return _train; + final dot = _train.indexOf('.'); + return '${dot < 0 ? _train : _train.substring(0, dot)}.x'; + } + /// The version the platform itself records for this build — what the OS /// shows under Settings → app. For a local debug run that is the pubspec /// placeholder (`26.1.0`); CI stamps `--build-name` on iOS and `DPIP_LABEL` diff --git a/lib/features/bug_tracker/presentation/pages/bug_list_page.dart b/lib/features/bug_tracker/presentation/pages/bug_list_page.dart index 3f53788a6..48b765c9e 100644 --- a/lib/features/bug_tracker/presentation/pages/bug_list_page.dart +++ b/lib/features/bug_tracker/presentation/pages/bug_list_page.dart @@ -354,11 +354,13 @@ class _ThreadCard extends StatelessWidget { final BugThread thread; final AvatarFetch avatarFor; + static final DateFormat _date = DateFormat('yyyy/MM/dd'); + @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; - final date = DateFormat('yyyy/MM/dd').format(thread.createdAt.toLocal()); + final date = _date.format(thread.createdAt.toLocal()); return Card( margin: EdgeInsets.zero, color: colors.surfaceContainerHigh, diff --git a/lib/features/bug_tracker/presentation/widgets/bug_avatar_image.dart b/lib/features/bug_tracker/presentation/widgets/bug_avatar_image.dart index 1ff8ede5b..fdf416539 100644 --- a/lib/features/bug_tracker/presentation/widgets/bug_avatar_image.dart +++ b/lib/features/bug_tracker/presentation/widgets/bug_avatar_image.dart @@ -32,18 +32,61 @@ class BugAvatarImage extends ImageProvider { BugAvatarImage key, ImageDecoderCallback decode, ) { - return MultiFrameImageStreamCompleter(codec: _codec(key), scale: 1); + return MultiFrameImageStreamCompleter(codec: _codec(key, decode), scale: 1); } - Future _codec(BugAvatarImage key) async { + /// The decode cap, in pixels, on the longer side of the source. + /// + /// Every call site is a small circle — `radius: 9`, `14`, `15`, so 30 logical + /// px across at the widest; 256 is headroom rather than a fitted bound, since + /// the framework promises no ceiling on the device pixel ratio and Android's + /// display-size setting and desktop display scaling both raise it past a + /// panel's nominal one. The URL is server-supplied — `users[].img` copied + /// straight out of the tracker payload, commonly a Discord CDN avatar served + /// at 1024² — and opaque to this app, which is exactly why the cap belongs in + /// the decode and not in the URL: that string is also the ETag identity and + /// has to reach the CDN unchanged. A 1024² source is 4 MB of RGBA held for + /// the session by Flutter's image cache, against 256 KB here. + /// + /// Static sources only. `ImageDescriptor.instantiateCodec` forwards a target + /// size on its single-frame path alone, so Discord's animated `a_*` avatars + /// go on decoding at native size. + static const int _maxSide = 256; + + Future _codec( + BugAvatarImage key, + ImageDecoderCallback decode, + ) 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(); + // Give the decoder one side only — `dart:ui` scales the omitted dimension + // to keep the aspect ratio, whereas passing both is a stretch-to-fit that + // would squash a non-square source `BoxFit.cover` centre-crops today. + // Going through the framework's own `decode` also disposes `buffer`, which + // the hand-rolled `ImageDescriptor` path used to leave to the collector. + return decode( + buffer, + getTargetSize: (width, height) { + if (width <= _maxSide && height <= _maxSide) { + return const ui.TargetImageSize(); + } + // `dart:ui` derives the omitted side by integer division, which + // truncates to zero once one dimension exceeds [_maxSide] times the + // other — and it clamps before that arithmetic, not after. Such a + // source is already small in its short dimension; decode it whole + // rather than ask the engine for a zero-pixel image. + if (width > height * _maxSide || height > width * _maxSide) { + return const ui.TargetImageSize(); + } + return width >= height + ? const ui.TargetImageSize(width: _maxSide) + : const ui.TargetImageSize(height: _maxSide); + }, + ); } @override diff --git a/lib/features/changelog/presentation/pages/changelog_page.dart b/lib/features/changelog/presentation/pages/changelog_page.dart index f41d777c2..c9c624cc1 100644 --- a/lib/features/changelog/presentation/pages/changelog_page.dart +++ b/lib/features/changelog/presentation/pages/changelog_page.dart @@ -236,11 +236,15 @@ class _ChangelogPageState extends State { }); } + /// Compiled once. `_isCurrent` runs per visible tile per rebuild, and Dart + /// interns nothing — every `RegExp(...)` compiles a fresh pattern. + static final RegExp _vPrefix = RegExp(r'^v'); + bool _isCurrent(ReleaseNote note) { final installed = _installedVersion; if (installed == null) return false; - final tag = note.tagName.replaceFirst(RegExp(r'^v'), ''); - final name = note.name.replaceFirst(RegExp(r'^v'), ''); + final tag = note.tagName.replaceFirst(_vPrefix, ''); + final name = note.name.replaceFirst(_vPrefix, ''); return tag == installed || name == installed; } } @@ -267,6 +271,10 @@ class _ReleaseTile extends StatelessWidget { static const _prerelease = Color(0xFFEF6C00); static const _railWidth = 28.0; + /// Parsing a locale's date pattern is not free — memoised per locale, since + /// every visible tile formats its date again on every page rebuild. + static final Map _dateFormats = {}; + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -277,9 +285,10 @@ class _ReleaseTile extends StatelessWidget { ? Icons.science_outlined : Icons.verified_outlined; final title = note.name.isEmpty ? note.tagName : note.name; - final date = DateFormat.yMMMd( - intlDateLocale(Localizations.localeOf(context)), - ).format(note.publishedAt.toLocal()); + final dateLocale = intlDateLocale(Localizations.localeOf(context)); + final date = _dateFormats + .putIfAbsent(dateLocale, () => DateFormat.yMMMd(dateLocale)) + .format(note.publishedAt.toLocal()); final emphasized = isCurrent || expanded; return CustomPaint( @@ -407,8 +416,12 @@ class _ReleaseTile extends StatelessWidget { thickness: 1, color: colors.outlineVariant.withValues(alpha: 0.55), ), - if (contributorsFromBody(note.body).isNotEmpty || - note.htmlUrl.isNotEmpty) + // `htmlUrl` first: it is a field read, while the contributor + // test walks the whole multi-language body, and a GitHub release + // always carries a URL — so this drops the guard's own scan. The + // strip below still runs one of its own for the badges it draws. + if (note.htmlUrl.isNotEmpty || + contributorsFromBody(note.body).isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB( AppSpacing.lg, diff --git a/lib/features/changelog/presentation/pages/version_notes_page.dart b/lib/features/changelog/presentation/pages/version_notes_page.dart index 65c465240..836524fd5 100644 --- a/lib/features/changelog/presentation/pages/version_notes_page.dart +++ b/lib/features/changelog/presentation/pages/version_notes_page.dart @@ -94,11 +94,11 @@ class VersionNotesPage extends StatelessWidget { AppSpacing.xl + MediaQuery.paddingOf(context).bottom, ), children: [ - // The version's own story, one level further in: the train's - // key highlights, named for the release (e.g. 26.1 重點整理) + // The version's own story, one level further in: the cycle's + // key highlights, named for the cycle (e.g. 26.x 重點整理) // rather than this build. Sits right under the app bar so the // reader finds the summary first, before this build's note. - _HighlightsEntry(train: AppBuild.train), + _HighlightsEntry(cycle: AppBuild.cycle), const SizedBox(height: AppSpacing.md), _Header(note: note, isStable: stable), const SizedBox(height: AppSpacing.md), @@ -206,9 +206,9 @@ class _Header extends StatelessWidget { /// level further in from this build's own note. Label carries the train /// number so the reader sees where the note they just read fits. class _HighlightsEntry extends StatelessWidget { - const _HighlightsEntry({required this.train}); + const _HighlightsEntry({required this.cycle}); - final String train; + final String cycle; @override Widget build(BuildContext context) { @@ -252,7 +252,7 @@ class _HighlightsEntry extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - l10n.releaseHighlightsTitle(train), + l10n.releaseHighlightsTitle(cycle), style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w800, letterSpacing: -0.2, diff --git a/lib/features/data/presentation/pages/planets_page.dart b/lib/features/data/presentation/pages/planets_page.dart index be5138807..cd88342bc 100644 --- a/lib/features/data/presentation/pages/planets_page.dart +++ b/lib/features/data/presentation/pages/planets_page.dart @@ -49,15 +49,17 @@ class PlanetsPage extends StatelessWidget { ? null : Observer(latitude: town.lat, longitude: town.lng); - final entries = [ - for (final planet in Planet.values) + // `PlanetEphemeris.at` solves Kepler three times — Earth, the planet, then + // the planet again for light-time — not a lookup, so bind it once per + // planet and reuse it for the horizontal look-up below. + final entries = <_Entry>[]; + for (final planet in Planet.values) { + final body = PlanetEphemeris.at(planet, now); + entries.add( _Entry( planet: planet, - body: PlanetEphemeris.at(planet, now), - now: observer?.lookAt( - PlanetEphemeris.at(planet, now).equatorial, - now, - ), + body: body, + now: observer?.lookAt(body.equatorial, now), events: observer == null ? null : RiseSet.solve( @@ -67,7 +69,9 @@ class PlanetsPage extends StatelessWidget { horizon: (_) => pointHorizon, ), ), - ]..sort((a, b) => b.rank.compareTo(a.rank)); + ); + } + entries.sort((a, b) => b.rank.compareTo(a.rank)); return Scaffold( appBar: AppBar(title: Text(l10n.planetsTitle)), diff --git a/lib/features/earthquake/presentation/widgets/report_filter_sheet.dart b/lib/features/earthquake/presentation/widgets/report_filter_sheet.dart index ca52ae442..0da4d2521 100644 --- a/lib/features/earthquake/presentation/widgets/report_filter_sheet.dart +++ b/lib/features/earthquake/presentation/widgets/report_filter_sheet.dart @@ -292,9 +292,8 @@ class _ReportFilterSheetState extends State<_ReportFilterSheet> { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final colors = theme.colorScheme; - final media = MediaQuery.of(context); final dateFmt = DateFormat('yyyy/MM/dd'); - final height = media.size.height; + final height = MediaQuery.sizeOf(context).height; return SizedBox( height: height, diff --git a/lib/features/events/presentation/widgets/event_timeline.dart b/lib/features/events/presentation/widgets/event_timeline.dart index 2ddefa0da..aabed2e7d 100644 --- a/lib/features/events/presentation/widgets/event_timeline.dart +++ b/lib/features/events/presentation/widgets/event_timeline.dart @@ -68,54 +68,81 @@ class _EventTile extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; - return IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _Connector( + return Stack( + children: [ + // The rail spans the whole tile, but the tile's height comes from the + // text beside it — or the dot, whichever is taller — which a Row can + // only hand back through an IntrinsicHeight, i.e. a speculative pass + // that re-measures all three Texts on every layout of the tile, not + // just on inflation: a width or text-scale change re-runs it too. + // Positioning the connector against the Stack gets it the same tight + // height for nothing: the Row below sizes the Stack, the connector then + // fills it. + PositionedDirectional( + start: 0, + top: 0, + bottom: 0, + width: _Connector._dotSize, + child: _Connector( icon: eventTypeIcon(event.type.iconKey), isFirst: isFirst, isLast: isLast, ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.xl), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _clockFormat.format(event.time), - style: theme.textTheme.labelMedium?.copyWith( - color: colors.onSurfaceVariant, + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Holds open the column the connector is positioned over, plus the + // gap after it. Its height is the connector's own: a tile with very + // little text must still be tall enough for the dot, which is what + // IntrinsicHeight used to guarantee. + const SizedBox( + width: _Connector._dotSize + AppSpacing.md, + height: _Connector._minHeight, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xl), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _clockFormat.format(event.time), + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onSurfaceVariant, + ), ), - ), - const SizedBox(height: AppSpacing.xs), - Text( - event.title, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, + const SizedBox(height: AppSpacing.xs), + Text( + event.title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), ), - ), - const SizedBox(height: AppSpacing.xs), - Text( - event.description, - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, + const SizedBox(height: AppSpacing.xs), + Text( + event.description, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), ), - ), - ], + ], + ), ), ), - ), - ], - ), + ], + ), + ], ); } } -/// The left rail: a connecting line with an icon dot, so consecutive events read -/// as one thread ([isFirst]/[isLast] trim the line at the ends). +/// The leading rail — start-side, so it mirrors to the right under RTL: a +/// connecting line with an icon dot, so consecutive events read as one thread +/// ([isFirst]/[isLast] trim the line at the ends). +/// +/// [_EventTile] positions this to the full height of its tile, so the trailing +/// [Expanded] line can fill whatever is left below the dot. class _Connector extends StatelessWidget { const _Connector({ required this.icon, @@ -129,6 +156,10 @@ class _Connector extends StatelessWidget { static const double _dotSize = 36; + /// Stub plus dot — the shortest this can draw itself. [_EventTile] reserves + /// it in its Row so the tile is never too short to hold the dot. + static const double _minHeight = AppSpacing.sm + _dotSize; + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index 302a6cff7..0585cd329 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -69,7 +69,7 @@ class _HomePageState extends State { /// content and is never dimmed with it. static const double _mapDimPeak = 0.35; - /// The filter instance last handed to the [ImageFiltered] — [ImageFilter] + /// The filter instance last handed to the [BackdropFilter] — [ImageFilter] /// has no value equality, so a fresh `blur(...)` per drag tick would /// recomposite the full-screen blur every frame even though the sigma /// quantises to the same step (same pattern as [_CachedBlur] in HomeSheet). @@ -226,14 +226,23 @@ class _HomePageState extends State { sigmaY: sigma, ); } - // The tree's shape never changes — no SizedBox/ImageFiltered - // swap at t=0, which would re-parent the subtree right over - // the map platform view at the exact edge the sheet starts - // climbing (the same re-parent flash as the sheet's sky). - // `enabled` makes the filter a no-op at rest without - // touching the tree. - return ImageFiltered( - imageFilter: _mapBlur!, + // [BackdropFilter], not [ImageFiltered]: the filter has + // to reach the map painted *beneath* this layer, and the + // map backdrop is the Stack child directly below. An + // ImageFiltered filters its own child instead, and that + // child is a uniform ColoredBox — blurring a flat colour + // moves nothing but the feathering of its outer edge, so + // the map stayed sharp however high the sheet climbed. + // + // The tree's shape never changes — no SizedBox/Backdrop- + // Filter swap at t=0, which would re-parent the subtree + // right over the map platform view at the exact edge the + // sheet starts climbing (the same re-parent flash as the + // sheet's sky). `enabled` makes the filter a no-op at + // rest without touching the tree: disabled, it paints the + // child straight through and reads back nothing. + return BackdropFilter( + filter: _mapBlur!, enabled: t > 0, child: ColoredBox( color: Colors.black.withValues(alpha: dim), diff --git a/lib/features/location/presentation/pages/region_city_page.dart b/lib/features/location/presentation/pages/region_city_page.dart index b4bda67e8..5baf5c0c1 100644 --- a/lib/features/location/presentation/pages/region_city_page.dart +++ b/lib/features/location/presentation/pages/region_city_page.dart @@ -44,6 +44,27 @@ class RegionCityPage extends StatefulWidget { class _RegionCityPageState extends State { final _searchController = TextEditingController(); + /// The townships of [RegionCityPage.city]. The directory is one immutable + /// instance for the app's life, so scan it once per city instead of once per + /// build: [build] re-runs on every keystroke and the scan walks all 368 + /// towns, building a `cityName` string for each. + late List _towns = context.read().townsInCity( + widget.city, + ); + + @override + void didUpdateWidget(covariant RegionCityPage oldWidget) { + super.didUpdateWidget(oldWidget); + // go_router keys a page by its route *pattern* (`:city`), not by the + // resolved city, so a `go()` or a deep link to another city reuses this + // State with a different `widget.city`. The push path used today mints a + // fresh one, which is what makes the scan above an optimisation and this + // re-scan the thing that keeps it correct. + if (oldWidget.city != widget.city) { + _towns = context.read().townsInCity(widget.city); + } + } + @override void dispose() { _searchController.dispose(); @@ -53,15 +74,15 @@ class _RegionCityPageState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); - final directory = context.read(); final store = context.watch(); - final towns = directory.townsInCity(widget.city); + // Read `savedCodes` once (the getter allocates a fresh list each call). + final savedCodes = store.savedCodes; final query = GoRouterState.of(context).uri.queryParameters; final effectiveReplaceCode = widget.replaceCode ?? query['replace']; final needle = _searchController.text.trim().toLowerCase(); final shown = [ - for (final town in towns) + for (final town in _towns) if (needle.isEmpty || town.townName.toLowerCase().contains(needle)) town, ]; @@ -100,10 +121,7 @@ class _RegionCityPageState extends State { ), ), SectionHeader( - l10n.regionSelectCount( - store.savedCodes.length, - RegionStore.maxSaved, - ), + l10n.regionSelectCount(savedCodes.length, RegionStore.maxSaved), ), if (shown.isEmpty) EmptyView( @@ -114,15 +132,15 @@ class _RegionCityPageState extends State { for (final town in shown) _TownTile( town: town, - saved: store.savedCodes.contains(town.code), + saved: savedCodes.contains(town.code), enabled: effectiveReplaceCode == null || town.code == effectiveReplaceCode || - !store.savedCodes.contains(town.code), + !savedCodes.contains(town.code), canAdd: effectiveReplaceCode != null || store.canSave(town.code) || - store.savedCodes.contains(town.code), + savedCodes.contains(town.code), onToggle: () => _toggle(context, store, town), ), ], diff --git a/lib/features/location/presentation/pages/region_select_page.dart b/lib/features/location/presentation/pages/region_select_page.dart index aa5991a0b..f2415839a 100644 --- a/lib/features/location/presentation/pages/region_select_page.dart +++ b/lib/features/location/presentation/pages/region_select_page.dart @@ -35,6 +35,15 @@ class RegionSelectPage extends StatefulWidget { class _RegionSelectPageState extends State { final _searchController = TextEditingController(); + /// The city list, read once for the page's lifetime. + /// + /// [TownDirectory.cities] is a getter that walks every township to collapse + /// them into the ~20 city names, and [build] runs on every character typed + /// into the search field. The directory is built once at bootstrap and never + /// mutated, so the result cannot differ between keystrokes — the filter below + /// narrows this list instead of rebuilding it. + late final List _cities = context.read().cities; + @override void dispose() { _searchController.dispose(); @@ -57,7 +66,7 @@ class _RegionSelectPageState extends State { }; final needle = _searchController.text.trim().toLowerCase(); final cities = [ - for (final city in directory.cities) + for (final city in _cities) if (needle.isEmpty || city.toLowerCase().contains(needle)) city, ]; diff --git a/lib/features/map/presentation/widgets/monitor_eew_card.dart b/lib/features/map/presentation/widgets/monitor_eew_card.dart index c3e182c7c..8839eb44e 100644 --- a/lib/features/map/presentation/widgets/monitor_eew_card.dart +++ b/lib/features/map/presentation/widgets/monitor_eew_card.dart @@ -2,7 +2,8 @@ /// monitor's `EewCard`, sharing its domain math (`estimateLocalShaking`) and /// its tile styling (`EewEstimateTile`), so the map overlay's numbers and /// colours can never drift from the monitor's. The S-wave countdown ticks -/// against the calibrated [AppTime] clock and stops on dispose. +/// against the calibrated [AppTime] clock, pauses while the map tab is not the +/// selected branch, and stops on dispose. /// /// Lives in this feature (not `features/earthquake`) because the layering gate /// forbids `features/map` importing another feature's presentation; the home @@ -60,9 +61,30 @@ class MonitorEewCard extends StatefulWidget { class _MonitorEewCardState extends State with SecondTicker { /// The RTS panel's own gate suppresses feed-notify rebuilds behind other /// tabs, but the countdown has its own timer — same gate here. + /// + /// Deliberately the tab test only, not [VisibleTab.isOnScreen], and for a + /// sharper reason than [RefreshOnAppear]'s: `isOnScreen` also goes false for + /// *any* root-navigator push, and `showDialog` defaults to that navigator + /// while painting a translucent barrier. Gating on it would freeze a live + /// S-wave countdown at whatever second it held, in full view around the + /// dialog — a stale safety number presented as current. An unselected branch + /// is genuinely unpainted (`_RenderIndexedStack` paints only the selected + /// child), so the branch test alone carries the whole saving safely. @override bool get secondTickerActive => - VisibleTabScope.of(context)?.isOnScreen(MapPage.tabIndex) ?? true; + (_visibleTab?.value ?? MapPage.tabIndex) == MapPage.tabIndex; + + /// The shell's visible-tab notifier, subscribed to rather than merely read. + /// + /// [SecondTicker] re-reads [secondTickerActive] on every [syncSecondTicker], + /// so the gate is not latched — but nothing *calls* that sync on a tab + /// change, because [VisibleTabScope] hands the same instance down for the + /// page's whole life and so never notifies its dependents. Reading the scope + /// is how a consumer finds the notifier; only the subscription is a change + /// signal. Before this, the timer stayed in whatever state the lifecycle + /// edges last left it in. The panel that hosts this card subscribes the same + /// way for the same reason. + VisibleTab? _visibleTab; /// The CWA P/S travel-time table once it resolves — the countdown settles on /// the table's arrival time the moment it loads (see [estimateLocalShaking]). @@ -87,6 +109,26 @@ class _MonitorEewCardState extends State with SecondTicker { }); } + @override + void didChangeDependencies() { + // Ahead of `super`, which runs [SecondTicker]'s own first sync: the gate + // above has to find the notifier before it is evaluated, or that sync + // reads the null fallback and starts the timer on a hidden card. + final visibleTab = VisibleTabScope.of(context); + if (!identical(visibleTab, _visibleTab)) { + _visibleTab?.removeListener(syncSecondTicker); + _visibleTab = visibleTab; + visibleTab?.addListener(syncSecondTicker); + } + super.didChangeDependencies(); + } + + @override + void dispose() { + _visibleTab?.removeListener(syncSecondTicker); + super.dispose(); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); diff --git a/lib/features/map/presentation/widgets/typhoon_forecast_callouts.dart b/lib/features/map/presentation/widgets/typhoon_forecast_callouts.dart index b63785a9b..7b9f7dbd7 100644 --- a/lib/features/map/presentation/widgets/typhoon_forecast_callouts.dart +++ b/lib/features/map/presentation/widgets/typhoon_forecast_callouts.dart @@ -603,6 +603,12 @@ class _LeaderPainter extends CustomPainter { ..strokeWidth = 1.4 ..style = PaintingStyle.stroke; final fill = Paint()..color = color; + // The ring never varies with the callout, so it is built once beside + // `paint` and `fill` instead of once per anchor. + final ring = Paint() + ..color = Colors.white.withValues(alpha: 0.9) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2; for (final c in callouts) { final attach = Offset( c.tip.dx + c.width / 2, @@ -610,29 +616,34 @@ class _LeaderPainter extends CustomPainter { ); _dashLine(canvas, paint, attach, c.anchor); canvas.drawCircle(c.anchor, 3, fill); - canvas.drawCircle( - c.anchor, - 3, - Paint() - ..color = Colors.white.withValues(alpha: 0.9) - ..style = PaintingStyle.stroke - ..strokeWidth = 1.2, - ); + canvas.drawCircle(c.anchor, 3, ring); } } + /// Draws a dashed leader from [a] to [b] as one stroked path. + /// + /// The path stays per-leader on purpose. Its own dashes are collinear and + /// disjoint (butt caps, 3 px gap) so one stroke covers exactly what separate + /// lines would; a path shared across callouts would not, because the + /// leader colour is translucent — a crossing would blend once instead of + /// twice — and it would force every anchor dot above or below every leader. void _dashLine(Canvas canvas, Paint paint, Offset a, Offset b) { const dash = 4.5; const gap = 3.0; final total = (b - a).distance; if (total < 1) return; final dir = (b - a) / total; + final path = Path(); var t = 0.0; while (t < total) { final t2 = math.min(t + dash, total); - canvas.drawLine(a + dir * t, a + dir * t2, paint); + final from = a + dir * t; + final to = a + dir * t2; + path.moveTo(from.dx, from.dy); + path.lineTo(to.dx, to.dy); t = t2 + gap; } + canvas.drawPath(path, paint); } @override diff --git a/lib/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index 4771dc6de..34f5e6868 100644 --- a/lib/features/more/presentation/pages/more_page.dart +++ b/lib/features/more/presentation/pages/more_page.dart @@ -144,7 +144,8 @@ class MorePage extends StatelessWidget { title: l10n.meshtasticTitle, // A message arrived in a conversation the user has not read — // the same state as the chat page's unread pills, selected - // down to one boolean so only this tile rebuilds. + // down to one boolean so the page rebuilds only when that + // boolean flips, not on every mesh packet. alert: context.select((u) => u.hasUnread), onTap: () => context.pushNamed(AppRoutes.meshtastic), ), @@ -155,7 +156,9 @@ class MorePage extends StatelessWidget { children: [ // Hidden until ten taps on the Developer page's version row // (ExperimentalSettings.unlocked). - if (context.watch().unlocked) + if (context.select( + (s) => s.unlocked, + )) _MoreTile( icon: Icons.science_outlined, title: l10n.experimentalFeatures, @@ -171,11 +174,15 @@ class MorePage extends StatelessWidget { 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(), + // pull-to-refresh. Read through a Consumer rather than on the + // page's context: that refresh notifies while the bug list is + // pushed over this page, and only this trailing slot wants + // the number. + trailing: Consumer( + builder: (context, counter, _) => _BugReportCount( + counter: counter, + onLoad: counter.ensureLoaded, + ), ), onTap: () => context.pushNamed(AppRoutes.bugTracker), ), @@ -421,10 +428,14 @@ class _MoreGroup extends StatelessWidget { ), ); } - rows.add(Material(type: MaterialType.transparency, child: children[i])); + rows.add(children[i]); } // Material (not DecoratedBox) so ListTile ink paints on this ancestor — - // a colored DecoratedBox between tile and Material asserts in debug. + // a colored DecoratedBox between tile and Material asserts in debug. One + // Material for the whole card is all it takes: every row's ink comes from + // a ListTile or a button, so the splash is bounded by its own InkWell and + // clipped by this card's rounded rect either way — a transparent Material + // per row would host nothing this one does not. return Padding( padding: const EdgeInsets.fromLTRB( AppSpacing.lg, @@ -943,32 +954,18 @@ class _DeveloperNoteCard extends StatelessWidget { /// ranking is carried by the gold alone, rendered flat: a warm champagne /// fill, a hairline along the edge, and a filled badge holding the most /// saturated step. -class _SupportCallout extends StatefulWidget { +class _SupportCallout extends StatelessWidget { const _SupportCallout(); - @override - State<_SupportCallout> createState() => _SupportCalloutState(); -} - -class _SupportCalloutState extends State<_SupportCallout> - with SingleTickerProviderStateMixin { - /// The border's breathing pulse — a slow sine that keeps the gold border - /// gently swelling, so the card draws the eye without any of the strobing - /// an opacity blink would. Repeats forever, but costs nothing when the card - /// is off screen (the ticker pauses) and the test suite treats it as a - /// plain animation. - late final AnimationController _breath = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1800), - lowerBound: 0.55, - upperBound: 1.0, - )..repeat(reverse: true); - - @override - void dispose() { - _breath.dispose(); - super.dispose(); - } + /// The hairline's alpha, formerly sampled from a repeating controller that + /// nothing listened to — no `AnimatedBuilder`, no listener, no `setState`. + /// The controller pumped frames for as long as this page was mounted and the + /// border never animated once: `build` read `.value` at whatever phase the + /// sine happened to be in, so a cold first build drew the 0.55 lower bound + /// and every later rebuild (theme, locale, an unread-count change) froze an + /// arbitrary brighter edge until the next one. 0.55 is the resting value + /// that was, and the only one the card reliably showed. + static const double _edgeAlpha = 0.55; @override Widget build(BuildContext context) { @@ -979,7 +976,7 @@ class _SupportCalloutState extends State<_SupportCallout> decoration: BoxDecoration( color: gold.fill, borderRadius: AppRadius.large, - border: Border.all(color: gold.edge.withValues(alpha: _breath.value)), + border: Border.all(color: gold.edge.withValues(alpha: _edgeAlpha)), // No gradient: the card sits on the same tonal plane as its two // neighbours, and the ranking is carried by the gold colour alone — // the badge is what reads as paid, not the sheen. diff --git a/lib/features/release_highlights/data/release_highlight_repository.dart b/lib/features/release_highlights/data/release_highlight_repository.dart index e5eaccf7f..073d58d18 100644 --- a/lib/features/release_highlights/data/release_highlight_repository.dart +++ b/lib/features/release_highlights/data/release_highlight_repository.dart @@ -1,18 +1,20 @@ -/// Loads the current version's highlight cards from the content package. +/// Loads the current cycle's highlight cards from the content package. /// -/// Each version's cards live as Dart source in `package:dpip_release_highlights` -/// (`lib//{normal,advanced}.dart`) — the *current* version's files -/// are imported below. Older versions stay in the package as the archive and -/// are never compiled into a build. When a new version ships, replace these two -/// imports with the new version's; nothing else changes. +/// Highlights are written once per *cycle*, not per train: every 26 release +/// reads the same `26.x` deck, and 27 opens a new one. They live as Dart source +/// in `package:dpip_release_highlights` (`lib//{normal,advanced}.dart`) +/// — the current cycle's files are imported below. Closed cycles stay in the +/// package as the archive and are never compiled into a build. When a cycle +/// opens, replace these two imports with its own; nothing else changes. /// -/// Content is authored as JSON at `release_highlights//…/cards.json` -/// and compiled to Dart by `tool/gen/release_highlights.py`. +/// Content is authored as JSON at +/// `release_highlights/assets//…/cards.json` and compiled to Dart by +/// `tool/gen/release_highlights.py`. library; import 'package:dpip/features/release_highlights/domain/release_highlight.dart'; -import 'package:dpip_release_highlights/26.1/advanced.dart' as current_advanced; -import 'package:dpip_release_highlights/26.1/normal.dart' as current_normal; +import 'package:dpip_release_highlights/26.x/advanced.dart' as current_advanced; +import 'package:dpip_release_highlights/26.x/normal.dart' as current_normal; /// Stateless loader that assembles [HighlightDeck]s from the current version's /// Dart content. diff --git a/lib/features/release_highlights/presentation/pages/release_highlights_page.dart b/lib/features/release_highlights/presentation/pages/release_highlights_page.dart index 3790932f7..603e77037 100644 --- a/lib/features/release_highlights/presentation/pages/release_highlights_page.dart +++ b/lib/features/release_highlights/presentation/pages/release_highlights_page.dart @@ -10,7 +10,7 @@ import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -/// The page behind the version card's chevron — the train's key highlights +/// The page behind the version card's chevron — the cycle's key highlights /// and technical notes. class ReleaseHighlightsPage extends StatelessWidget { const ReleaseHighlightsPage({super.key}); @@ -22,7 +22,7 @@ class ReleaseHighlightsPage extends StatelessWidget { length: 2, child: Scaffold( appBar: AppBar( - title: Text(l10n.releaseHighlightsTitle(AppBuild.train)), + title: Text(l10n.releaseHighlightsTitle(AppBuild.cycle)), bottom: TabBar( tabs: [ Tab(text: l10n.releaseHighlightsTabNormal), diff --git a/lib/features/release_highlights/presentation/widgets/highlight_card.dart b/lib/features/release_highlights/presentation/widgets/highlight_card.dart index 62cb0d089..2506c87cc 100644 --- a/lib/features/release_highlights/presentation/widgets/highlight_card.dart +++ b/lib/features/release_highlights/presentation/widgets/highlight_card.dart @@ -69,100 +69,98 @@ class _ReleaseHighlightTile extends StatelessWidget { : localized(card.headline!, tag); final stat = card.stat == null ? null : localized(card.stat!, tag); - return Theme( - data: theme.copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - key: PageStorageKey('release-highlight-${card.id}'), - maintainState: true, - backgroundColor: Colors.transparent, - collapsedBackgroundColor: Colors.transparent, - shape: const Border(), - collapsedShape: const Border(), - tilePadding: const EdgeInsets.fromLTRB( - AppSpacing.lg, - AppSpacing.sm, - AppSpacing.md, - AppSpacing.sm, - ), - childrenPadding: const EdgeInsets.fromLTRB( - AppSpacing.lg, - 0, - AppSpacing.lg, - AppSpacing.lg, - ), - leading: Icon( - highlightIcon(card.icon), - color: colors.primary, - size: 24, - ), - title: Text( - localized(card.title, tag), - style: theme.textTheme.titleMedium?.copyWith( - color: colors.onSurface, - fontWeight: FontWeight.w700, - height: 1.3, - ), + // `shape` and `collapsedShape` are both load-bearing, and neither is + // decoration: ExpansionTile falls back to a Border built from + // `theme.dividerColor` for the expanded state and to a transparent one for + // the collapsed state, so dropping `shape` draws a line above and below + // every expanded segment. They are why the `Theme(dividerColor: + // transparent)` wrapper that used to sit here was inert. + return ExpansionTile( + key: PageStorageKey('release-highlight-${card.id}'), + backgroundColor: Colors.transparent, + collapsedBackgroundColor: Colors.transparent, + shape: const Border(), + collapsedShape: const Border(), + tilePadding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.sm, + ), + childrenPadding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + 0, + AppSpacing.lg, + AppSpacing.lg, + ), + leading: Icon(highlightIcon(card.icon), color: colors.primary, size: 24), + title: Text( + localized(card.title, tag), + style: theme.textTheme.titleMedium?.copyWith( + color: colors.onSurface, + fontWeight: FontWeight.w700, + height: 1.3, ), - subtitle: headline == null && stat == null - ? null - : Padding( - padding: const EdgeInsets.only(top: AppSpacing.xs), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (headline != null) - Text( - headline, - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, - height: 1.45, - ), + ), + subtitle: headline == null && stat == null + ? null + : Padding( + padding: const EdgeInsets.only(top: AppSpacing.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (headline != null) + Text( + headline, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + height: 1.45, ), - if (stat != null) ...[ - const SizedBox(height: AppSpacing.sm), - Text( - stat, - style: theme.textTheme.labelLarge?.copyWith( - color: colors.primary, - fontWeight: FontWeight.w700, - fontFeatures: const [FontFeature.tabularFigures()], - ), + ), + if (stat != null) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + stat, + style: theme.textTheme.labelLarge?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], ), - ], + ), ], - ), - ), - expandedCrossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (card.body != null) - Text( - localized(card.body!, tag), - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, - height: 1.6, + ], ), ), - if (card.statLabel != null) ...[ - if (card.body != null) const SizedBox(height: AppSpacing.md), - Text( - localized(card.statLabel!, tag), - style: theme.textTheme.bodySmall?.copyWith( - color: colors.onSurfaceVariant, - height: 1.5, - ), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (card.body != null) + Text( + localized(card.body!, tag), + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + height: 1.6, ), - ], - if (card.highlights.isNotEmpty) ...[ - if (card.body != null || card.statLabel != null) - const SizedBox(height: AppSpacing.lg), - for (var index = 0; index < card.highlights.length; index++) ...[ - _Bullet(text: localized(card.highlights[index], tag)), - if (index < card.highlights.length - 1) - const SizedBox(height: AppSpacing.sm), - ], + ), + if (card.statLabel != null) ...[ + if (card.body != null) const SizedBox(height: AppSpacing.md), + Text( + localized(card.statLabel!, tag), + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + height: 1.5, + ), + ), + ], + if (card.highlights.isNotEmpty) ...[ + if (card.body != null || card.statLabel != null) + const SizedBox(height: AppSpacing.lg), + for (var index = 0; index < card.highlights.length; index++) ...[ + _Bullet(text: localized(card.highlights[index], tag)), + if (index < card.highlights.length - 1) + const SizedBox(height: AppSpacing.sm), ], ], - ), + ], ); } } @@ -238,61 +236,59 @@ class _TechnicalHighlightTile extends StatelessWidget { final colors = theme.colorScheme; final tag = localeTagOf(context); - return Theme( - data: theme.copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - key: PageStorageKey('technical-highlight-${card.id}'), - maintainState: true, - backgroundColor: Colors.transparent, - collapsedBackgroundColor: Colors.transparent, - shape: const Border(), - collapsedShape: const Border(), - tilePadding: const EdgeInsets.fromLTRB( - AppSpacing.lg, - AppSpacing.sm, - AppSpacing.md, - AppSpacing.sm, - ), - childrenPadding: const EdgeInsets.fromLTRB( - AppSpacing.lg, - 0, - AppSpacing.lg, - AppSpacing.lg, - ), - leading: Icon( - highlightIcon(card.icon), - color: colors.primary, - size: 24, - ), - title: Text( - localized(card.title, tag), - style: theme.textTheme.titleMedium?.copyWith( - color: colors.onSurface, - fontWeight: FontWeight.w700, - height: 1.35, - ), + // `shape` and `collapsedShape` are both load-bearing, and neither is + // decoration: ExpansionTile falls back to a Border built from + // `theme.dividerColor` for the expanded state and to a transparent one for + // the collapsed state, so dropping `shape` draws a line above and below + // every expanded segment. They are why the `Theme(dividerColor: + // transparent)` wrapper that used to sit here was inert. + return ExpansionTile( + key: PageStorageKey('technical-highlight-${card.id}'), + backgroundColor: Colors.transparent, + collapsedBackgroundColor: Colors.transparent, + shape: const Border(), + collapsedShape: const Border(), + tilePadding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.sm, + ), + childrenPadding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + 0, + AppSpacing.lg, + AppSpacing.lg, + ), + leading: Icon(highlightIcon(card.icon), color: colors.primary, size: 24), + title: Text( + localized(card.title, tag), + style: theme.textTheme.titleMedium?.copyWith( + color: colors.onSurface, + fontWeight: FontWeight.w700, + height: 1.35, ), - expandedCrossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (card.body != null) - Text( - localized(card.body!, tag), - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, - height: 1.65, - ), + ), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (card.body != null) + Text( + localized(card.body!, tag), + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + height: 1.65, ), - if (card.details.isNotEmpty) ...[ - if (card.body != null) const SizedBox(height: AppSpacing.lg), - _TechnicalDetails(details: card.details, tag: tag), - ], - if (card.stats.isNotEmpty) ...[ - if (card.body != null || card.details.isNotEmpty) - const SizedBox(height: AppSpacing.lg), - _StatRows(stats: card.stats, tag: tag), - ], + ), + if (card.details.isNotEmpty) ...[ + if (card.body != null) const SizedBox(height: AppSpacing.lg), + _TechnicalDetails(details: card.details, tag: tag), ], - ), + if (card.stats.isNotEmpty) ...[ + if (card.body != null || card.details.isNotEmpty) + const SizedBox(height: AppSpacing.lg), + _StatRows(stats: card.stats, tag: tag), + ], + ], ); } } diff --git a/lib/features/status/presentation/pages/server_status_page.dart b/lib/features/status/presentation/pages/server_status_page.dart index 39f37cb8f..183e031e8 100644 --- a/lib/features/status/presentation/pages/server_status_page.dart +++ b/lib/features/status/presentation/pages/server_status_page.dart @@ -381,7 +381,7 @@ class _ClientEndpoints extends StatelessWidget { children: [ _SummaryBanner(summary: summary), const SizedBox(height: AppSpacing.md), - _Legend(), + const _Legend(), const SizedBox(height: AppSpacing.md), for (final g in _groups) ...[ _ServiceTable( diff --git a/lib/features/weather/presentation/widgets/weather_ranking_row.dart b/lib/features/weather/presentation/widgets/weather_ranking_row.dart index ff95d2dc7..35409f327 100644 --- a/lib/features/weather/presentation/widgets/weather_ranking_row.dart +++ b/lib/features/weather/presentation/widgets/weather_ranking_row.dart @@ -93,7 +93,7 @@ class WeatherRankingRow extends StatelessWidget { vertical: AppSpacing.xs, ), child: Material( - color: Colors.transparent, + type: MaterialType.transparency, child: InkWell( onTap: onTap, borderRadius: AppRadius.small, diff --git a/lib/shared/map/map_timeline.dart b/lib/shared/map/map_timeline.dart index daf6d4d91..f20b9a40f 100644 --- a/lib/shared/map/map_timeline.dart +++ b/lib/shared/map/map_timeline.dart @@ -601,6 +601,14 @@ class _MapTimelineState extends State { controller: _scroll, scrollDirection: Axis.horizontal, physics: const _ScrubPhysics(), + // A tick is a hairline and a label — cheaper to repaint + // than to composite, and the whole ruler moves together + // when scrubbed, so per-child layers would all be + // invalidated at once anyway. Nothing here holds state + // worth keeping alive off screen either: [_Tick] is + // stateless and rebuilt from `frames` on demand. + addRepaintBoundaries: false, + addAutomaticKeepAlives: false, padding: EdgeInsets.symmetric(horizontal: pad), itemExtent: widget.itemExtent, itemCount: widget.frames.length, diff --git a/release_highlights/assets/26.1/advanced/cards.json b/release_highlights/assets/26.x/advanced/cards.json similarity index 99% rename from release_highlights/assets/26.1/advanced/cards.json rename to release_highlights/assets/26.x/advanced/cards.json index 699cc37b5..cb59788ee 100644 --- a/release_highlights/assets/26.1/advanced/cards.json +++ b/release_highlights/assets/26.x/advanced/cards.json @@ -1,16 +1,16 @@ { - "version": "26.1", + "version": "26.x", "kind": "advanced", "title": { - "zh_Hant": "DPIP 26.1 技術變更", - "zh_Hans": "DPIP 26.1 技术变更", - "en": "DPIP 26.1 technical changes", - "ja": "DPIP 26.1 技術変更", - "ko": "DPIP 26.1 기술 변경 사항", - "th": "การเปลี่ยนแปลงทางเทคนิคใน DPIP 26.1", - "vi": "Thay đổi kỹ thuật trong DPIP 26.1", - "id": "Perubahan teknis DPIP 26.1", - "fil": "Mga teknikal na pagbabago sa DPIP 26.1" + "zh_Hant": "DPIP 26.x 技術變更", + "zh_Hans": "DPIP 26.x 技术变更", + "en": "DPIP 26.x technical changes", + "ja": "DPIP 26.x 技術変更", + "ko": "DPIP 26.x 기술 변경 사항", + "th": "การเปลี่ยนแปลงทางเทคนิคใน DPIP 26.x", + "vi": "Thay đổi kỹ thuật trong DPIP 26.x", + "id": "Perubahan teknis DPIP 26.x", + "fil": "Mga teknikal na pagbabago sa DPIP 26.x" }, "subtitle": { "zh_Hant": "以下內容已直接對照現行與 legacy 程式碼,只保留可由實作確認的差異。", diff --git a/release_highlights/assets/26.1/normal/cards.json b/release_highlights/assets/26.x/normal/cards.json similarity index 98% rename from release_highlights/assets/26.1/normal/cards.json rename to release_highlights/assets/26.x/normal/cards.json index 535f3e4ec..7b622632d 100644 --- a/release_highlights/assets/26.1/normal/cards.json +++ b/release_highlights/assets/26.x/normal/cards.json @@ -1,16 +1,16 @@ { - "version": "26.1", + "version": "26.x", "kind": "normal", "title": { - "zh_Hant": "DPIP 26.1 更新重點", - "zh_Hans": "DPIP 26.1 更新重点", - "en": "DPIP 26.1 highlights", - "ja": "DPIP 26.1 更新内容", - "ko": "DPIP 26.1 주요 변경 사항", - "th": "ไฮไลต์ของ DPIP 26.1", - "vi": "Điểm nổi bật của DPIP 26.1", - "id": "Sorotan DPIP 26.1", - "fil": "Mga highlight ng DPIP 26.1" + "zh_Hant": "DPIP 26.x 更新重點", + "zh_Hans": "DPIP 26.x 更新重点", + "en": "DPIP 26.x highlights", + "ja": "DPIP 26.x 更新内容", + "ko": "DPIP 26.x 주요 변경 사항", + "th": "ไฮไลต์ของ DPIP 26.x", + "vi": "Điểm nổi bật của DPIP 26.x", + "id": "Sorotan DPIP 26.x", + "fil": "Mga highlight ng DPIP 26.x" }, "subtitle": { "zh_Hant": "本版改善即時資料傳輸、網路與地圖快取、時間校正,以及資料儲存方式。", diff --git a/release_highlights/lib/26.1/advanced.dart b/release_highlights/lib/26.x/advanced.dart similarity index 99% rename from release_highlights/lib/26.1/advanced.dart rename to release_highlights/lib/26.x/advanced.dart index 9209f5586..416bacd69 100644 --- a/release_highlights/lib/26.1/advanced.dart +++ b/release_highlights/lib/26.x/advanced.dart @@ -1,11 +1,11 @@ -// Version-highlight card content for DPIP 26.1 (advanced). +// Version-highlight card content for DPIP 26.x (advanced). // -// GENERATED from `release_highlights/assets/26.1/advanced/cards.json` by `tool/gen/release_highlights.py` — edit the +// GENERATED from `release_highlights/assets/26.x/advanced/cards.json` by `tool/gen/release_highlights.py` — edit the // JSON, not this file. Rendering lives in `lib/features/release_highlights`; // this package carries only data. library; -const title = {"zh_Hant": "DPIP 26.1 技術變更", "zh_Hans": "DPIP 26.1 技术变更", "en": "DPIP 26.1 technical changes", "ja": "DPIP 26.1 技術変更", "ko": "DPIP 26.1 기술 변경 사항", "th": "การเปลี่ยนแปลงทางเทคนิคใน DPIP 26.1", "vi": "Thay đổi kỹ thuật trong DPIP 26.1", "id": "Perubahan teknis DPIP 26.1", "fil": "Mga teknikal na pagbabago sa DPIP 26.1"}; +const title = {"zh_Hant": "DPIP 26.x 技術變更", "zh_Hans": "DPIP 26.x 技术变更", "en": "DPIP 26.x technical changes", "ja": "DPIP 26.x 技術変更", "ko": "DPIP 26.x 기술 변경 사항", "th": "การเปลี่ยนแปลงทางเทคนิคใน DPIP 26.x", "vi": "Thay đổi kỹ thuật trong DPIP 26.x", "id": "Perubahan teknis DPIP 26.x", "fil": "Mga teknikal na pagbabago sa DPIP 26.x"}; const subtitle = {"zh_Hant": "以下內容已直接對照現行與 legacy 程式碼,只保留可由實作確認的差異。", "zh_Hans": "以下内容已直接对照现行与 legacy 代码,只保留可由实现确认的差异。", "en": "Each item was checked directly against the current and legacy code; only implementation-backed differences remain.", "ja": "現行版と旧版のコードを直接照合し、実装で確認できる差分だけを残しました。", "ko": "현재 코드와 레거시 코드를 직접 대조해 구현으로 확인되는 차이만 남겼습니다.", "th": "ตรวจสอบแต่ละรายการกับโค้ดปัจจุบันและ legacy โดยตรง และคงไว้เฉพาะความต่างที่ยืนยันได้จากการทำงานจริง", "vi": "Mỗi mục đã được đối chiếu trực tiếp với mã hiện tại và legacy; chỉ giữ lại khác biệt có thể xác nhận từ phần triển khai.", "id": "Setiap item diperiksa langsung terhadap kode saat ini dan legacy; hanya perbedaan yang didukung implementasi yang dipertahankan.", "fil": "Direktang inihambing ang bawat item sa kasalukuyan at legacy code; mga pagkakaibang napapatunayan ng implementation lang ang nanatili."}; const cards = >[ { diff --git a/release_highlights/lib/26.1/normal.dart b/release_highlights/lib/26.x/normal.dart similarity index 98% rename from release_highlights/lib/26.1/normal.dart rename to release_highlights/lib/26.x/normal.dart index 4afcef91f..888b19894 100644 --- a/release_highlights/lib/26.1/normal.dart +++ b/release_highlights/lib/26.x/normal.dart @@ -1,11 +1,11 @@ -// Version-highlight card content for DPIP 26.1 (normal). +// Version-highlight card content for DPIP 26.x (normal). // -// GENERATED from `release_highlights/assets/26.1/normal/cards.json` by `tool/gen/release_highlights.py` — edit the +// GENERATED from `release_highlights/assets/26.x/normal/cards.json` by `tool/gen/release_highlights.py` — edit the // JSON, not this file. Rendering lives in `lib/features/release_highlights`; // this package carries only data. library; -const title = {"zh_Hant": "DPIP 26.1 更新重點", "zh_Hans": "DPIP 26.1 更新重点", "en": "DPIP 26.1 highlights", "ja": "DPIP 26.1 更新内容", "ko": "DPIP 26.1 주요 변경 사항", "th": "ไฮไลต์ของ DPIP 26.1", "vi": "Điểm nổi bật của DPIP 26.1", "id": "Sorotan DPIP 26.1", "fil": "Mga highlight ng DPIP 26.1"}; +const title = {"zh_Hant": "DPIP 26.x 更新重點", "zh_Hans": "DPIP 26.x 更新重点", "en": "DPIP 26.x highlights", "ja": "DPIP 26.x 更新内容", "ko": "DPIP 26.x 주요 변경 사항", "th": "ไฮไลต์ของ DPIP 26.x", "vi": "Điểm nổi bật của DPIP 26.x", "id": "Sorotan DPIP 26.x", "fil": "Mga highlight ng DPIP 26.x"}; const subtitle = {"zh_Hant": "本版改善即時資料傳輸、網路與地圖快取、時間校正,以及資料儲存方式。", "zh_Hans": "本版改进实时数据传输、网络与地图缓存、时间校正,以及数据存储方式。", "en": "This release improves realtime delivery, network and map caching, calibrated time, and data storage.", "ja": "リアルタイム配信、ネットワークと地図のキャッシュ、時刻補正、データ保存を改善しました。", "ko": "실시간 전송, 네트워크 및 지도 캐시, 시간 보정, 데이터 저장 방식을 개선했습니다.", "th": "รุ่นนี้ปรับปรุงการส่งข้อมูลเรียลไทม์ แคชเครือข่ายและแผนที่ การเทียบเวลา และการจัดเก็บข้อมูล", "vi": "Bản này cải thiện truyền dữ liệu thời gian thực, cache mạng và bản đồ, hiệu chỉnh thời gian và lưu trữ dữ liệu.", "id": "Rilis ini meningkatkan pengiriman realtime, cache jaringan dan peta, kalibrasi waktu, serta penyimpanan data.", "fil": "Pinahusay ng release na ito ang realtime delivery, network at map cache, calibrated time, at data storage."}; const cards = >[ { diff --git a/release_highlights/pubspec.yaml b/release_highlights/pubspec.yaml index 86de43c53..e614391a8 100644 --- a/release_highlights/pubspec.yaml +++ b/release_highlights/pubspec.yaml @@ -1,8 +1,8 @@ name: dpip_release_highlights description: > Version-highlight cards for DPIP. Each version's card content lives here as - Dart source, per version and per kind (`lib/26.1/normal.dart`, - `lib/26.1/advanced.dart`). The app depends on this package via a path + Dart source, per cycle and per kind (`lib/26.x/normal.dart`, + `lib/26.x/advanced.dart`). The app depends on this package via a path dependency and imports only the *current* version — older versions stay here as the archive and are never compiled into a build. publish_to: 'none' diff --git a/test/core/version/app_build_test.dart b/test/core/version/app_build_test.dart index 5e8c544d4..c3ad72ee9 100644 --- a/test/core/version/app_build_test.dart +++ b/test/core/version/app_build_test.dart @@ -17,6 +17,23 @@ void main() { expect(AppBuild.label, isNot('26.1.0')); }); + test('the cycle names the year, never the train inside it', () { + // The highlights belong to the whole cycle, so 26.1 and 26.2 have to + // reach the same heading — otherwise the second train reads as though the + // first one's highlights were never written. + AppBuild.debugSet(label: '26w35a', code: 42, train: '26.1'); + expect(AppBuild.cycle, '26.x'); + AppBuild.debugSet(label: '26w36e', code: 43, train: '26.2'); + expect(AppBuild.cycle, '26.x'); + AppBuild.debugSet(label: '27w01a', code: 44, train: '27.1'); + expect(AppBuild.cycle, '27.x'); + // A build outside the repository has no train to shorten; a heading of + // '.x' would be worse than none. + AppBuild.debugSet(label: 'dev', code: 0, train: ''); + expect(AppBuild.cycle, isEmpty); + addTearDown(() => AppBuild.debugSet(label: 'dev', code: 0)); + }); + test('an unknown ordinal never counts as older', () { // Not knowing is not evidence of being behind, and prompting an update on // no evidence is how a user gets pushed off a build that works. diff --git a/test/features/more/more_page_test.dart b/test/features/more/more_page_test.dart index 3163f754c..1bc7e8b00 100644 --- a/test/features/more/more_page_test.dart +++ b/test/features/more/more_page_test.dart @@ -250,7 +250,7 @@ void main() { testWidgets('lists formal data sources quietly under About', (tester) async { await _pump(tester, _router([])); await tester.fling(find.byType(ListView), const Offset(0, -5000), 5000); - // The support card breathes forever; a fixed pump completes the fling. + // Enough for the fling's ballistic scroll to carry the list to the end. await tester.pump(const Duration(milliseconds: 600)); const sources = [ '探索智慧科技有限公司 — TREM-Net', @@ -355,9 +355,7 @@ void main() { final visited = []; await _pump(tester, _router(visited)); await tester.tap(find.widgetWithText(ListTile, label)); - // pumpAndSettle would time out: the support card's border breathes - // forever. A fixed pump covers the navigation transition. - await tester.pump(const Duration(milliseconds: 600)); + await tester.pumpAndSettle(); expect(visited, [route]); }); } @@ -527,9 +525,7 @@ void main() { await _pump(tester, _router(visited)); // The card is the DPIP row with the chevron — tap its label. await tester.tap(find.text('DPIP').first); - // pumpAndSettle would time out: the support card's border breathes - // forever. A fixed pump covers the navigation transition. - await tester.pump(const Duration(milliseconds: 600)); + await tester.pumpAndSettle(); expect(visited, [AppRoutes.versionNotes]); }); diff --git a/test/features/release_highlights/release_highlights_page_test.dart b/test/features/release_highlights/release_highlights_page_test.dart index 8ee32b892..d3a22f808 100644 --- a/test/features/release_highlights/release_highlights_page_test.dart +++ b/test/features/release_highlights/release_highlights_page_test.dart @@ -47,7 +47,7 @@ Future _pumpPage(WidgetTester tester) async { } void main() { - testWidgets("the app bar names the train's highlights", (tester) async { + testWidgets('the app bar names the cycle, not the train', (tester) async { AppBuild.debugSet(label: '26w35a', code: 42, train: '26.1'); addTearDown(() => AppBuild.debugSet(label: 'dev', code: 0)); await _pumpPage(tester); @@ -55,8 +55,11 @@ void main() { final l10n = AppLocalizations.of( tester.element(find.byType(ReleaseHighlightsPage)), ); - // The page's own name stays in the app bar… plus the train number. - expect(find.text(l10n.releaseHighlightsTitle('26.1')), findsOneWidget); + // The page's own name stays in the app bar… over the cycle, so every + // train in it reaches the same heading. A build riding 26.1 must not + // title the page 26.1, or the highlights read as this build's alone. + expect(find.text(l10n.releaseHighlightsTitle('26.x')), findsOneWidget); + expect(find.text(l10n.releaseHighlightsTitle('26.1')), findsNothing); }); testWidgets('renders both decks without overflow on a narrow phone', (