From d4822f285b174cce2fbe856f3a2253e8f59e78ee Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Mon, 25 May 2026 15:48:01 +0800 Subject: [PATCH 1/3] fix: package --- mise.toml | 2 ++ pubspec.lock | 11 ++++++----- pubspec.yaml | 5 ++++- 3 files changed, 12 insertions(+), 6 deletions(-) create mode 100644 mise.toml diff --git a/mise.toml b/mise.toml new file mode 100644 index 000000000..446a21ffe --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +flutter = "3.44.0-stable" diff --git a/pubspec.lock b/pubspec.lock index 30293affd..6cbbe6689 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1362,11 +1362,12 @@ packages: simple_icons: dependency: "direct main" description: - name: simple_icons - sha256: "2ca3cd79c9f12e97a8588cae0f342609f19fd2e82315356cb09b5c4987ad0808" - url: "https://pub.dev" - source: hosted - version: "14.6.1" + path: "." + ref: develop + resolved-ref: fa3798db2995e5c7a0464610ad2610d4c0142cc3 + url: "https://github.com/jlnrrg/simple_icons.git" + source: git + version: "16.20.0" skeletonizer: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index c75f1550c..a681b1463 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,7 +65,10 @@ dependencies: permission_handler: ^12.0.1 provider: ^6.1.5 shared_preferences: ^2.5.4 - simple_icons: ^14.6.1 + simple_icons: + git: + url: https://github.com/jlnrrg/simple_icons.git + ref: develop skeletonizer: ^2.1.2 styled_text: ^9.0.0 talker_flutter: ^5.1.9 From f48c1b4d1b3f44f49be95d02b72e7fc3c45890a4 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Tue, 26 May 2026 13:29:54 +0800 Subject: [PATCH 2/3] fix: all new page card ui --- lib/app/new_home/_models/home_model.dart | 74 +++--- .../_widgets/all_observation_average.dart | 91 ------- lib/app/new_home/_widgets/assistant_hint.dart | 51 ++-- lib/app/new_home/_widgets/day_cycle.dart | 61 ++--- lib/app/new_home/_widgets/forecast.dart | 169 +++++++++++++ lib/app/new_home/_widgets/greeting.dart | 16 +- lib/app/new_home/_widgets/radar.dart | 32 ++- lib/app/new_home/_widgets/station_info.dart | 208 ++++++++++++++++ lib/app/new_home/_widgets/temperature.dart | 6 +- lib/app/new_home/_widgets/weather.dart | 11 +- .../new_home/_widgets/weather_parameters.dart | 125 ---------- lib/app/new_home/_widgets/wind.dart | 24 ++ lib/app/new_home/layout.dart | 227 ++++++------------ lib/app/new_home/page.dart | 109 +++------ pubspec.lock | 4 +- 15 files changed, 639 insertions(+), 569 deletions(-) delete mode 100644 lib/app/new_home/_widgets/all_observation_average.dart create mode 100644 lib/app/new_home/_widgets/forecast.dart create mode 100644 lib/app/new_home/_widgets/station_info.dart delete mode 100644 lib/app/new_home/_widgets/weather_parameters.dart create mode 100644 lib/app/new_home/_widgets/wind.dart diff --git a/lib/app/new_home/_models/home_model.dart b/lib/app/new_home/_models/home_model.dart index abd9de420..0460b92b1 100644 --- a/lib/app/new_home/_models/home_model.dart +++ b/lib/app/new_home/_models/home_model.dart @@ -3,12 +3,9 @@ library; import 'dart:async'; -import 'package:collection/collection.dart'; -import 'package:dpip/api/model/history/history.dart'; import 'package:dpip/api/model/weather_schema.dart'; import 'package:dpip/global.dart'; import 'package:dpip/models/settings/location.dart'; -import 'package:dpip/utils/log.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -26,8 +23,9 @@ class HomeModel extends ChangeNotifier { final SettingsLocationModel _settingsLocation; String? _temporaryCode; RealtimeWeather? _weather; - List _alerts = const []; Map? _forecast; + bool _isLoading = false; + Object? _error; Timer? _autoRefreshTimer; /// Creates a [HomeModel] backed by [settingsLocation]. @@ -42,50 +40,54 @@ class HomeModel extends ChangeNotifier { if (_temporaryCode == null) _doRefresh(); } - /// Runs [task] and logs failures under [tag]; returns `null` on error. - static Future _safe(String tag, Future Function() task) async { - try { - return await task(); - } catch (e) { - TalkerManager.instance.error('HomeModel $tag', e); - return null; - } - } - Future _doRefresh() async { final code = _temporaryCode ?? _settingsLocation.code; - final loc = code != null ? Global.location[code] : null; - final lat = loc?.lat ?? _settingsLocation.coordinates?.latitude; - final lon = loc?.lng ?? _settingsLocation.coordinates?.longitude; + double? lat; + double? lon; + + if (code != null) { + final loc = Global.location[code]; + if (loc != null) { + lat = loc.lat; + lon = loc.lng; + } + } + + lat ??= _settingsLocation.coordinates?.latitude; + lon ??= _settingsLocation.coordinates?.longitude; + if (lat == null || lon == null) return; - // Fetch weather + alerts + forecast in parallel. - final results = await Future.wait([ - _safe('weather', () => Global.api.getWeatherRealtimeByCoords(lat, lon)), - if (code != null) _safe('alerts', () => Global.api.getRealtimeRegion(code)) else Future.value(null), - if (code != null) _safe('forecast', () => Global.api.getWeatherForecast(code)) else Future.value(null), - ]); - - if (results[0] is RealtimeWeather) _weather = results[0] as RealtimeWeather; - _alerts = results[1] is List - ? (results[1]! as List).sorted((a, b) => b.time.send.compareTo(a.time.send)) - : const []; - if (results[2] is Map) _forecast = results[2] as Map; + _isLoading = true; notifyListeners(); + + try { + final results = await Future.wait([ + Global.api.getWeatherRealtimeByCoords(lat, lon), + if (code != null) Global.api.getWeatherForecast(code), + ]); + _weather = results[0] as RealtimeWeather; + _forecast = code != null ? results[1] as Map : null; + _error = null; + } catch (e) { + _error = e; + } finally { + _isLoading = false; + notifyListeners(); + } } /// The most recently fetched weather data, or `null` if not yet loaded. RealtimeWeather? get weather => _weather; - /// The most recent realtime alerts for the active location, sorted newest first. - List get alerts => _alerts; + /// The most recently fetched forecast data, or `null` if not yet loaded. + Map? get forecast => _forecast; - /// The most recent thunderstorm alert, or `null` when none active. - History? get thunderstorm => - _alerts.firstWhereOrNull((e) => e.type == HistoryType.thunderstorm); + /// Whether a weather fetch is currently in progress. + bool get isLoading => _isLoading; - /// The 24-hour weather forecast for the active location, or `null` if missing. - Map? get forecast => _forecast; + /// The error from the last failed fetch, or `null` when the last fetch succeeded. + Object? get error => _error; /// The currently active temporary location code, or `null` when unset. String? get temporaryCode => _temporaryCode; diff --git a/lib/app/new_home/_widgets/all_observation_average.dart b/lib/app/new_home/_widgets/all_observation_average.dart deleted file mode 100644 index 5af244c00..000000000 --- a/lib/app/new_home/_widgets/all_observation_average.dart +++ /dev/null @@ -1,91 +0,0 @@ -/// A card widget showing the nearest-station temperature and humidity summary. -library; - -import 'package:dpip/app/new_home/_models/home_model.dart'; -import 'package:dpip/utils/extensions/build_context.dart'; -import 'package:dpip/widgets/typography.dart'; -import 'package:flutter/material.dart'; -import 'package:material_symbols_icons/material_symbols_icons.dart'; -import 'package:provider/provider.dart'; - -/// Displays temperature and humidity from the nearest CWA weather station. -/// -/// The left column shows TREM network data (currently unavailable in this -/// context, displayed as "--"). The right column shows CWA nearest-station -/// readings from [HomeModel]. Rebuilds only when temperature or humidity change. -class AllObservationAverage extends StatelessWidget { - /// Creates an [AllObservationAverage] widget. - const AllObservationAverage({super.key}); - - @override - Widget build(BuildContext context) { - return Selector( - selector: (_, m) { - final d = m.weather?.data; - return d != null ? (d.temperature, d.humidity) : null; - }, - builder: (context, data, _) { - const tremLabel = '--° / --%'; - final cwaLabel = data != null - ? '${data.$1.toStringAsFixed(1)}° / ${data.$2.round()}%' - : '--° / --%'; - - return Padding( - padding: const .symmetric(horizontal: 12, vertical: 8), - child: Column( - crossAxisAlignment: .start, - spacing: 4, - children: [ - Padding( - padding: const .symmetric(horizontal: 8), - child: LabelText.large( - '所有測站平均', - color: Colors.white, - shadows: kElevationToShadow[1], - ), - ), - Card( - child: Padding( - padding: const .all(12), - child: IntrinsicHeight( - child: Row( - children: [ - Expanded( - child: Row( - spacing: 8, - children: [ - Icon( - Symbols.cell_tower_rounded, - fill: 1, - color: context.colors.onSurfaceVariant, - ), - Text(tremLabel, style: const TextStyle(fontSize: 16)), - ], - ), - ), - const VerticalDivider(width: 24), - Expanded( - child: Row( - spacing: 8, - children: [ - Icon( - Symbols.globe_asia_rounded, - fill: 1, - color: context.colors.onSurfaceVariant, - ), - Text(cwaLabel, style: const TextStyle(fontSize: 16)), - ], - ), - ), - ], - ), - ), - ), - ), - ], - ), - ); - }, - ); - } -} diff --git a/lib/app/new_home/_widgets/assistant_hint.dart b/lib/app/new_home/_widgets/assistant_hint.dart index cc99c29fc..2c3f06d1f 100644 --- a/lib/app/new_home/_widgets/assistant_hint.dart +++ b/lib/app/new_home/_widgets/assistant_hint.dart @@ -27,30 +27,35 @@ class AssistantHint extends StatelessWidget { builder: (context, data, _) { final text = data != null ? _buildHintText(data.$1, data.$2) : '載入天氣資料中…'; - return Padding( - padding: const .symmetric(horizontal: 12, vertical: 8), - child: Card( - child: Padding( - padding: const .all(12), - child: Row( - spacing: 8, - crossAxisAlignment: .start, - children: [ - Icon( - Symbols.auto_awesome_rounded, - fill: 1, - color: context.colors.onSurfaceVariant, - ), - Expanded( - child: Text( - text, - style: TextStyle(fontSize: 16, color: context.colors.onSurfaceVariant), - maxLines: 2, - overflow: .ellipsis, - ), + return Container( + margin: const .symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: context.colors.surfaceContainerLow, + borderRadius: .circular(16), + border: Border.all( + color: context.colors.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: Padding( + padding: const .all(12), + child: Row( + spacing: 8, + crossAxisAlignment: .start, + children: [ + Icon( + Symbols.auto_awesome_rounded, + fill: 1, + color: context.colors.onSurfaceVariant, + ), + Expanded( + child: Text( + text, + style: TextStyle(fontSize: 16, color: context.colors.onSurfaceVariant), + maxLines: 2, + overflow: .ellipsis, ), - ], - ), + ), + ], ), ), ); diff --git a/lib/app/new_home/_widgets/day_cycle.dart b/lib/app/new_home/_widgets/day_cycle.dart index 4e31be675..adb698e23 100644 --- a/lib/app/new_home/_widgets/day_cycle.dart +++ b/lib/app/new_home/_widgets/day_cycle.dart @@ -18,34 +18,39 @@ class DayCycle extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const .symmetric(horizontal: 12, vertical: 4), - child: Card( - child: Padding( - padding: const .all(16), - child: Column( - crossAxisAlignment: .start, - spacing: 8, - children: [ - Row( - spacing: 4, - children: [ - const Icon( - Symbols.wb_twilight_rounded, - fill: 1, - color: Colors.orangeAccent, - ), - BodyText.large('日出日落', weight: .bold), - ], - ), - const SizedBox(height: 16), - _SunCycleGraph( - sunrise: const TimeOfDay(hour: 5, minute: 30), - sunset: const TimeOfDay(hour: 18, minute: 30), - now: TimeOfDay.now(), - ), - ], - ), + return Container( + margin: const .symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: context.colors.surfaceContainerLow, + borderRadius: .circular(16), + border: Border.all( + color: context.colors.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: Padding( + padding: const .all(16), + child: Column( + crossAxisAlignment: .start, + spacing: 8, + children: [ + Row( + spacing: 4, + children: [ + const Icon( + Symbols.wb_twilight_rounded, + fill: 1, + color: Colors.orangeAccent, + ), + BodyText.large('日出日落', weight: .bold), + ], + ), + const SizedBox(height: 16), + _SunCycleGraph( + sunrise: const TimeOfDay(hour: 5, minute: 30), + sunset: const TimeOfDay(hour: 18, minute: 30), + now: TimeOfDay.now(), + ), + ], ), ), ); diff --git a/lib/app/new_home/_widgets/forecast.dart b/lib/app/new_home/_widgets/forecast.dart new file mode 100644 index 000000000..71b7cd43e --- /dev/null +++ b/lib/app/new_home/_widgets/forecast.dart @@ -0,0 +1,169 @@ +/// 24 小時預報。 +library; + +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/core/i18n.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/utils/log.dart'; +import 'package:dpip/widgets/responsive/responsive_container.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:provider/provider.dart'; + +const double _kColumnWidth = 56.0; + +class Forecast extends StatelessWidget { + const Forecast({super.key}); + + @override + Widget build(BuildContext context) { + return Selector?>( + selector: (_, m) => m.forecast, + builder: (context, forecast, _) { + if (forecast == null) return const SizedBox.shrink(); + try { + final data = forecast['forecast'] as List?; + if (data == null || data.isEmpty) return const SizedBox.shrink(); + + return ResponsiveContainer( + child: Container( + margin: const .symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: context.colors.surfaceContainerLow, + borderRadius: .circular(16), + border: Border.all( + color: context.colors.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: Padding( + padding: const .symmetric(vertical: 14), + child: Column( + crossAxisAlignment: .start, + children: [ + Padding( + padding: const .symmetric(horizontal: 14), + child: Text( + '24 小時預報'.i18n, + style: context.texts.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: .w600, + letterSpacing: 0.5, + ), + ), + ), + const SizedBox(height: 12), + SingleChildScrollView( + scrollDirection: .horizontal, + padding: const .symmetric(horizontal: 6), + child: Row( + crossAxisAlignment: .start, + children: [ + for (var i = 0; i < data.length; i++) + _HourColumn( + item: data[i] as Map, + isNow: i == 0, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } catch (e, s) { + TalkerManager.instance.error('Failed to render forecast card', e, s); + } + return const SizedBox.shrink(); + }, + ); + } +} + +class _HourColumn extends StatelessWidget { + final Map item; + final bool isNow; + + const _HourColumn({required this.item, required this.isNow}); + + static (IconData, Color?) _weatherIcon(BuildContext context, String weather) => switch (weather) { + final s when s.contains('晴') => (Symbols.sunny_rounded, Colors.orangeAccent), + final s when s.contains('雨') => (Symbols.rainy_light_rounded, Colors.blueAccent), + final s when s.contains('雲') || s.contains('陰') => ( + Symbols.cloud_rounded, + context.colors.onSurfaceVariant, + ), + final s when s.contains('雷') => (Symbols.flash_on_rounded, Colors.amber), + final s when s.contains('雪') => (Symbols.snowflake_rounded, Colors.lightBlue[200]), + _ => (Symbols.wb_cloudy_rounded, context.colors.onSurfaceVariant), + }; + + @override + Widget build(BuildContext context) { + final time = item['time'] as String? ?? ''; + final weather = item['weather'] as String? ?? ''; + final temp = (item['temperature'] as num?)?.toDouble() ?? 0.0; + final pop = item['pop'] as int? ?? 0; + + final (icon, color) = _weatherIcon(context, weather); + + final primaryColor = isNow + ? context.colors.onSurface + : context.colors.onSurface.withValues(alpha: 0.82); + final labelColor = isNow + ? context.colors.onSurface + : context.colors.onSurfaceVariant.withValues(alpha: 0.75); + + return SizedBox( + width: _kColumnWidth, + child: Column( + mainAxisSize: .min, + children: [ + Text( + isNow ? '現在'.i18n : time, + style: context.texts.labelSmall?.copyWith( + color: labelColor, + fontWeight: isNow ? .w700 : .w500, + height: 1, + ), + ), + const SizedBox(height: 10), + Icon(icon, color: color, fill: 1, size: 24), + if (pop > 0) ...[ + const SizedBox(height: 6), + Row( + mainAxisSize: .min, + mainAxisAlignment: .center, + children: [ + Icon( + Symbols.water_drop_rounded, + size: 10, + color: Colors.blueAccent.withValues(alpha: 0.85), + ), + const SizedBox(width: 2), + Text( + '$pop%', + style: TextStyle( + fontSize: 10, + color: Colors.blueAccent.withValues(alpha: 0.85), + fontWeight: .w600, + height: 1, + ), + ), + ], + ), + ], + const SizedBox(height: 10), + Text( + '${temp.round()}°', + style: context.texts.titleMedium?.copyWith( + fontWeight: isNow ? .w700 : .w600, + color: primaryColor, + height: 1, + ), + ), + ], + ), + ); + } +} diff --git a/lib/app/new_home/_widgets/greeting.dart b/lib/app/new_home/_widgets/greeting.dart index 7b6b26a9c..41a34544f 100644 --- a/lib/app/new_home/_widgets/greeting.dart +++ b/lib/app/new_home/_widgets/greeting.dart @@ -1,8 +1,8 @@ /// A greeting widget that displays a time-aware salutation. library; -import 'package:dpip/app/new_home/_models/weather_params.dart'; import 'package:dpip/core/i18n.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; import 'package:dpip/widgets/typography.dart'; import 'package:flutter/material.dart'; @@ -13,12 +13,20 @@ class Greeting extends StatelessWidget { @override Widget build(BuildContext context) { + final hour = DateTime.now().hour; + + final greeting = switch (hour) { + < 6 => '夜深了', + < 12 => '早安', + < 18 => '午安', + _ => '晚安', + }; + return Padding( padding: const .all(16), child: TitleText.large( - greetingForHour(DateTime.now().hour).i18n, - color: Colors.white, - shadows: kElevationToShadow[2], + greeting.i18n, + color: context.colors.onSurface, ), ); } diff --git a/lib/app/new_home/_widgets/radar.dart b/lib/app/new_home/_widgets/radar.dart index e97968320..298119c1e 100644 --- a/lib/app/new_home/_widgets/radar.dart +++ b/lib/app/new_home/_widgets/radar.dart @@ -34,7 +34,7 @@ class Radar extends StatefulWidget { class _RadarState extends State with WidgetsBindingObserver, RouteAware { MapLibreMapController? _mapController; - bool _homeListenerAdded = false; + HomeModel? _homeModel; /// Resolves to the list of available radar timestamps once fetched. late Future> _radarListFuture; @@ -109,9 +109,11 @@ class _RadarState extends State with WidgetsBindingObserver, RouteAware { final route = ModalRoute.of(context); if (route != null) routeObserver.subscribe(this, route); - if (!_homeListenerAdded) { - context.home.addListener(_onHomeModelChanged); - _homeListenerAdded = true; + final model = context.home; + if (_homeModel != model) { + _homeModel?.removeListener(_onHomeModelChanged); + _homeModel = model; + model.addListener(_onHomeModelChanged); } } @@ -121,14 +123,22 @@ class _RadarState extends State with WidgetsBindingObserver, RouteAware { final targetLocation = userLocation ?? DpipMap.kTaiwanCenter; final targetZoom = userLocation != null ? DpipMap.kUserLocationZoom : DpipMap.kTaiwanZoom; - return Padding( - padding: .symmetric(horizontal: 12, vertical: 4), - child: Card( - clipBehavior: .antiAlias, + return Container( + margin: const .symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: context.colors.surfaceContainerLow, + borderRadius: .circular(16), + border: Border.all( + color: context.colors.outlineVariant.withValues(alpha: 0.5), + ), + ), + clipBehavior: .antiAlias, + child: Material( + color: Colors.transparent, child: InkWell( - onTap: () => MapRoute(layers: 'radar').push(context), + onTap: () => const MapRoute(layers: 'radar').push(context), child: Padding( - padding: .all(12), + padding: const .all(12), child: Column( crossAxisAlignment: .start, spacing: 12, @@ -209,7 +219,7 @@ class _RadarState extends State with WidgetsBindingObserver, RouteAware { @override void dispose() { - context.home.removeListener(_onHomeModelChanged); + _homeModel?.removeListener(_onHomeModelChanged); routeObserver.unsubscribe(this); WidgetsBinding.instance.removeObserver(this); super.dispose(); diff --git a/lib/app/new_home/_widgets/station_info.dart b/lib/app/new_home/_widgets/station_info.dart new file mode 100644 index 000000000..850d818c2 --- /dev/null +++ b/lib/app/new_home/_widgets/station_info.dart @@ -0,0 +1,208 @@ +/// 首頁站點觀測卡片。 +library; + +import 'package:dpip/api/model/weather_schema.dart'; +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/core/i18n.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/widgets/responsive/responsive_container.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +class StationInfo extends StatelessWidget { + /// 繼承 [HomeModel] + const StationInfo({super.key}); + + @override + Widget build(BuildContext context) { + return Selector( + selector: (_, m) => m.weather, + builder: (context, w, _) { + final hasData = w != null; + + final pairs = <_PairData>[ + _PairData( + icon: Symbols.water_drop_rounded, + color: Colors.cyan, + label: '濕度'.i18n, + value: hasData && w.data.humidity >= 0 ? '${w.data.humidity.round()}%' : null, + ), + _PairData( + icon: Symbols.compress_rounded, + color: Colors.purple, + label: '氣壓'.i18n, + value: hasData && w.data.pressure >= 0 ? '${w.data.pressure.round()} hPa' : null, + ), + _PairData( + icon: Symbols.rainy_rounded, + color: Colors.blue, + label: '降雨'.i18n, + value: hasData && w.data.rain >= 0 ? '${w.data.rain.toStringAsFixed(1)} mm' : null, + ), + _PairData( + icon: Symbols.visibility_rounded, + color: Colors.amber, + label: '能見度'.i18n, + value: hasData && w.data.visibility >= 0 ? '${w.data.visibility.round()} km' : null, + ), + _PairData( + icon: Symbols.wind_power_rounded, + color: Colors.teal, + label: '風速'.i18n, + value: hasData && w.data.wind.speed >= 0 + ? '${w.data.wind.speed.toStringAsFixed(1)} m/s' + : null, + ), + _PairData( + icon: Symbols.air_rounded, + color: Colors.orange, + label: '陣風'.i18n, + value: hasData && w.data.gust.speed >= 0 + ? '${w.data.gust.speed.toStringAsFixed(1)} m/s' + : null, + ), + ]; + + final divider = Divider( + height: 1, + thickness: 1, + color: context.colors.outlineVariant.withValues(alpha: 0.3), + ); + + return ResponsiveContainer( + child: Container( + margin: const .symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: context.colors.surfaceContainerLow, + borderRadius: .circular(16), + border: Border.all( + color: context.colors.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: Padding( + padding: const .fromLTRB(12, 12, 12, 4), + child: Column( + crossAxisAlignment: .start, + children: [ + _buildStationHeader(context, w, hasData), + const SizedBox(height: 8), + _buildPairRow(pairs[0], pairs[1]), + divider, + _buildPairRow(pairs[2], pairs[3]), + divider, + _buildPairRow(pairs[4], pairs[5]), + ], + ), + ), + ), + ); + }, + ); + } + + Widget _buildStationHeader(BuildContext context, RealtimeWeather? w, bool hasData) { + String timeStr = '--:--'; + if (hasData) { + final dt = DateTime.fromMillisecondsSinceEpoch(w!.time); + final hour = dt.hour; + final hour12 = hour == 0 ? 12 : (hour > 12 ? hour - 12 : hour); + final period = hour < 12 ? '上午'.i18n : '下午'.i18n; + timeStr = + '$period ${hour12.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } + + String stationLabel = '--'; + if (hasData) { + stationLabel = w!.station.name; + if (w.station.distance >= 0) { + stationLabel += '・${w.station.distance.toStringAsFixed(1)}km'; + } + } + + return Row( + children: [ + Icon(Symbols.pin_drop_rounded, size: 16, color: context.colors.primary), + const SizedBox(width: 4), + Expanded( + child: Text( + stationLabel, + style: context.texts.labelMedium?.copyWith( + fontWeight: .w600, + color: context.colors.onSurface, + ), + overflow: .ellipsis, + ), + ), + Text( + timeStr, + style: context.texts.labelMedium?.copyWith( + fontWeight: .w600, + color: context.colors.onSurfaceVariant, + ), + ), + ], + ); + } + + Widget _buildPairRow(_PairData left, _PairData right) { + return Row( + children: [ + Expanded(child: _MetricPair(data: left)), + const SizedBox(width: 12), + Expanded(child: _MetricPair(data: right)), + ], + ); + } +} + +class _PairData { + final IconData icon; + final Color color; + final String label; + final String? value; + + const _PairData({ + required this.icon, + required this.color, + required this.label, + required this.value, + }); +} + +class _MetricPair extends StatelessWidget { + final _PairData data; + + const _MetricPair({required this.data}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const .symmetric(vertical: 10), + child: Row( + children: [ + Icon(data.icon, size: 18, color: data.color), + const SizedBox(width: 8), + Text( + data.label, + style: context.texts.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: .w500, + ), + ), + const Spacer(), + Text( + data.value ?? '—', + style: context.texts.titleMedium?.copyWith( + color: data.value != null + ? context.colors.onSurface + : context.colors.onSurfaceVariant.withValues(alpha: 0.4), + fontWeight: .w700, + height: 1, + ), + ), + ], + ), + ); + } +} diff --git a/lib/app/new_home/_widgets/temperature.dart b/lib/app/new_home/_widgets/temperature.dart index c95ee45a4..4394a41ce 100644 --- a/lib/app/new_home/_widgets/temperature.dart +++ b/lib/app/new_home/_widgets/temperature.dart @@ -29,20 +29,18 @@ class Temperature extends StatelessWidget { children: [ DisplayText.large( tempStr, - color: context.colors.secondaryFixed, + color: context.colors.onSurface, fontFamily: 'Google Sans Flex', fontSize: 96, fontVariations: [const .new('ROND', 100)], - shadows: kElevationToShadow[4], ), DisplayText.large( '°', - color: context.colors.secondaryFixed, + color: context.colors.onSurface, fontFamily: 'Google Sans Flex', weight: .w300, fontSize: 96, fontVariations: [const .new('ROND', 100)], - shadows: kElevationToShadow[4], ), ], ), diff --git a/lib/app/new_home/_widgets/weather.dart b/lib/app/new_home/_widgets/weather.dart index 7d23d0a26..a6a9f6bec 100644 --- a/lib/app/new_home/_widgets/weather.dart +++ b/lib/app/new_home/_widgets/weather.dart @@ -2,6 +2,7 @@ library; import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; import 'package:dpip/widgets/typography.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; @@ -32,17 +33,11 @@ class Weather extends StatelessWidget { child: Row( spacing: 8, children: [ - Icon( - icon, - fill: 1, - color: color, - shadows: kElevationToShadow[2], - ), + Icon(icon, fill: 1, color: color), BodyText.large( label, fontSize: 20, - color: Colors.white, - shadows: kElevationToShadow[2], + color: context.colors.onSurface, ), ], ), diff --git a/lib/app/new_home/_widgets/weather_parameters.dart b/lib/app/new_home/_widgets/weather_parameters.dart deleted file mode 100644 index 679c60d5d..000000000 --- a/lib/app/new_home/_widgets/weather_parameters.dart +++ /dev/null @@ -1,125 +0,0 @@ -/// A grid of weather parameter cards for the home page. -library; - -import 'package:dpip/api/model/weather_schema.dart'; -import 'package:dpip/app/new_home/_models/home_model.dart'; -import 'package:dpip/core/i18n.dart'; -import 'package:dpip/utils/extensions/build_context.dart'; -import 'package:dpip/widgets/typography.dart'; -import 'package:flutter/material.dart'; -import 'package:material_symbols_icons/material_symbols_icons.dart'; -import 'package:provider/provider.dart'; - -typedef _Params = ({ - double? humidity, - RealtimeWeatherWind? wind, - double? rain, -}); - -/// A 2×2 grid of cards showing humidity, air quality, wind, and rainfall. -/// -/// Reads values from [HomeModel] and rebuilds only when the relevant weather -/// parameters change. -class WeatherParameters extends StatelessWidget { - /// Creates a [WeatherParameters] widget. - const WeatherParameters({super.key}); - - @override - Widget build(BuildContext context) { - return Selector( - selector: (_, m) { - final d = m.weather?.data; - return ( - humidity: d?.humidity, - wind: d?.wind, - rain: d?.rain, - ); - }, - builder: (context, params, _) { - final (:humidity, :wind, :rain) = params; - - return GridView.count( - crossAxisCount: 2, - childAspectRatio: 7 / 5, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - padding: const .symmetric(horizontal: 12, vertical: 4), - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - children: [ - _ParameterCard( - icon: const Icon(Symbols.water_drop_rounded, fill: 1, color: Colors.blueAccent), - label: Text('相對溼度'.i18n), - value: humidity != null ? '${humidity.round()}%' : '--', - ), - _ParameterCard( - icon: const Icon(Symbols.mist_rounded, fill: 1, color: Colors.grey), - label: Text('空氣品質'.i18n), - value: '--', - ), - _ParameterCard( - icon: const Icon(Symbols.air_rounded, fill: 1, color: Colors.lightBlue), - label: Text('風向/風速'.i18n), - value: wind != null && wind.direction.isNotEmpty ? wind.direction : '--', - footer: wind != null ? Text('${wind.speed.toStringAsFixed(1)} m/s') : null, - ), - _ParameterCard( - icon: const Icon(Symbols.umbrella_rounded, fill: 1, color: Colors.indigoAccent), - label: Text('降水量'.i18n), - value: rain != null ? '${rain.toStringAsFixed(1)} mm' : '--', - ), - ], - ); - }, - ); - } -} - -class _ParameterCard extends StatelessWidget { - final Icon icon; - final Widget label; - final String value; - final Widget? footer; - - const _ParameterCard({ - required this.icon, - required this.label, - required this.value, - this.footer, - }); - - @override - Widget build(BuildContext context) { - return Card( - child: Padding( - padding: const .all(16), - child: Column( - crossAxisAlignment: .start, - spacing: 4, - children: [ - Row( - spacing: 4, - children: [ - icon, - DefaultTextStyle( - style: context.texts.bodyMedium!.copyWith( - color: context.colors.onSurfaceVariant, - ), - child: label, - ), - ], - ), - HeadLineText.medium(value, weight: .bold), - if (footer != null) - DefaultTextStyle( - style: context.texts.bodyLarge!.copyWith( - color: context.colors.onSurfaceVariant, - ), - child: footer!, - ), - ], - ), - ), - ); - } -} diff --git a/lib/app/new_home/_widgets/wind.dart b/lib/app/new_home/_widgets/wind.dart new file mode 100644 index 000000000..037794e41 --- /dev/null +++ b/lib/app/new_home/_widgets/wind.dart @@ -0,0 +1,24 @@ +/// 風向卡。 +library; + +import 'package:dpip/api/model/weather_schema.dart'; +import 'package:dpip/app/home/_widgets/wind_card.dart'; +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class Wind extends StatelessWidget { + /// 繼承 [HomeModel] + const Wind({super.key}); + + @override + Widget build(BuildContext context) { + return Selector( + selector: (_, m) => m.weather, + builder: (context, weather, _) { + if (weather == null) return const SizedBox.shrink(); + return WindCard(weather); + }, + ); + } +} diff --git a/lib/app/new_home/layout.dart b/lib/app/new_home/layout.dart index 554bdb7b0..9378ae540 100644 --- a/lib/app/new_home/layout.dart +++ b/lib/app/new_home/layout.dart @@ -1,192 +1,105 @@ -/// Single stateful shell hosting all 5 bottom-nav tabs via [IndexedStack]. -/// -/// Tabs preserve state between switches (no rebuild) and the bottom nav is -/// fixed at the bottom of the screen. Routes can pass [initialTab] for deep -/// linking; the shell exposes [NewHomeShell.of] for descendants to switch -/// tabs programmatically. -library; - -import 'package:dpip/app/map/page.dart'; -import 'package:dpip/app/new_home/events/page.dart'; -import 'package:dpip/app/new_home/menu/page.dart'; -import 'package:dpip/app/new_home/page.dart'; -import 'package:dpip/app/new_home/weather/page.dart'; import 'package:dpip/core/i18n.dart'; +import 'package:dpip/router.dart'; import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/utils/extensions/color.dart'; import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; -/// Home tab index. -const int tabHome = 0; - -/// Events timeline tab index. -const int tabEvents = 1; - -/// Map tab index. -const int tabMap = 2; - -/// Weather detail tab index. -const int tabWeather = 3; - -/// Menu tab index. -const int tabMenu = 4; - -/// Single-instance stateful shell that hosts every tab. -class NewHomeShell extends StatefulWidget { - /// The tab to display on first build. - final int initialTab; - - /// Creates a [NewHomeShell] starting on [initialTab]. - const NewHomeShell({super.key, this.initialTab = tabHome}); +class NewHomeLayout extends StatefulWidget { + final Widget child; - /// Returns the nearest enclosing [NewHomeShellState], or `null` when not - /// inside a shell. - static NewHomeShellState? of(BuildContext context) => - context.findAncestorStateOfType(); + const NewHomeLayout({required this.child, super.key}); @override - State createState() => NewHomeShellState(); + State createState() => _NewHomeLayoutState(); } -/// Public state allowing descendants to call [setTab]. -class NewHomeShellState extends State { - late int _tabIndex = widget.initialTab; - late final Set _visited = {_tabIndex}; - - /// Switches the active tab. - void setTab(int index) { - if (index == _tabIndex) return; - setState(() { - _visited.add(index); - _tabIndex = index; - }); - } +class _NewHomeLayoutState extends State with TickerProviderStateMixin { + late final _scrollAnimator = AnimationController(vsync: this); @override Widget build(BuildContext context) { return Scaffold( - body: IndexedStack( - index: _tabIndex, + body: Stack( children: [ - _LazyTab(visited: _visited.contains(tabHome), child: const NewHomePage()), - _LazyTab(visited: _visited.contains(tabEvents), child: const EventsPage()), - _LazyTab( - visited: _visited.contains(tabMap), - child: const MapPage(showBackButton: false), + Positioned.fill( + child: widget.child, ), - _LazyTab( - visited: _visited.contains(tabWeather), - child: const WeatherDetailPage(), + Positioned( + top: 0, + left: 0, + right: 0, + height: context.padding.top + 8, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: .topCenter, + end: .bottomCenter, + colors: [ + context.colors.surface / 40, + context.colors.surface / 0, + ], + ), + ), + ), ), - _LazyTab(visited: _visited.contains(tabMenu), child: const MenuPage()), - ], - ), - bottomNavigationBar: _BottomNav( - selectedIndex: _tabIndex, - onTap: setTab, - ), - ); - } -} - -/// Builds [child] only after its tab has been visited at least once, then -/// keeps it alive in the stack for subsequent visits. -class _LazyTab extends StatelessWidget { - final bool visited; - final Widget child; - - const _LazyTab({required this.visited, required this.child}); - - @override - Widget build(BuildContext context) { - if (!visited) return const SizedBox.shrink(); - return child; - } -} - -/// Fixed-position bottom navigation bar using the Material 3 [NavigationBar]. -class _BottomNav extends StatelessWidget { - final int selectedIndex; - final ValueChanged onTap; - - const _BottomNav({required this.selectedIndex, required this.onTap}); - - @override - Widget build(BuildContext context) { - final colors = context.colors; - return NavigationBarTheme( - data: NavigationBarThemeData( - height: 72, - backgroundColor: colors.surfaceContainer, - surfaceTintColor: Colors.transparent, - elevation: 3, - indicatorColor: colors.secondaryContainer, - indicatorShape: const StadiumBorder(), - iconTheme: WidgetStateProperty.resolveWith( - (states) => IconThemeData( - size: 24, - color: states.contains(WidgetState.selected) - ? colors.onSecondaryContainer - : colors.onSurfaceVariant, + Positioned( + top: context.padding.top + 8, + right: 8, + child: Row( + mainAxisSize: .min, + mainAxisAlignment: .end, + children: [ + IconButton.filledTonal( + onPressed: () {}, + icon: const Icon( + Symbols.notifications_rounded, + fill: 1, + ), + style: IconButton.styleFrom( + elevation: 4, + ), + ), + IconButton.filledTonal( + onPressed: () => const SettingsIndexRoute().push(context), + icon: const Icon( + Symbols.settings_rounded, + fill: 1, + ), + style: IconButton.styleFrom( + elevation: 4, + ), + ), + ], + ), ), - ), - labelTextStyle: WidgetStateProperty.resolveWith((states) { - final base = context.texts.labelMedium ?? const TextStyle(); - return base.copyWith( - fontWeight: states.contains(WidgetState.selected) ? .w700 : .w500, - color: states.contains(WidgetState.selected) - ? colors.onSurface - : colors.onSurfaceVariant, - ); - }), - labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + ], ), - child: NavigationBar( - selectedIndex: selectedIndex, - onDestinationSelected: onTap, + extendBody: true, + bottomNavigationBar: NavigationBarM3E( + elevation: 2, + padding: const .symmetric(horizontal: 72, vertical: 8), + density: .compact, destinations: [ - NavigationDestination( + NavigationDestinationM3E( icon: const Icon(Symbols.home_rounded), selectedIcon: const Icon(Symbols.home_rounded, fill: 1), label: '首頁'.i18n, ), - NavigationDestination( - icon: const Icon(Symbols.history_rounded), - selectedIcon: const Icon(Symbols.history_rounded, fill: 1), - label: '事件'.i18n, - ), - NavigationDestination( + NavigationDestinationM3E( icon: const Icon(Symbols.map_rounded), selectedIcon: const Icon(Symbols.map_rounded, fill: 1), label: '地圖'.i18n, ), - NavigationDestination( - icon: const Icon(Symbols.partly_cloudy_day_rounded), - selectedIcon: const Icon(Symbols.partly_cloudy_day_rounded, fill: 1), - label: '天氣'.i18n, - ), - NavigationDestination( - icon: const Icon(Symbols.menu_rounded), - selectedIcon: const Icon(Symbols.menu_rounded, fill: 1), - label: '選單'.i18n, - ), ], ), ); } -} - -/// Backwards-compat shim — keeps the old name used elsewhere in routing. -/// -/// Routes that wrapped a page in [NewHomeLayout] now produce a [NewHomeShell]. -/// The [child] is ignored because each tab has its own dedicated widget. -class NewHomeLayout extends StatelessWidget { - /// Ignored — the shell owns its own tab widgets. Kept for source compat. - final Widget child; - - /// Creates a [NewHomeLayout]. The [child] argument is ignored. - const NewHomeLayout({required this.child, super.key}); @override - Widget build(BuildContext context) => const NewHomeShell(); + void dispose() { + _scrollAnimator.dispose(); + super.dispose(); + } } diff --git a/lib/app/new_home/page.dart b/lib/app/new_home/page.dart index 2e9ab40d3..a15b3ef94 100644 --- a/lib/app/new_home/page.dart +++ b/lib/app/new_home/page.dart @@ -1,21 +1,19 @@ -/// The new home page — a concise weather + alerts + events dashboard. +/// The new home page providing weather data via [HomeModel]. library; import 'package:dpip/app/new_home/_models/home_model.dart'; -import 'package:dpip/app/new_home/_models/weather_params.dart'; -import 'package:dpip/app/new_home/_widgets/eew_alert.dart'; -import 'package:dpip/app/new_home/_widgets/events_timeline.dart'; +import 'package:dpip/app/new_home/_widgets/assistant_hint.dart'; +import 'package:dpip/app/new_home/_widgets/day_cycle.dart'; +import 'package:dpip/app/new_home/_widgets/forecast.dart'; import 'package:dpip/app/new_home/_widgets/greeting.dart'; import 'package:dpip/app/new_home/_widgets/location_chip.dart'; import 'package:dpip/app/new_home/_widgets/radar.dart'; +import 'package:dpip/app/new_home/_widgets/station_info.dart'; import 'package:dpip/app/new_home/_widgets/temperature.dart'; -import 'package:dpip/app/new_home/_widgets/thunderstorm_alert.dart'; import 'package:dpip/app/new_home/_widgets/weather.dart'; import 'package:dpip/app/new_home/_widgets/weather_background.dart'; -import 'package:dpip/app/new_home/_widgets/weather_particles.dart'; +import 'package:dpip/app/new_home/_widgets/wind.dart'; import 'package:dpip/models/settings/location.dart'; -import 'package:dpip/utils/extensions/build_context.dart'; -import 'package:dpip/utils/extensions/color.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -23,8 +21,7 @@ import 'package:provider/provider.dart'; /// /// Lazily creates a [HomeModel] on first dependency resolution, provides it to /// all child widgets, supports pull-to-refresh, and automatically refreshes -/// weather data every 30 minutes. The page mirrors the original home's hero + -/// alerts + events flow with a modern shader + particle weather background. +/// weather data every 30 minutes. class NewHomePage extends StatefulWidget { /// Creates a [NewHomePage]. const NewHomePage({super.key}); @@ -58,62 +55,30 @@ class _NewHomePageState extends State { return ChangeNotifierProvider.value( value: homeModel, - child: Selector( - selector: (_, m) { - final d = m.weather?.data; - return ( - scene: resolveSkyScene(DateTime.now().hour), - cloud: cloudWeight(d), - rain: rainWeight(d), - ); - }, - builder: (context, params, _) { - final colorScheme = ColorScheme.fromSeed( - seedColor: _seedColor(params.scene, params.cloud, params.rain), - brightness: context.theme.brightness, - ); - - return AnimatedTheme( - duration: const Duration(milliseconds: 600), - data: context.theme.copyWith( - colorScheme: colorScheme, - cardTheme: CardThemeData( - color: colorScheme.surface / 95, - ), - ), - child: Stack( - children: [ - // Layered weather background: shader sky + animated particles. - Positioned.fill(child: WeatherBackground(scrollOffset: _scrollOffset)), - const Positioned.fill(child: WeatherParticles()), - RefreshIndicator( - onRefresh: homeModel.manualRefresh, - child: ListView( - controller: _scrollController, - children: const [ - Greeting(), - LocationChip(), - SizedBox(height: 8), - // Critical alerts surface first. - EewAlerts(), - ThunderstormAlert(), - SizedBox(height: 8), - // Weather hero. - Temperature(), - Weather(), - SizedBox(height: 16), - // Radar preview - 1 tap to full map. - Radar(), - // Events timeline - the originally-missing list. - EventsTimeline(), - SizedBox(height: 16), - ], - ), - ), + child: Stack( + children: [ + Positioned.fill(child: WeatherBackground(scrollOffset: _scrollOffset)), + RefreshIndicator( + onRefresh: homeModel.manualRefresh, + child: ListView( + controller: _scrollController, + children: const [ + Greeting(), + LocationChip(), + SizedBox(height: 16), + Temperature(), + Weather(), + SizedBox(height: 16), + AssistantHint(), + StationInfo(), + Forecast(), + Wind(), + DayCycle(), + Radar(), ], ), - ); - }, + ), + ], ), ); } @@ -128,19 +93,3 @@ class _NewHomePageState extends State { super.dispose(); } } - -Color _seedColor(int scene, double cloud, double rain) { - final base = switch (scene) { - 1 => const Color(0xFF1A237E), - 2 => const Color(0xFF6A1B9A), - 3 => const Color(0xFFC62828), - _ => const Color(0xFF1565C0), - }; - if (rain > 0.4) { - return Color.lerp(base, const Color(0xFF263238), ((rain - 0.4) * 1.2).clamp(0.0, 0.7))!; - } - if (cloud > 0.5) { - return Color.lerp(base, const Color(0xFF546E7A), (cloud - 0.5) * 0.5)!; - } - return base; -} diff --git a/pubspec.lock b/pubspec.lock index 6cbbe6689..556bfb046 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1553,10 +1553,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" timezone: dependency: "direct main" description: From 983c66c3487259893443f184340e98837682b1d0 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Sat, 30 May 2026 04:19:48 +0800 Subject: [PATCH 3/3] init: init --- lib/app/new_home/_models/home_model.dart | 74 +++--- .../_widgets/all_observation_average.dart | 91 +++++++ lib/app/new_home/_widgets/greeting.dart | 16 +- .../new_home/_widgets/weather_parameters.dart | 125 ++++++++++ lib/app/new_home/layout.dart | 227 ++++++++++++------ lib/app/new_home/page.dart | 111 +++++++-- 6 files changed, 500 insertions(+), 144 deletions(-) create mode 100644 lib/app/new_home/_widgets/all_observation_average.dart create mode 100644 lib/app/new_home/_widgets/weather_parameters.dart diff --git a/lib/app/new_home/_models/home_model.dart b/lib/app/new_home/_models/home_model.dart index 0460b92b1..abd9de420 100644 --- a/lib/app/new_home/_models/home_model.dart +++ b/lib/app/new_home/_models/home_model.dart @@ -3,9 +3,12 @@ library; import 'dart:async'; +import 'package:collection/collection.dart'; +import 'package:dpip/api/model/history/history.dart'; import 'package:dpip/api/model/weather_schema.dart'; import 'package:dpip/global.dart'; import 'package:dpip/models/settings/location.dart'; +import 'package:dpip/utils/log.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -23,9 +26,8 @@ class HomeModel extends ChangeNotifier { final SettingsLocationModel _settingsLocation; String? _temporaryCode; RealtimeWeather? _weather; + List _alerts = const []; Map? _forecast; - bool _isLoading = false; - Object? _error; Timer? _autoRefreshTimer; /// Creates a [HomeModel] backed by [settingsLocation]. @@ -40,54 +42,50 @@ class HomeModel extends ChangeNotifier { if (_temporaryCode == null) _doRefresh(); } - Future _doRefresh() async { - final code = _temporaryCode ?? _settingsLocation.code; - double? lat; - double? lon; - - if (code != null) { - final loc = Global.location[code]; - if (loc != null) { - lat = loc.lat; - lon = loc.lng; - } + /// Runs [task] and logs failures under [tag]; returns `null` on error. + static Future _safe(String tag, Future Function() task) async { + try { + return await task(); + } catch (e) { + TalkerManager.instance.error('HomeModel $tag', e); + return null; } + } - lat ??= _settingsLocation.coordinates?.latitude; - lon ??= _settingsLocation.coordinates?.longitude; - + Future _doRefresh() async { + final code = _temporaryCode ?? _settingsLocation.code; + final loc = code != null ? Global.location[code] : null; + final lat = loc?.lat ?? _settingsLocation.coordinates?.latitude; + final lon = loc?.lng ?? _settingsLocation.coordinates?.longitude; if (lat == null || lon == null) return; - _isLoading = true; + // Fetch weather + alerts + forecast in parallel. + final results = await Future.wait([ + _safe('weather', () => Global.api.getWeatherRealtimeByCoords(lat, lon)), + if (code != null) _safe('alerts', () => Global.api.getRealtimeRegion(code)) else Future.value(null), + if (code != null) _safe('forecast', () => Global.api.getWeatherForecast(code)) else Future.value(null), + ]); + + if (results[0] is RealtimeWeather) _weather = results[0] as RealtimeWeather; + _alerts = results[1] is List + ? (results[1]! as List).sorted((a, b) => b.time.send.compareTo(a.time.send)) + : const []; + if (results[2] is Map) _forecast = results[2] as Map; notifyListeners(); - - try { - final results = await Future.wait([ - Global.api.getWeatherRealtimeByCoords(lat, lon), - if (code != null) Global.api.getWeatherForecast(code), - ]); - _weather = results[0] as RealtimeWeather; - _forecast = code != null ? results[1] as Map : null; - _error = null; - } catch (e) { - _error = e; - } finally { - _isLoading = false; - notifyListeners(); - } } /// The most recently fetched weather data, or `null` if not yet loaded. RealtimeWeather? get weather => _weather; - /// The most recently fetched forecast data, or `null` if not yet loaded. - Map? get forecast => _forecast; + /// The most recent realtime alerts for the active location, sorted newest first. + List get alerts => _alerts; - /// Whether a weather fetch is currently in progress. - bool get isLoading => _isLoading; + /// The most recent thunderstorm alert, or `null` when none active. + History? get thunderstorm => + _alerts.firstWhereOrNull((e) => e.type == HistoryType.thunderstorm); - /// The error from the last failed fetch, or `null` when the last fetch succeeded. - Object? get error => _error; + /// The 24-hour weather forecast for the active location, or `null` if missing. + Map? get forecast => _forecast; /// The currently active temporary location code, or `null` when unset. String? get temporaryCode => _temporaryCode; diff --git a/lib/app/new_home/_widgets/all_observation_average.dart b/lib/app/new_home/_widgets/all_observation_average.dart new file mode 100644 index 000000000..5af244c00 --- /dev/null +++ b/lib/app/new_home/_widgets/all_observation_average.dart @@ -0,0 +1,91 @@ +/// A card widget showing the nearest-station temperature and humidity summary. +library; + +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/widgets/typography.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:provider/provider.dart'; + +/// Displays temperature and humidity from the nearest CWA weather station. +/// +/// The left column shows TREM network data (currently unavailable in this +/// context, displayed as "--"). The right column shows CWA nearest-station +/// readings from [HomeModel]. Rebuilds only when temperature or humidity change. +class AllObservationAverage extends StatelessWidget { + /// Creates an [AllObservationAverage] widget. + const AllObservationAverage({super.key}); + + @override + Widget build(BuildContext context) { + return Selector( + selector: (_, m) { + final d = m.weather?.data; + return d != null ? (d.temperature, d.humidity) : null; + }, + builder: (context, data, _) { + const tremLabel = '--° / --%'; + final cwaLabel = data != null + ? '${data.$1.toStringAsFixed(1)}° / ${data.$2.round()}%' + : '--° / --%'; + + return Padding( + padding: const .symmetric(horizontal: 12, vertical: 8), + child: Column( + crossAxisAlignment: .start, + spacing: 4, + children: [ + Padding( + padding: const .symmetric(horizontal: 8), + child: LabelText.large( + '所有測站平均', + color: Colors.white, + shadows: kElevationToShadow[1], + ), + ), + Card( + child: Padding( + padding: const .all(12), + child: IntrinsicHeight( + child: Row( + children: [ + Expanded( + child: Row( + spacing: 8, + children: [ + Icon( + Symbols.cell_tower_rounded, + fill: 1, + color: context.colors.onSurfaceVariant, + ), + Text(tremLabel, style: const TextStyle(fontSize: 16)), + ], + ), + ), + const VerticalDivider(width: 24), + Expanded( + child: Row( + spacing: 8, + children: [ + Icon( + Symbols.globe_asia_rounded, + fill: 1, + color: context.colors.onSurfaceVariant, + ), + Text(cwaLabel, style: const TextStyle(fontSize: 16)), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/app/new_home/_widgets/greeting.dart b/lib/app/new_home/_widgets/greeting.dart index 41a34544f..7b6b26a9c 100644 --- a/lib/app/new_home/_widgets/greeting.dart +++ b/lib/app/new_home/_widgets/greeting.dart @@ -1,8 +1,8 @@ /// A greeting widget that displays a time-aware salutation. library; +import 'package:dpip/app/new_home/_models/weather_params.dart'; import 'package:dpip/core/i18n.dart'; -import 'package:dpip/utils/extensions/build_context.dart'; import 'package:dpip/widgets/typography.dart'; import 'package:flutter/material.dart'; @@ -13,20 +13,12 @@ class Greeting extends StatelessWidget { @override Widget build(BuildContext context) { - final hour = DateTime.now().hour; - - final greeting = switch (hour) { - < 6 => '夜深了', - < 12 => '早安', - < 18 => '午安', - _ => '晚安', - }; - return Padding( padding: const .all(16), child: TitleText.large( - greeting.i18n, - color: context.colors.onSurface, + greetingForHour(DateTime.now().hour).i18n, + color: Colors.white, + shadows: kElevationToShadow[2], ), ); } diff --git a/lib/app/new_home/_widgets/weather_parameters.dart b/lib/app/new_home/_widgets/weather_parameters.dart new file mode 100644 index 000000000..679c60d5d --- /dev/null +++ b/lib/app/new_home/_widgets/weather_parameters.dart @@ -0,0 +1,125 @@ +/// A grid of weather parameter cards for the home page. +library; + +import 'package:dpip/api/model/weather_schema.dart'; +import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/core/i18n.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/widgets/typography.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:provider/provider.dart'; + +typedef _Params = ({ + double? humidity, + RealtimeWeatherWind? wind, + double? rain, +}); + +/// A 2×2 grid of cards showing humidity, air quality, wind, and rainfall. +/// +/// Reads values from [HomeModel] and rebuilds only when the relevant weather +/// parameters change. +class WeatherParameters extends StatelessWidget { + /// Creates a [WeatherParameters] widget. + const WeatherParameters({super.key}); + + @override + Widget build(BuildContext context) { + return Selector( + selector: (_, m) { + final d = m.weather?.data; + return ( + humidity: d?.humidity, + wind: d?.wind, + rain: d?.rain, + ); + }, + builder: (context, params, _) { + final (:humidity, :wind, :rain) = params; + + return GridView.count( + crossAxisCount: 2, + childAspectRatio: 7 / 5, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + padding: const .symmetric(horizontal: 12, vertical: 4), + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + children: [ + _ParameterCard( + icon: const Icon(Symbols.water_drop_rounded, fill: 1, color: Colors.blueAccent), + label: Text('相對溼度'.i18n), + value: humidity != null ? '${humidity.round()}%' : '--', + ), + _ParameterCard( + icon: const Icon(Symbols.mist_rounded, fill: 1, color: Colors.grey), + label: Text('空氣品質'.i18n), + value: '--', + ), + _ParameterCard( + icon: const Icon(Symbols.air_rounded, fill: 1, color: Colors.lightBlue), + label: Text('風向/風速'.i18n), + value: wind != null && wind.direction.isNotEmpty ? wind.direction : '--', + footer: wind != null ? Text('${wind.speed.toStringAsFixed(1)} m/s') : null, + ), + _ParameterCard( + icon: const Icon(Symbols.umbrella_rounded, fill: 1, color: Colors.indigoAccent), + label: Text('降水量'.i18n), + value: rain != null ? '${rain.toStringAsFixed(1)} mm' : '--', + ), + ], + ); + }, + ); + } +} + +class _ParameterCard extends StatelessWidget { + final Icon icon; + final Widget label; + final String value; + final Widget? footer; + + const _ParameterCard({ + required this.icon, + required this.label, + required this.value, + this.footer, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const .all(16), + child: Column( + crossAxisAlignment: .start, + spacing: 4, + children: [ + Row( + spacing: 4, + children: [ + icon, + DefaultTextStyle( + style: context.texts.bodyMedium!.copyWith( + color: context.colors.onSurfaceVariant, + ), + child: label, + ), + ], + ), + HeadLineText.medium(value, weight: .bold), + if (footer != null) + DefaultTextStyle( + style: context.texts.bodyLarge!.copyWith( + color: context.colors.onSurfaceVariant, + ), + child: footer!, + ), + ], + ), + ), + ); + } +} diff --git a/lib/app/new_home/layout.dart b/lib/app/new_home/layout.dart index 9378ae540..554bdb7b0 100644 --- a/lib/app/new_home/layout.dart +++ b/lib/app/new_home/layout.dart @@ -1,105 +1,192 @@ +/// Single stateful shell hosting all 5 bottom-nav tabs via [IndexedStack]. +/// +/// Tabs preserve state between switches (no rebuild) and the bottom nav is +/// fixed at the bottom of the screen. Routes can pass [initialTab] for deep +/// linking; the shell exposes [NewHomeShell.of] for descendants to switch +/// tabs programmatically. +library; + +import 'package:dpip/app/map/page.dart'; +import 'package:dpip/app/new_home/events/page.dart'; +import 'package:dpip/app/new_home/menu/page.dart'; +import 'package:dpip/app/new_home/page.dart'; +import 'package:dpip/app/new_home/weather/page.dart'; import 'package:dpip/core/i18n.dart'; -import 'package:dpip/router.dart'; import 'package:dpip/utils/extensions/build_context.dart'; -import 'package:dpip/utils/extensions/color.dart'; import 'package:flutter/material.dart'; -import 'package:m3e_collection/m3e_collection.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; -class NewHomeLayout extends StatefulWidget { - final Widget child; +/// Home tab index. +const int tabHome = 0; - const NewHomeLayout({required this.child, super.key}); +/// Events timeline tab index. +const int tabEvents = 1; + +/// Map tab index. +const int tabMap = 2; + +/// Weather detail tab index. +const int tabWeather = 3; + +/// Menu tab index. +const int tabMenu = 4; + +/// Single-instance stateful shell that hosts every tab. +class NewHomeShell extends StatefulWidget { + /// The tab to display on first build. + final int initialTab; + + /// Creates a [NewHomeShell] starting on [initialTab]. + const NewHomeShell({super.key, this.initialTab = tabHome}); + + /// Returns the nearest enclosing [NewHomeShellState], or `null` when not + /// inside a shell. + static NewHomeShellState? of(BuildContext context) => + context.findAncestorStateOfType(); @override - State createState() => _NewHomeLayoutState(); + State createState() => NewHomeShellState(); } -class _NewHomeLayoutState extends State with TickerProviderStateMixin { - late final _scrollAnimator = AnimationController(vsync: this); +/// Public state allowing descendants to call [setTab]. +class NewHomeShellState extends State { + late int _tabIndex = widget.initialTab; + late final Set _visited = {_tabIndex}; + + /// Switches the active tab. + void setTab(int index) { + if (index == _tabIndex) return; + setState(() { + _visited.add(index); + _tabIndex = index; + }); + } @override Widget build(BuildContext context) { return Scaffold( - body: Stack( + body: IndexedStack( + index: _tabIndex, children: [ - Positioned.fill( - child: widget.child, - ), - Positioned( - top: 0, - left: 0, - right: 0, - height: context.padding.top + 8, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: .topCenter, - end: .bottomCenter, - colors: [ - context.colors.surface / 40, - context.colors.surface / 0, - ], - ), - ), - ), + _LazyTab(visited: _visited.contains(tabHome), child: const NewHomePage()), + _LazyTab(visited: _visited.contains(tabEvents), child: const EventsPage()), + _LazyTab( + visited: _visited.contains(tabMap), + child: const MapPage(showBackButton: false), ), - Positioned( - top: context.padding.top + 8, - right: 8, - child: Row( - mainAxisSize: .min, - mainAxisAlignment: .end, - children: [ - IconButton.filledTonal( - onPressed: () {}, - icon: const Icon( - Symbols.notifications_rounded, - fill: 1, - ), - style: IconButton.styleFrom( - elevation: 4, - ), - ), - IconButton.filledTonal( - onPressed: () => const SettingsIndexRoute().push(context), - icon: const Icon( - Symbols.settings_rounded, - fill: 1, - ), - style: IconButton.styleFrom( - elevation: 4, - ), - ), - ], - ), + _LazyTab( + visited: _visited.contains(tabWeather), + child: const WeatherDetailPage(), ), + _LazyTab(visited: _visited.contains(tabMenu), child: const MenuPage()), ], ), - extendBody: true, - bottomNavigationBar: NavigationBarM3E( - elevation: 2, - padding: const .symmetric(horizontal: 72, vertical: 8), - density: .compact, + bottomNavigationBar: _BottomNav( + selectedIndex: _tabIndex, + onTap: setTab, + ), + ); + } +} + +/// Builds [child] only after its tab has been visited at least once, then +/// keeps it alive in the stack for subsequent visits. +class _LazyTab extends StatelessWidget { + final bool visited; + final Widget child; + + const _LazyTab({required this.visited, required this.child}); + + @override + Widget build(BuildContext context) { + if (!visited) return const SizedBox.shrink(); + return child; + } +} + +/// Fixed-position bottom navigation bar using the Material 3 [NavigationBar]. +class _BottomNav extends StatelessWidget { + final int selectedIndex; + final ValueChanged onTap; + + const _BottomNav({required this.selectedIndex, required this.onTap}); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + return NavigationBarTheme( + data: NavigationBarThemeData( + height: 72, + backgroundColor: colors.surfaceContainer, + surfaceTintColor: Colors.transparent, + elevation: 3, + indicatorColor: colors.secondaryContainer, + indicatorShape: const StadiumBorder(), + iconTheme: WidgetStateProperty.resolveWith( + (states) => IconThemeData( + size: 24, + color: states.contains(WidgetState.selected) + ? colors.onSecondaryContainer + : colors.onSurfaceVariant, + ), + ), + labelTextStyle: WidgetStateProperty.resolveWith((states) { + final base = context.texts.labelMedium ?? const TextStyle(); + return base.copyWith( + fontWeight: states.contains(WidgetState.selected) ? .w700 : .w500, + color: states.contains(WidgetState.selected) + ? colors.onSurface + : colors.onSurfaceVariant, + ); + }), + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + ), + child: NavigationBar( + selectedIndex: selectedIndex, + onDestinationSelected: onTap, destinations: [ - NavigationDestinationM3E( + NavigationDestination( icon: const Icon(Symbols.home_rounded), selectedIcon: const Icon(Symbols.home_rounded, fill: 1), label: '首頁'.i18n, ), - NavigationDestinationM3E( + NavigationDestination( + icon: const Icon(Symbols.history_rounded), + selectedIcon: const Icon(Symbols.history_rounded, fill: 1), + label: '事件'.i18n, + ), + NavigationDestination( icon: const Icon(Symbols.map_rounded), selectedIcon: const Icon(Symbols.map_rounded, fill: 1), label: '地圖'.i18n, ), + NavigationDestination( + icon: const Icon(Symbols.partly_cloudy_day_rounded), + selectedIcon: const Icon(Symbols.partly_cloudy_day_rounded, fill: 1), + label: '天氣'.i18n, + ), + NavigationDestination( + icon: const Icon(Symbols.menu_rounded), + selectedIcon: const Icon(Symbols.menu_rounded, fill: 1), + label: '選單'.i18n, + ), ], ), ); } +} + +/// Backwards-compat shim — keeps the old name used elsewhere in routing. +/// +/// Routes that wrapped a page in [NewHomeLayout] now produce a [NewHomeShell]. +/// The [child] is ignored because each tab has its own dedicated widget. +class NewHomeLayout extends StatelessWidget { + /// Ignored — the shell owns its own tab widgets. Kept for source compat. + final Widget child; + + /// Creates a [NewHomeLayout]. The [child] argument is ignored. + const NewHomeLayout({required this.child, super.key}); @override - void dispose() { - _scrollAnimator.dispose(); - super.dispose(); - } + Widget build(BuildContext context) => const NewHomeShell(); } diff --git a/lib/app/new_home/page.dart b/lib/app/new_home/page.dart index a15b3ef94..7c3cf408a 100644 --- a/lib/app/new_home/page.dart +++ b/lib/app/new_home/page.dart @@ -1,19 +1,26 @@ -/// The new home page providing weather data via [HomeModel]. +/// The new home page — a concise weather + alerts + events dashboard. library; import 'package:dpip/app/new_home/_models/home_model.dart'; +import 'package:dpip/app/new_home/_models/weather_params.dart'; import 'package:dpip/app/new_home/_widgets/assistant_hint.dart'; import 'package:dpip/app/new_home/_widgets/day_cycle.dart'; +import 'package:dpip/app/new_home/_widgets/eew_alert.dart'; +import 'package:dpip/app/new_home/_widgets/events_timeline.dart'; import 'package:dpip/app/new_home/_widgets/forecast.dart'; import 'package:dpip/app/new_home/_widgets/greeting.dart'; import 'package:dpip/app/new_home/_widgets/location_chip.dart'; import 'package:dpip/app/new_home/_widgets/radar.dart'; import 'package:dpip/app/new_home/_widgets/station_info.dart'; import 'package:dpip/app/new_home/_widgets/temperature.dart'; +import 'package:dpip/app/new_home/_widgets/thunderstorm_alert.dart'; import 'package:dpip/app/new_home/_widgets/weather.dart'; import 'package:dpip/app/new_home/_widgets/weather_background.dart'; +import 'package:dpip/app/new_home/_widgets/weather_particles.dart'; import 'package:dpip/app/new_home/_widgets/wind.dart'; import 'package:dpip/models/settings/location.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/utils/extensions/color.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -21,7 +28,8 @@ import 'package:provider/provider.dart'; /// /// Lazily creates a [HomeModel] on first dependency resolution, provides it to /// all child widgets, supports pull-to-refresh, and automatically refreshes -/// weather data every 30 minutes. +/// weather data every 30 minutes. The page mirrors the original home's hero + +/// alerts + events flow with a modern shader + particle weather background. class NewHomePage extends StatefulWidget { /// Creates a [NewHomePage]. const NewHomePage({super.key}); @@ -55,30 +63,69 @@ class _NewHomePageState extends State { return ChangeNotifierProvider.value( value: homeModel, - child: Stack( - children: [ - Positioned.fill(child: WeatherBackground(scrollOffset: _scrollOffset)), - RefreshIndicator( - onRefresh: homeModel.manualRefresh, - child: ListView( - controller: _scrollController, - children: const [ - Greeting(), - LocationChip(), - SizedBox(height: 16), - Temperature(), - Weather(), - SizedBox(height: 16), - AssistantHint(), - StationInfo(), - Forecast(), - Wind(), - DayCycle(), - Radar(), + child: Selector( + selector: (_, m) { + final d = m.weather?.data; + return ( + scene: resolveSkyScene(DateTime.now().hour), + cloud: cloudWeight(d), + rain: rainWeight(d), + ); + }, + builder: (context, params, _) { + final colorScheme = ColorScheme.fromSeed( + seedColor: _seedColor(params.scene, params.cloud, params.rain), + brightness: context.theme.brightness, + ); + + return AnimatedTheme( + duration: const Duration(milliseconds: 600), + data: context.theme.copyWith( + colorScheme: colorScheme, + cardTheme: CardThemeData( + color: colorScheme.surface / 95, + ), + ), + child: Stack( + children: [ + // Layered weather background: shader sky + animated particles. + Positioned.fill(child: WeatherBackground(scrollOffset: _scrollOffset)), + const Positioned.fill(child: WeatherParticles()), + RefreshIndicator( + onRefresh: homeModel.manualRefresh, + child: ListView( + controller: _scrollController, + children: const [ + Greeting(), + LocationChip(), + SizedBox(height: 8), + // Critical alerts surface first. + EewAlerts(), + ThunderstormAlert(), + SizedBox(height: 8), + // Weather hero. + Temperature(), + Weather(), + SizedBox(height: 16), + // Detail cards. + AssistantHint(), + StationInfo(), + Forecast(), + Wind(), + DayCycle(), + SizedBox(height: 16), + // Radar preview - 1 tap to full map. + Radar(), + // Events timeline - the originally-missing list. + EventsTimeline(), + SizedBox(height: 16), + ], + ), + ), ], ), - ), - ], + ); + }, ), ); } @@ -93,3 +140,19 @@ class _NewHomePageState extends State { super.dispose(); } } + +Color _seedColor(int scene, double cloud, double rain) { + final base = switch (scene) { + 1 => const Color(0xFF1A237E), + 2 => const Color(0xFF6A1B9A), + 3 => const Color(0xFFC62828), + _ => const Color(0xFF1565C0), + }; + if (rain > 0.4) { + return Color.lerp(base, const Color(0xFF263238), ((rain - 0.4) * 1.2).clamp(0.0, 0.7))!; + } + if (cloud > 0.5) { + return Color.lerp(base, const Color(0xFF546E7A), (cloud - 0.5) * 0.5)!; + } + return base; +}