Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions lib/core/version/app_build.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,61 @@ class BugAvatarImage extends ImageProvider<BugAvatarImage> {
BugAvatarImage key,
ImageDecoderCallback decode,
) {
return MultiFrameImageStreamCompleter(codec: _codec(key), scale: 1);
return MultiFrameImageStreamCompleter(codec: _codec(key, decode), scale: 1);
}

Future<ui.Codec> _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<ui.Codec> _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
Expand Down
27 changes: 20 additions & 7 deletions lib/features/changelog/presentation/pages/changelog_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -236,11 +236,15 @@ class _ChangelogPageState extends State<ChangelogPage> {
});
}

/// 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;
}
}
Expand All @@ -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<String, DateFormat> _dateFormats = {};

@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 12 additions & 8 deletions lib/features/data/presentation/pages/planets_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
101 changes: 66 additions & 35 deletions lib/features/events/presentation/widgets/event_timeline.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down
Loading
Loading