Skip to content
Merged
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
4 changes: 0 additions & 4 deletions lib/app/shell/main_shell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import 'package:dpip/shared/map/map_camera_handoff.dart';
import 'package:dpip/shared/map/map_trace.dart';
import 'package:dpip/shared/navigation/app_routes.dart';
import 'package:dpip/shared/navigation/refresh_on_appear.dart';
import 'package:dpip/shared/widgets/permission_banners.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
Expand Down Expand Up @@ -204,11 +203,8 @@ class _MainShellState extends State<MainShell> with RouteAware {
return Scaffold(
// Let the Home weather backdrop show through behind the bar.
extendBody: true,
// A location "fix it" banner sits above the active tab (zero height when
// location is healthy, so Home's full-bleed layout is unaffected).
body: Column(
children: [
const PermissionBanners(),
// Renders nothing; checks once after the first frame whether this
// build's channel has a newer release, and says so once per version.
const UpdatePrompt(),
Expand Down
61 changes: 39 additions & 22 deletions lib/app/theme/app_glass.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,43 +46,60 @@ const double glassRevealedAlpha = 0.20;

/// The translucent card/surface tint: resting
/// [ColorScheme.surfaceContainerHighest] → the reference's sky-tinted 20 % fill as
/// [reveal] rises.
/// [reveal] rises — **dark theme only**. Light theme skips the fade entirely
/// and stays [ColorScheme.surfaceContainerLow] at every [reveal] — the same
/// solid fill [Card] itself defaults to (see the Material 3 `_CardDefaultsM3`
/// this mirrors), so a light-theme card here reads exactly as crisp as the
/// plain [Card] the EEW alert uses, not merely "less translucent." Any partial
/// alpha over a busy backdrop, even 90-odd percent, still reads as hazy next
/// to that fully opaque comparison.
///
/// Without a [sky] colour (no backdrop running, or before the first LUT bake)
/// it falls back to a near-opaque theme surface, because 20 % of nothing is an
/// unreadable card.
Color glassSurface(
ColorScheme colors,
double reveal, {
Color? sky,
int? hour,
}) => Color.lerp(
colors.surfaceContainerHighest.withValues(alpha: 0.55),
sky == null
/// dark theme falls back to a near-opaque theme surface, because 20 % of
/// nothing is an unreadable card.
Color glassSurface(ColorScheme colors, double reveal, {Color? sky, int? hour}) {
if (colors.brightness == Brightness.light) return colors.surfaceContainerLow;
final revealed = sky == null
? colors.surface.withValues(alpha: 0.92)
: skyCardTint(sky, hour: hour ?? AppTime.utc8.hour),
reveal,
)!;
: skyCardTint(sky, hour: hour ?? AppTime.utc8.hour);
return Color.lerp(
colors.surfaceContainerHighest.withValues(alpha: 0.55),
revealed,
reveal,
)!;
}

/// Ink for content **inside** a [glassSurface] card.
///
/// At rest the card is its own plate and the theme's on-surface roles are
/// right. Once [reveal] dissolves it to the 20 % fill above, the card is no
/// longer a plate — it is a pane of the sky — so its ink has to follow the sky
/// exactly the way [inkOverWeather] does. Leaving it on [ColorScheme.onSurface]
/// puts near-black text on a rain sky in a light theme.
/// Dark theme: at rest the card is its own plate and the theme's on-surface
/// roles are right. Once [reveal] dissolves it to the 20 % fill above, the
/// card is no longer a plate — it is a pane of the sky — so its ink has to
/// follow the sky exactly the way [inkOverWeather] does. Leaving it on
/// [ColorScheme.onSurface] puts near-black text on a rain sky.
///
/// Light theme: [glassSurface] stays an opaque plate at every [reveal], so
/// this stays [ColorScheme.onSurface] unconditionally — shifting it toward
/// white for a dark [skyIsLight] would put pale text on that still-opaque,
/// still-light plate.
Color glassOnSurface(
ColorScheme colors, {
double reveal = 0,
bool skyIsLight = false,
}) => inkOverWeather(colors, reveal, skyIsLight: skyIsLight);
}) {
if (colors.brightness == Brightness.light) return colors.onSurface;
return inkOverWeather(colors, reveal, skyIsLight: skyIsLight);
}

/// Secondary ink for content inside a [glassSurface] card.
/// Secondary ink for content inside a [glassSurface] card. See [glassOnSurface]
/// for why light theme skips the sky-following shift.
Color glassOnSurfaceVariant(
ColorScheme colors, {
double reveal = 0,
bool skyIsLight = false,
}) => inkOverWeatherVariant(colors, reveal, skyIsLight: skyIsLight);
}) {
if (colors.brightness == Brightness.light) return colors.onSurfaceVariant;
return inkOverWeatherVariant(colors, reveal, skyIsLight: skyIsLight);
}

/// Whether [mode]'s sky is light enough that dark foregrounds read better than
/// white once the weather backdrop is showing.
Expand Down
16 changes: 16 additions & 0 deletions lib/core/settings/locale_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:dpip/l10n/gen/app_localizations.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart';

/// The app's home locale: Traditional Chinese for Taiwan (`zh_TW`).
///
Expand Down Expand Up @@ -98,6 +99,21 @@ bool _isBareChinese(Locale locale) =>
locale.scriptCode == null &&
locale.countryCode == null;

/// A `DateFormat`-safe locale tag for [locale].
///
/// `intl` ships its own locale-data set, entirely separate from
/// `flutter_localizations` — it does not know every locale this app
/// supports either (`yue` included, the same gap the widgets-fallback
/// delegates above patch for Material/Cupertino). Asking `DateFormat` to
/// format with a tag it has no data for throws ("Invalid locale") rather than
/// falling back, so every `DateFormat` call driven by the app's locale must
/// go through this first. Falls back to [kHomeLocale], the same replacement
/// the Material/Cupertino delegates use.
String intlDateLocale(Locale locale) {
final tag = locale.toString();
return DateFormat.localeExists(tag) ? tag : kHomeLocale.toString();
}

/// Picks the best supported locale for [deviceLocales].
///
/// Chinese needs an explicit script/region split: bare `zh` and `zh_Hant` map
Expand Down
6 changes: 4 additions & 2 deletions lib/features/changelog/presentation/pages/changelog_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:dpip/app/theme/app_motion.dart';
import 'package:dpip/app/theme/app_radius.dart';
import 'package:dpip/app/theme/app_spacing.dart';
import 'package:dpip/core/logging/log.dart';
import 'package:dpip/core/settings/locale_config.dart';
import 'package:dpip/core/version/app_build.dart';
import 'package:dpip/core/error/result.dart';
import 'package:dpip/features/changelog/domain/changelog_repository.dart';
Expand Down Expand Up @@ -276,8 +277,9 @@ class _ReleaseTile extends StatelessWidget {
? Icons.science_outlined
: Icons.verified_outlined;
final title = note.name.isEmpty ? note.tagName : note.name;
final date = DateFormat.yMMMd(Localizations.localeOf(context).toString())
.format(note.publishedAt.toLocal());
final date = DateFormat.yMMMd(
intlDateLocale(Localizations.localeOf(context)),
).format(note.publishedAt.toLocal());
final emphasized = isCurrent || expanded;

return CustomPaint(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ library;
import 'package:dpip/app/theme/app_radius.dart';
import 'package:dpip/app/theme/app_spacing.dart';
import 'package:dpip/core/logging/log.dart';
import 'package:dpip/core/settings/locale_config.dart';
import 'package:dpip/core/version/app_build.dart';
import 'package:dpip/features/changelog/domain/changelog_repository.dart';
import 'package:dpip/features/changelog/domain/release_note.dart';
Expand Down Expand Up @@ -137,8 +138,9 @@ class _Header extends StatelessWidget {
final colors = theme.colorScheme;
final typeColor = isStable ? _stableColor : _snapshotColor;
final title = note.name.isEmpty ? note.tagName : note.name;
final date = DateFormat.yMMMd(Localizations.localeOf(context).toString())
.format(note.publishedAt.toLocal());
final date = DateFormat.yMMMd(
intlDateLocale(Localizations.localeOf(context)),
).format(note.publishedAt.toLocal());
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ library;
import 'package:dpip/app/theme/app_radius.dart';
import 'package:dpip/app/theme/app_spacing.dart';
import 'package:dpip/core/realtime/app_time.dart';
import 'package:dpip/core/settings/locale_config.dart';
import 'package:dpip/shared/seismic/intensity.dart';
import 'package:dpip/features/earthquake/domain/partial_earthquake_report.dart';
import 'package:dpip/features/earthquake/domain/report_repository.dart';
Expand Down Expand Up @@ -238,7 +239,7 @@ class _DaySection extends StatelessWidget {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final locale = intlDateLocale(Localizations.localeOf(context));

return Column(
crossAxisAlignment: CrossAxisAlignment.start,
Expand Down
100 changes: 74 additions & 26 deletions lib/features/home/presentation/pages/home_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -302,31 +302,75 @@ class _HomePageState extends State<HomePage> {
blend: HomeChrome.regionBlend(sheetExtent.value),
dismiss: HomeChrome.regionDismiss(sheetExtent.value),
),
builder: (context, dials, _) => Column(
mainAxisSize: MainAxisSize.min,
children: [
RegionBar(
blend: dials.blend,
dismiss: dials.dismiss,
skyIsLight: skyIsLightFrom(sky, weatherMode),
),
// Quick link to 強震監視器 — same dials as the
// region bar above it, so the two move as one
// piece of chrome as the sheet rises.
//
// Above the support pill on purpose: this one
// only renders while an alert is active, and an
// alert must not be pushed down the screen by a
// donation prompt. When nothing is happening it
// draws nothing, so the pill sits directly under
// the bar anyway.
HomeMonitorBanner(dismiss: dials.dismiss),
_GoldSupportBar(
blend: dials.blend,
dismiss: dials.dismiss,
),
],
),
builder: (context, dials, _) {
// Quick link to 強震監視器. The gold support bar's
// top edge always sits flush under the region bar
// — its position never moves. When an alert is
// live, its card is anchored to that same top
// edge and, being the taller of the two, sits
// directly over it: real Z-axis overlap, with the
// card's own elevation shadow falling across the
// bar underneath, not just first-in-a-list order.
// `isActive` is the same check
// HomeMonitorBanner.build gates its own render on,
// shared so this layout math can't drift from it.
final hasActiveEew = HomeMonitorBanner.isActive(
context,
);
final stackHeight = hasActiveEew
? HomeMonitorBanner.height
: _GoldSupportBar.height;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
RegionBar(
blend: dials.blend,
dismiss: dials.dismiss,
skyIsLight: skyIsLightFrom(sky, weatherMode),
),
SizedBox(
height: stackHeight,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
top: 0,
left: 0,
right: 0,
child: _GoldSupportBar(
blend: dials.blend,
dismiss: dials.dismiss,
),
),
// Always mounted — HomeMonitorBanner
// renders SizedBox.shrink() internally
// while calm. Wrapping this in `if
// (hasActiveEew)` instead (removing the
// Positioned from the Stack's children
// entirely on every toggle) is what
// triggered a real Flutter engine crash
// here: a semantics assertion
// (`!child.attached`) when the Material
// + InkWell subtree underneath is
// repeatedly unmounted and remounted at
// this position. Letting the banner's
// own build() switch its child instead
// keeps the element permanently in the
// tree, which does not hit that bug.
Positioned(
top: 0,
left: 0,
right: 0,
child: HomeMonitorBanner(
dismiss: dials.dismiss,
),
),
],
),
),
],
);
},
),
),
),
Expand All @@ -351,6 +395,10 @@ class _GoldSupportBar extends StatelessWidget {
final double blend;
final double dismiss;

/// This bar's own rendered height — used to size the overlap stack that
/// lets [HomeMonitorBanner] float over its top edge.
static const double height = 30;

@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
Expand All @@ -369,7 +417,7 @@ class _GoldSupportBar extends StatelessWidget {
child: InkWell(
onTap: () => context.pushNamed(AppRoutes.sponsor),
child: SizedBox(
height: 30,
height: height,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
Expand Down
Loading
Loading