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/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/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/page.dart b/lib/app/new_home/page.dart index 2e9ab40d3..7c3cf408a 100644 --- a/lib/app/new_home/page.dart +++ b/lib/app/new_home/page.dart @@ -3,16 +3,21 @@ 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'; @@ -102,6 +107,13 @@ class _NewHomePageState extends State { 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. 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..556bfb046 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: @@ -1552,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: 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