From 5c310ed8bedbe67f7ceb933610263f2e723584ed Mon Sep 17 00:00:00 2001 From: Kamiya Date: Wed, 6 Aug 2025 12:00:41 +0800 Subject: [PATCH 1/2] feat: map legends --- lib/app/map/_lib/managers/monitor.dart | 41 +- lib/app/map/_lib/managers/precipitation.dart | 261 ++++++----- lib/app/map/_lib/managers/radar.dart | 451 ++++++++++--------- lib/app/map/_lib/managers/temperature.dart | 219 +++++---- lib/app/map/_lib/managers/wind.dart | 223 +++++---- lib/app/map/_widgets/map_legend.dart | 203 +++++++++ lib/utils/extensions/int.dart | 2 + lib/utils/radar_color.dart | 58 +-- lib/widgets/blurred_container.dart | 40 ++ 9 files changed, 957 insertions(+), 541 deletions(-) create mode 100644 lib/app/map/_widgets/map_legend.dart create mode 100644 lib/widgets/blurred_container.dart diff --git a/lib/app/map/_lib/managers/monitor.dart b/lib/app/map/_lib/managers/monitor.dart index 7b716f276..414c6f761 100644 --- a/lib/app/map/_lib/managers/monitor.dart +++ b/lib/app/map/_lib/managers/monitor.dart @@ -1,6 +1,14 @@ import 'dart:async'; import 'dart:collection'; +import 'package:flutter/material.dart'; + +import 'package:i18n_extension/i18n_extension.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:provider/provider.dart'; +import 'package:styled_text/styled_text.dart'; + import 'package:dpip/api/model/eew.dart'; import 'package:dpip/app/map/_lib/manager.dart'; import 'package:dpip/app/map/_lib/utils.dart'; @@ -17,12 +25,6 @@ import 'package:dpip/utils/instrumental_intensity_color.dart'; import 'package:dpip/utils/log.dart'; import 'package:dpip/widgets/map/map.dart'; import 'package:dpip/widgets/sheet/morphing_sheet.dart'; -import 'package:flutter/material.dart'; -import 'package:i18n_extension/i18n_extension.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:material_symbols_icons/material_symbols_icons.dart'; -import 'package:provider/provider.dart'; -import 'package:styled_text/styled_text.dart'; class MonitorMapLayerManager extends MapLayerManager { final bool isReplayMode; @@ -798,7 +800,10 @@ class _MonitorMapLayerSheetState extends State { ), ], ), - Icon(Symbols.expand_less_rounded, color: context.colors.onErrorContainer, size: 24 + Icon( + Symbols.expand_less_rounded, + color: context.colors.onErrorContainer, + size: 24, ), ], ), @@ -881,7 +886,11 @@ class _MonitorMapLayerSheetState extends State { ), ], ), - Icon(Symbols.expand_more_rounded, color: context.colors.onErrorContainer, size: 24), + Icon( + Symbols.expand_more_rounded, + color: context.colors.onErrorContainer, + size: 24, + ), ], ), Padding( @@ -896,8 +905,12 @@ class _MonitorMapLayerSheetState extends State { 'magnitude': data.info.magnitude.toStringAsFixed(1), 'intensity': localIntensity.asIntensityLabel, }), - style: context.textTheme.bodyLarge!.copyWith(color: context.colors.onErrorContainer), - tags: {'bold': StyledTextTag(style: const TextStyle(fontWeight: FontWeight.bold))}, + style: context.textTheme.bodyLarge!.copyWith( + color: context.colors.onErrorContainer, + ), + tags: { + 'bold': StyledTextTag(style: const TextStyle(fontWeight: FontWeight.bold)), + }, ), ), Selector( @@ -972,14 +985,18 @@ class _MonitorMapLayerSheetState extends State { text: countdown.toString(), style: TextStyle( fontSize: - context.textTheme.displayMedium!.fontSize! * + context + .textTheme + .displayMedium! + .fontSize! * 1.15, ), ), TextSpan( text: ' 秒'.i18n, style: TextStyle( - fontSize: context.textTheme.labelLarge!.fontSize, + fontSize: + context.textTheme.labelLarge!.fontSize, ), ), ], diff --git a/lib/app/map/_lib/managers/precipitation.dart b/lib/app/map/_lib/managers/precipitation.dart index ad279882f..03f008b57 100644 --- a/lib/app/map/_lib/managers/precipitation.dart +++ b/lib/app/map/_lib/managers/precipitation.dart @@ -1,8 +1,15 @@ +import 'package:flutter/material.dart'; + import 'package:collection/collection.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:provider/provider.dart'; + import 'package:dpip/api/exptech.dart'; import 'package:dpip/api/model/weather/rain.dart'; import 'package:dpip/app/map/_lib/manager.dart'; import 'package:dpip/app/map/_lib/utils.dart'; +import 'package:dpip/app/map/_widgets/map_legend.dart'; import 'package:dpip/core/i18n.dart'; import 'package:dpip/core/providers.dart'; import 'package:dpip/models/data.dart'; @@ -11,13 +18,10 @@ import 'package:dpip/utils/extensions/latlng.dart'; import 'package:dpip/utils/extensions/string.dart'; import 'package:dpip/utils/geojson.dart'; import 'package:dpip/utils/log.dart'; +import 'package:dpip/widgets/blurred_container.dart'; import 'package:dpip/widgets/map/map.dart'; import 'package:dpip/widgets/sheet/morphing_sheet.dart'; import 'package:dpip/widgets/ui/loading_icon.dart'; -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:material_symbols_icons/material_symbols_icons.dart'; -import 'package:provider/provider.dart'; class RainData { final double latitude; @@ -314,95 +318,51 @@ class PrecipitationMapLayerSheet extends StatelessWidget { _ => interval, }; - return MorphingSheet( - title: '降水'.i18n, - borderRadius: BorderRadius.circular(16), - elevation: 4, - partialBuilder: (context, controller, sheetController) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Selector>( - selector: (context, model) => model.precipitation, - builder: (context, precipitation, header) { - final times = precipitation.map((time) { - final t = time.toSimpleDateTimeString(context).split(' '); - return (date: t[0], time: t[1], value: time); - }); - final grouped = times.groupListsBy((time) => time.date).entries.toList(); - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - header!, - SizedBox( - height: kMinInteractiveDimension, - child: ValueListenableBuilder( - valueListenable: manager.currentPrecipitationInterval, - builder: (context, currentPrecipitationInterval, child) { - const intervals = PrecipitationMapLayerManager.precipitationIntervals; - - return ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: 16), - scrollDirection: Axis.horizontal, - physics: const AlwaysScrollableScrollPhysics(), - itemCount: intervals.length, - itemBuilder: (context, index) { - final interval = intervals[index]; - final isSelected = interval == currentPrecipitationInterval; - - return ValueListenableBuilder( - valueListenable: manager.isLoading, - builder: (context, isLoading, child) { - return FilterChip( - selected: isSelected, - showCheckmark: !isLoading, - label: Text(getIntervalLabel(interval)), - side: BorderSide( - color: isSelected ? context.colors.primary : context.colors.outlineVariant, - ), - avatar: isSelected && isLoading ? const LoadingIcon() : null, - onSelected: - isLoading - ? null - : (selected) { - if (!selected) return; - manager.setPrecipitationInterval(interval); - }, - ); - }, - ); - }, - separatorBuilder: (context, index) => const SizedBox(width: 8), - ); - }, - ), - ), - SizedBox( - height: kMinInteractiveDimension, - child: ValueListenableBuilder( - valueListenable: manager.currentPrecipitationTime, - builder: (context, currentPrecipitationTime, child) { - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), - scrollDirection: Axis.horizontal, - physics: const AlwaysScrollableScrollPhysics(), - itemCount: grouped.length, - itemBuilder: (context, index) { - final MapEntry(key: date, value: group) = grouped[index]; - - final children = [Text(date)]; - - for (final time in group) { - final isSelected = time.value == currentPrecipitationTime; - - children.add( - ValueListenableBuilder( + return Stack( + children: [ + MorphingSheet( + title: '降水'.i18n, + borderRadius: BorderRadius.circular(16), + elevation: 4, + partialBuilder: (context, controller, sheetController) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Selector>( + selector: (context, model) => model.precipitation, + builder: (context, precipitation, header) { + final times = precipitation.map((time) { + final t = time.toSimpleDateTimeString(context).split(' '); + return (date: t[0], time: t[1], value: time); + }); + final grouped = times.groupListsBy((time) => time.date).entries.toList(); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + header!, + SizedBox( + height: kMinInteractiveDimension, + child: ValueListenableBuilder( + valueListenable: manager.currentPrecipitationInterval, + builder: (context, currentPrecipitationInterval, child) { + const intervals = PrecipitationMapLayerManager.precipitationIntervals; + + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16), + scrollDirection: Axis.horizontal, + physics: const AlwaysScrollableScrollPhysics(), + itemCount: intervals.length, + itemBuilder: (context, index) { + final interval = intervals[index]; + final isSelected = interval == currentPrecipitationInterval; + + return ValueListenableBuilder( valueListenable: manager.isLoading, builder: (context, isLoading, child) { return FilterChip( selected: isSelected, showCheckmark: !isLoading, - label: Text(time.time), + label: Text(getIntervalLabel(interval)), side: BorderSide( color: isSelected ? context.colors.primary : context.colors.outlineVariant, ), @@ -412,43 +372,116 @@ class PrecipitationMapLayerSheet extends StatelessWidget { ? null : (selected) { if (!selected) return; - manager.setPrecipitationTime(time.value); + manager.setPrecipitationInterval(interval); }, ); }, - ), - ); - } - - children.add( - const Padding( - padding: EdgeInsets.only(right: 8), - child: VerticalDivider(width: 16, indent: 8, endIndent: 8), - ), + ); + }, + separatorBuilder: (context, index) => const SizedBox(width: 8), ); + }, + ), + ), + SizedBox( + height: kMinInteractiveDimension, + child: ValueListenableBuilder( + valueListenable: manager.currentPrecipitationTime, + builder: (context, currentPrecipitationTime, child) { + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + scrollDirection: Axis.horizontal, + physics: const AlwaysScrollableScrollPhysics(), + itemCount: grouped.length, + itemBuilder: (context, index) { + final MapEntry(key: date, value: group) = grouped[index]; + + final children = [Text(date)]; + + for (final time in group) { + final isSelected = time.value == currentPrecipitationTime; + + children.add( + ValueListenableBuilder( + valueListenable: manager.isLoading, + builder: (context, isLoading, child) { + return FilterChip( + selected: isSelected, + showCheckmark: !isLoading, + label: Text(time.time), + side: BorderSide( + color: isSelected ? context.colors.primary : context.colors.outlineVariant, + ), + avatar: isSelected && isLoading ? const LoadingIcon() : null, + onSelected: + isLoading + ? null + : (selected) { + if (!selected) return; + manager.setPrecipitationTime(time.value); + }, + ); + }, + ), + ); + } + + children.add( + const Padding( + padding: EdgeInsets.only(right: 8), + child: VerticalDivider(width: 16, indent: 8, endIndent: 8), + ), + ); - return Row(mainAxisSize: MainAxisSize.min, spacing: 8, children: children); + return Row(mainAxisSize: MainAxisSize.min, spacing: 8, children: children); + }, + ); }, - ); - }, - ), + ), + ), + ], + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + spacing: 8, + children: [ + const Icon(Symbols.water_drop_rounded, size: 24), + Text('降水'.i18n, style: context.textTheme.titleMedium), + ], ), - ], - ); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - spacing: 8, - children: [ - const Icon(Symbols.water_drop_rounded, size: 24), - Text('降水'.i18n, style: context.textTheme.titleMedium), + ), + ), + ); + }, + ), + Positioned( + top: 24 + 48 + 16, + left: 24, + child: SafeArea( + child: BlurredContainer( + elevation: 4, + shadowColor: context.colors.shadow.withValues(alpha: 0.4), + child: ColorLegend( + unit: 'mm', + items: [ + ColorLegendItem(color: const Color(0xffc2c2c2), value: 0), + ColorLegendItem(color: const Color(0xFF9CFCFF), value: 10), + ColorLegendItem(color: const Color(0xFF059BFF), value: 30), + ColorLegendItem(color: const Color(0xFF39FF03), value: 50), + ColorLegendItem(color: const Color(0xFFFFFB03), value: 100), + ColorLegendItem(color: const Color(0xFFFF9500), value: 200), + ColorLegendItem(color: const Color(0xFFFF0000), value: 300), + ColorLegendItem(color: const Color(0xFFFB00FF), value: 500), + ColorLegendItem(color: const Color(0xFF960099), value: 1000), + ColorLegendItem(color: const Color(0xFF000000), value: 2000), ], ), ), ), - ); - }, + ), + ], ); } } diff --git a/lib/app/map/_lib/managers/radar.dart b/lib/app/map/_lib/managers/radar.dart index a25a4e56a..0a1242439 100644 --- a/lib/app/map/_lib/managers/radar.dart +++ b/lib/app/map/_lib/managers/radar.dart @@ -1,9 +1,17 @@ import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:provider/provider.dart'; + import 'package:dpip/api/exptech.dart'; import 'package:dpip/app/map/_lib/manager.dart'; import 'package:dpip/app/map/_lib/utils.dart'; +import 'package:dpip/app/map/_widgets/map_legend.dart'; import 'package:dpip/core/i18n.dart'; import 'package:dpip/core/providers.dart'; import 'package:dpip/models/data.dart'; @@ -11,13 +19,10 @@ import 'package:dpip/utils/extensions/build_context.dart'; import 'package:dpip/utils/extensions/latlng.dart'; import 'package:dpip/utils/extensions/string.dart'; import 'package:dpip/utils/log.dart'; +import 'package:dpip/widgets/blurred_container.dart'; import 'package:dpip/widgets/map/map.dart'; import 'package:dpip/widgets/sheet/morphing_sheet.dart'; import 'package:dpip/widgets/ui/loading_icon.dart'; -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:material_symbols_icons/material_symbols_icons.dart'; -import 'package:provider/provider.dart'; class RadarMapLayerManager extends MapLayerManager { RadarMapLayerManager(super.context, super.controller, {this.getActiveLayerCount}); @@ -464,230 +469,264 @@ class RadarMapLayerSheet extends StatelessWidget { @override Widget build(BuildContext context) { - return MorphingSheet( - title: '雷達回波'.i18n, - borderRadius: BorderRadius.circular(16), - elevation: 4, - partialBuilder: (context, controller, sheetController) { - return Padding( - padding: const EdgeInsets.only(top: 4, bottom: 8), - child: Selector>( - selector: (context, model) => model.radar, - builder: (context, radar, child) { - final times = radar.map((time) { - final t = time.toSimpleDateTimeString(context).split(' '); - return (date: t[0], time: t[1], value: time); - }); - final grouped = times.groupListsBy((time) => time.date).entries.toList(); - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 16, right: 4), - child: SizedBox( - height: 48, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - spacing: 8, + return Stack( + children: [ + MorphingSheet( + title: '雷達回波'.i18n, + borderRadius: BorderRadius.circular(16), + elevation: 4, + partialBuilder: (context, controller, sheetController) { + return Padding( + padding: const EdgeInsets.only(top: 4, bottom: 8), + child: Selector>( + selector: (context, model) => model.radar, + builder: (context, radar, child) { + final times = radar.map((time) { + final t = time.toSimpleDateTimeString(context).split(' '); + return (date: t[0], time: t[1], value: time); + }); + final grouped = times.groupListsBy((time) => time.date).entries.toList(); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16, right: 4), + child: SizedBox( + height: 48, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Icon(Symbols.radar_rounded, size: 24, color: context.colors.onSurface), - Text( - '雷達回波'.i18n, - style: context.textTheme.titleMedium?.copyWith(color: context.colors.onSurface), + Row( + spacing: 8, + children: [ + Icon(Symbols.radar_rounded, size: 24, color: context.colors.onSurface), + Text( + '雷達回波'.i18n, + style: context.textTheme.titleMedium?.copyWith(color: context.colors.onSurface), + ), + AnimatedBuilder( + animation: Listenable.merge([ + manager.currentRadarTime, + manager.playStartTime, + manager.isPlaying, + ]), + builder: (context, child) { + final currentTime = manager.currentRadarTime.value; + + if (currentTime == null) return const SizedBox.shrink(); + + try { + final timeFormatted = currentTime.toSimpleDateTimeString(context); + final timeData = timeFormatted.split(' '); + final date = timeData.length > 1 ? timeData[0] : ''; + final time = timeData.length > 1 ? timeData[1] : timeData[0]; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: context.colors.surfaceContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: context.colors.outline), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Icon( + Icons.schedule_rounded, + size: 12, + color: context.colors.onSurfaceVariant, + ), + if (date.isNotEmpty) ...[ + Text( + date, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + height: 1, + ), + ), + Container( + width: 0.5, + height: 14, + margin: const EdgeInsets.symmetric(horizontal: 2), + color: context.colors.outline, + ), + ], + Text( + time, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurface, + fontWeight: FontWeight.bold, + height: 1, + ), + ), + ], + ), + ); + } catch (e) { + return const SizedBox.shrink(); + } + }, + ), + ], ), AnimatedBuilder( animation: Listenable.merge([ - manager.currentRadarTime, - manager.playStartTime, manager.isPlaying, + manager.playStartTime, + manager.currentRadarTime, ]), builder: (context, child) { - final currentTime = manager.currentRadarTime.value; - - if (currentTime == null) return const SizedBox.shrink(); - - try { - final timeFormatted = currentTime.toSimpleDateTimeString(context); - final timeData = timeFormatted.split(' '); - final date = timeData.length > 1 ? timeData[0] : ''; - final time = timeData.length > 1 ? timeData[1] : timeData[0]; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), - decoration: BoxDecoration( - color: context.colors.surfaceContainer.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: context.colors.outline), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 4, - children: [ - Icon( - Icons.schedule_rounded, - size: 12, - color: context.colors.onSurfaceVariant, - ), - if (date.isNotEmpty) ...[ - Text( - date, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - height: 1, - ), - ), - Container( - width: 0.5, - height: 14, - margin: const EdgeInsets.symmetric(horizontal: 2), - color: context.colors.outline, - ), - ], - Text( - time, - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurface, - fontWeight: FontWeight.bold, - height: 1, - ), - ), - ], - ), - ); - } catch (e) { + final isPlaying = manager.isPlaying.value; + final startTime = manager.playStartTime.value; + final canPlay = manager.canPlay; + + final shouldHide = startTime == null && !isPlaying; + + if (shouldHide) { return const SizedBox.shrink(); } + + return IconButton( + onPressed: canPlay || isPlaying ? manager.toggleAutoPlay : null, + icon: Icon( + isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded, + size: 24, + color: context.colors.primary, + ), + ); }, ), ], ), - AnimatedBuilder( - animation: Listenable.merge([ - manager.isPlaying, - manager.playStartTime, - manager.currentRadarTime, - ]), - builder: (context, child) { - final isPlaying = manager.isPlaying.value; - final startTime = manager.playStartTime.value; - final canPlay = manager.canPlay; - - final shouldHide = startTime == null && !isPlaying; - - if (shouldHide) { - return const SizedBox.shrink(); - } - - return IconButton( - onPressed: canPlay || isPlaying ? manager.toggleAutoPlay : null, - icon: Icon( - isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded, - size: 24, - color: context.colors.primary, - ), - ); - }, - ), - ], + ), ), - ), - ), - AnimatedBuilder( - animation: Listenable.merge([manager.playStartTime, manager.isPlaying]), - builder: (context, child) { - final startTime = manager.playStartTime.value; - final isPlaying = manager.isPlaying.value; - - if (isPlaying) { - return const SizedBox.shrink(); - } - - if (startTime == null && !isPlaying) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), - child: Text( - '長按設定播放起點'.i18n, - style: context.textTheme.bodySmall?.copyWith(color: context.colors.onSurfaceVariant), - ), - ); - } - - return Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), - child: Column( - children: [ - Row( - spacing: 8, + AnimatedBuilder( + animation: Listenable.merge([manager.playStartTime, manager.isPlaying]), + builder: (context, child) { + final startTime = manager.playStartTime.value; + final isPlaying = manager.isPlaying.value; + + if (isPlaying) { + return const SizedBox.shrink(); + } + + if (startTime == null && !isPlaying) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Text( + '長按設定播放起點'.i18n, + style: context.textTheme.bodySmall?.copyWith(color: context.colors.onSurfaceVariant), + ), + ); + } + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Column( children: [ - _LegendItem( - label: '目前時間'.i18n, - color: context.colors.primaryContainer, - borderColor: context.colors.primary, - ), - _LegendItem( - label: '播放起點'.i18n, - color: context.colors.tertiaryContainer, - borderColor: context.colors.tertiary, + Row( + spacing: 8, + children: [ + _LegendItem( + label: '目前時間'.i18n, + color: context.colors.primaryContainer, + borderColor: context.colors.primary, + ), + _LegendItem( + label: '播放起點'.i18n, + color: context.colors.tertiaryContainer, + borderColor: context.colors.tertiary, + ), + ], ), ], ), - ], - ), - ); - }, - ), - ValueListenableBuilder( - valueListenable: manager.isPlaying, - builder: (context, isPlaying, child) { - if (isPlaying) { - return Container( - height: 32, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: AnimatedBuilder( - animation: Listenable.merge([ - manager.currentRadarTime, - manager.playStartTime, - manager.playEndTime, - ]), - builder: (context, child) { - return _RadarProgressBar(manager: manager); - }, - ), - ); - } - - return SizedBox( - height: kMinInteractiveDimension, - child: ValueListenableBuilder( - valueListenable: manager.currentRadarTime, - builder: (context, currentTime, child) { - return ValueListenableBuilder( - valueListenable: manager.playStartTime, - builder: (context, startTime, child) { - return _AutoScrollingTimeList( - grouped: grouped, - currentTime: currentTime, - startTime: startTime, - manager: manager, - shouldFocusOnShow: true, + ); + }, + ), + ValueListenableBuilder( + valueListenable: manager.isPlaying, + builder: (context, isPlaying, child) { + if (isPlaying) { + return Container( + height: 32, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: AnimatedBuilder( + animation: Listenable.merge([ + manager.currentRadarTime, + manager.playStartTime, + manager.playEndTime, + ]), + builder: (context, child) { + return _RadarProgressBar(manager: manager); + }, + ), + ); + } + + return SizedBox( + height: kMinInteractiveDimension, + child: ValueListenableBuilder( + valueListenable: manager.currentRadarTime, + builder: (context, currentTime, child) { + return ValueListenableBuilder( + valueListenable: manager.playStartTime, + builder: (context, startTime, child) { + return _AutoScrollingTimeList( + grouped: grouped, + currentTime: currentTime, + startTime: startTime, + manager: manager, + shouldFocusOnShow: true, + ); + }, ); }, - ); - }, - ), - ); - }, - ), + ), + ); + }, + ), + ], + ); + }, + ), + ); + }, + ), + Positioned( + top: 24 + 48 + 16, + left: 24, + child: SafeArea( + child: BlurredContainer( + elevation: 4, + shadowColor: context.colors.shadow.withValues(alpha: 0.4), + child: ColorLegend( + reverse: true, + items: [ + ColorLegendItem(color: const Color(0xff00ffff), value: 0), + ColorLegendItem(color: const Color(0xff00a3ff), value: 5), + ColorLegendItem(color: const Color(0xff005bff), value: 10), + ColorLegendItem(color: const Color(0xff0000ff), value: 15, blendTail: false), + ColorLegendItem(color: const Color(0xff00ff00), value: 16, hidden: true), + ColorLegendItem(color: const Color(0xff00d300), value: 20), + ColorLegendItem(color: const Color(0xff00a000), value: 25), + ColorLegendItem(color: const Color(0xffccea00), value: 30), + ColorLegendItem(color: const Color(0xffffd300), value: 35), + ColorLegendItem(color: const Color(0xffff8800), value: 40), + ColorLegendItem(color: const Color(0xffff1800), value: 45), + ColorLegendItem(color: const Color(0xffd30000), value: 50), + ColorLegendItem(color: const Color(0xffa00000), value: 55), + ColorLegendItem(color: const Color(0xffea00cc), value: 60), + ColorLegendItem(color: const Color(0xff9600ff), value: 65), ], - ); - }, + ), + ), ), - ); - }, + ), + ], ); } } diff --git a/lib/app/map/_lib/managers/temperature.dart b/lib/app/map/_lib/managers/temperature.dart index 6a967fdfc..9b108b23f 100644 --- a/lib/app/map/_lib/managers/temperature.dart +++ b/lib/app/map/_lib/managers/temperature.dart @@ -1,23 +1,29 @@ +import 'package:flutter/material.dart'; + import 'package:collection/collection.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:provider/provider.dart'; + import 'package:dpip/api/exptech.dart'; import 'package:dpip/api/model/weather/weather.dart'; import 'package:dpip/app/map/_lib/manager.dart'; import 'package:dpip/app/map/_lib/utils.dart'; +import 'package:dpip/app/map/_widgets/map_legend.dart'; import 'package:dpip/core/i18n.dart'; import 'package:dpip/core/providers.dart'; import 'package:dpip/models/data.dart'; +import 'package:dpip/models/settings/ui.dart'; import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/utils/extensions/int.dart'; import 'package:dpip/utils/extensions/latlng.dart'; import 'package:dpip/utils/extensions/string.dart'; import 'package:dpip/utils/geojson.dart'; import 'package:dpip/utils/log.dart'; +import 'package:dpip/widgets/blurred_container.dart'; import 'package:dpip/widgets/map/map.dart'; import 'package:dpip/widgets/sheet/morphing_sheet.dart'; import 'package:dpip/widgets/ui/loading_icon.dart'; -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:material_symbols_icons/material_symbols_icons.dart'; -import 'package:provider/provider.dart'; class TemperatureData { final double latitude; @@ -244,98 +250,131 @@ class TemperatureMapLayerSheet extends StatelessWidget { @override Widget build(BuildContext context) { - return MorphingSheet( - title: '氣溫'.i18n, - borderRadius: BorderRadius.circular(16), - elevation: 4, - partialBuilder: (context, controller, sheetController) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Selector>( - selector: (context, model) => model.temperature, - builder: (context, temperature, header) { - final times = temperature.map((time) { - final t = time.toSimpleDateTimeString(context).split(' '); - return (date: t[0], time: t[1], value: time); - }); - final grouped = times.groupListsBy((time) => time.date).entries.toList(); - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - header!, - SizedBox( - height: kMinInteractiveDimension, - child: ValueListenableBuilder( - valueListenable: manager.currentTemperatureTime, - builder: (context, currentTemperatureTime, child) { - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), - scrollDirection: Axis.horizontal, - physics: const AlwaysScrollableScrollPhysics(), - itemCount: grouped.length, - itemBuilder: (context, index) { - final MapEntry(key: date, value: group) = grouped[index]; - - final children = [Text(date)]; - - for (final time in group) { - final isSelected = time.value == currentTemperatureTime; - - children.add( - ValueListenableBuilder( - valueListenable: manager.isLoading, - builder: (context, isLoading, child) { - return FilterChip( - selected: isSelected, - showCheckmark: !isLoading, - label: Text(time.time), - side: BorderSide( - color: isSelected ? context.colors.primary : context.colors.outlineVariant, - ), - avatar: isSelected && isLoading ? const LoadingIcon() : null, - onSelected: - isLoading - ? null - : (selected) { - if (!selected) return; - manager.setTemperatureTime(time.value); - }, - ); - }, - ), - ); - } - - children.add( - const Padding( - padding: EdgeInsets.only(right: 8), - child: VerticalDivider(width: 16, indent: 8, endIndent: 8), - ), + return Stack( + children: [ + MorphingSheet( + title: '氣溫'.i18n, + borderRadius: BorderRadius.circular(16), + elevation: 4, + partialBuilder: (context, controller, sheetController) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Selector>( + selector: (context, model) => model.temperature, + builder: (context, temperature, header) { + final times = temperature.map((time) { + final t = time.toSimpleDateTimeString(context).split(' '); + return (date: t[0], time: t[1], value: time); + }); + final grouped = times.groupListsBy((time) => time.date).entries.toList(); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + header!, + SizedBox( + height: kMinInteractiveDimension, + child: ValueListenableBuilder( + valueListenable: manager.currentTemperatureTime, + builder: (context, currentTemperatureTime, child) { + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + scrollDirection: Axis.horizontal, + physics: const AlwaysScrollableScrollPhysics(), + itemCount: grouped.length, + itemBuilder: (context, index) { + final MapEntry(key: date, value: group) = grouped[index]; + + final children = [Text(date)]; + + for (final time in group) { + final isSelected = time.value == currentTemperatureTime; + + children.add( + ValueListenableBuilder( + valueListenable: manager.isLoading, + builder: (context, isLoading, child) { + return FilterChip( + selected: isSelected, + showCheckmark: !isLoading, + label: Text(time.time), + side: BorderSide( + color: isSelected ? context.colors.primary : context.colors.outlineVariant, + ), + avatar: isSelected && isLoading ? const LoadingIcon() : null, + onSelected: + isLoading + ? null + : (selected) { + if (!selected) return; + manager.setTemperatureTime(time.value); + }, + ); + }, + ), + ); + } + + children.add( + const Padding( + padding: EdgeInsets.only(right: 8), + child: VerticalDivider(width: 16, indent: 8, endIndent: 8), + ), + ); + + return Row(mainAxisSize: MainAxisSize.min, spacing: 8, children: children); + }, ); - - return Row(mainAxisSize: MainAxisSize.min, spacing: 8, children: children); }, - ); - }, - ), + ), + ), + ], + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + spacing: 8, + children: [ + const Icon(Symbols.thermostat_rounded, size: 24), + Text('氣溫'.i18n, style: context.textTheme.titleMedium), + ], ), - ], - ); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - spacing: 8, - children: [ - const Icon(Symbols.thermostat_rounded, size: 24), - Text('氣溫'.i18n, style: context.textTheme.titleMedium), - ], + ), + ), + ); + }, + ), + Positioned( + top: 24 + 48 + 16, + left: 24, + child: SafeArea( + child: BlurredContainer( + elevation: 4, + shadowColor: context.colors.shadow.withValues(alpha: 0.4), + child: Selector( + selector: (context, model) => model.useFahrenheit, + builder: (context, useFahrenheit, child) { + return ColorLegend( + reverse: true, + unit: useFahrenheit ? '℉' : '℃', + appendUnit: true, + items: [ + ColorLegendItem(color: const Color(0xff4d4e51), value: useFahrenheit ? -20.asFahrenheit : -20), + ColorLegendItem(color: const Color(0xff0000ff), value: useFahrenheit ? -10.asFahrenheit : -10), + ColorLegendItem(color: const Color(0xff6495ED), value: useFahrenheit ? 0.asFahrenheit : 0), + ColorLegendItem(color: const Color(0xff95d07e), value: useFahrenheit ? 10.asFahrenheit : 10), + ColorLegendItem(color: const Color(0xfff6e78b), value: useFahrenheit ? 20.asFahrenheit : 20), + ColorLegendItem(color: const Color(0xffff4500), value: useFahrenheit ? 30.asFahrenheit : 30), + ColorLegendItem(color: const Color(0xff8B0000), value: useFahrenheit ? 40.asFahrenheit : 40), + ], + ); + }, ), ), ), - ); - }, + ), + ], ); } } diff --git a/lib/app/map/_lib/managers/wind.dart b/lib/app/map/_lib/managers/wind.dart index 3c6452c83..355a67534 100644 --- a/lib/app/map/_lib/managers/wind.dart +++ b/lib/app/map/_lib/managers/wind.dart @@ -1,8 +1,15 @@ +import 'package:flutter/material.dart'; + import 'package:collection/collection.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:provider/provider.dart'; + import 'package:dpip/api/exptech.dart'; import 'package:dpip/api/model/weather/weather.dart'; import 'package:dpip/app/map/_lib/manager.dart'; import 'package:dpip/app/map/_lib/utils.dart'; +import 'package:dpip/app/map/_widgets/map_legend.dart'; import 'package:dpip/core/i18n.dart'; import 'package:dpip/core/providers.dart'; import 'package:dpip/models/data.dart'; @@ -11,13 +18,10 @@ import 'package:dpip/utils/extensions/build_context.dart'; import 'package:dpip/utils/extensions/string.dart'; import 'package:dpip/utils/geojson.dart'; import 'package:dpip/utils/log.dart'; +import 'package:dpip/widgets/blurred_container.dart'; import 'package:dpip/widgets/map/map.dart'; import 'package:dpip/widgets/sheet/morphing_sheet.dart'; import 'package:dpip/widgets/ui/loading_icon.dart'; -import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:material_symbols_icons/material_symbols_icons.dart'; -import 'package:provider/provider.dart'; class WindData { final double latitude; @@ -195,97 +199,136 @@ class WindMapLayerSheet extends StatelessWidget { @override Widget build(BuildContext context) { - return MorphingSheet( - title: '風向/風速'.i18n, - borderRadius: BorderRadius.circular(16), - elevation: 4, - partialBuilder: (context, controller, sheetController) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Selector>( - selector: (context, model) => model.wind, - builder: (context, wind, child) { - final times = wind.map((time) { - final t = time.toSimpleDateTimeString(context).split(' '); - return (date: t[0], time: t[1], value: time); - }); - final grouped = times.groupListsBy((time) => time.date).entries.toList(); - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - spacing: 8, - children: [ - const Icon(Symbols.wind_power_rounded, size: 24), - Text('風向/風速'.i18n, style: context.textTheme.titleMedium), - ], - ), - ), - SizedBox( - height: kMinInteractiveDimension, - child: ValueListenableBuilder( - valueListenable: manager.currentWindTime, - builder: (context, currentWindTime, child) { - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), - scrollDirection: Axis.horizontal, - physics: const AlwaysScrollableScrollPhysics(), - itemCount: grouped.length, - itemBuilder: (context, index) { - final MapEntry(key: date, value: group) = grouped[index]; - - final children = [Text(date)]; - - for (final time in group) { - final isSelected = time.value == currentWindTime; - - children.add( - ValueListenableBuilder( - valueListenable: manager.isLoading, - builder: (context, isLoading, child) { - return FilterChip( - selected: isSelected, - showCheckmark: !isLoading, - label: Text(time.time), - side: BorderSide( - color: isSelected ? context.colors.primary : context.colors.outlineVariant, - ), - avatar: isSelected && isLoading ? const LoadingIcon() : null, - onSelected: - isLoading - ? null - : (selected) { - if (!selected) return; - manager.setWindTime(time.value); - }, - ); - }, - ), - ); - } - - children.add( - const Padding( - padding: EdgeInsets.only(right: 8), - child: VerticalDivider(width: 16, indent: 8, endIndent: 8), - ), + return Stack( + children: [ + MorphingSheet( + title: '風向/風速'.i18n, + borderRadius: BorderRadius.circular(16), + elevation: 4, + partialBuilder: (context, controller, sheetController) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Selector>( + selector: (context, model) => model.wind, + builder: (context, wind, child) { + final times = wind.map((time) { + final t = time.toSimpleDateTimeString(context).split(' '); + return (date: t[0], time: t[1], value: time); + }); + final grouped = times.groupListsBy((time) => time.date).entries.toList(); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + spacing: 8, + children: [ + const Icon(Symbols.wind_power_rounded, size: 24), + Text('風向/風速'.i18n, style: context.textTheme.titleMedium), + ], + ), + ), + SizedBox( + height: kMinInteractiveDimension, + child: ValueListenableBuilder( + valueListenable: manager.currentWindTime, + builder: (context, currentWindTime, child) { + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + scrollDirection: Axis.horizontal, + physics: const AlwaysScrollableScrollPhysics(), + itemCount: grouped.length, + itemBuilder: (context, index) { + final MapEntry(key: date, value: group) = grouped[index]; + + final children = [Text(date)]; + + for (final time in group) { + final isSelected = time.value == currentWindTime; + + children.add( + ValueListenableBuilder( + valueListenable: manager.isLoading, + builder: (context, isLoading, child) { + return FilterChip( + selected: isSelected, + showCheckmark: !isLoading, + label: Text(time.time), + side: BorderSide( + color: isSelected ? context.colors.primary : context.colors.outlineVariant, + ), + avatar: isSelected && isLoading ? const LoadingIcon() : null, + onSelected: + isLoading + ? null + : (selected) { + if (!selected) return; + manager.setWindTime(time.value); + }, + ); + }, + ), + ); + } + + children.add( + const Padding( + padding: EdgeInsets.only(right: 8), + child: VerticalDivider(width: 16, indent: 8, endIndent: 8), + ), + ); + + return Row(mainAxisSize: MainAxisSize.min, spacing: 8, children: children); + }, ); - - return Row(mainAxisSize: MainAxisSize.min, spacing: 8, children: children); }, - ); - }, - ), + ), + ), + ], + ); + }, + ), + ); + }, + ), + Positioned( + top: 24 + 48 + 16, + left: 24, + child: SafeArea( + child: BlurredContainer( + elevation: 4, + shadowColor: context.colors.shadow.withValues(alpha: 0.4), + child: Legend( + unit: 'm/s', + items: [ + LegendItem( + icon: const OutlinedIcon(Symbols.navigation_rounded, fill: Color(0xffffffff), size: 20), + label: '0.1 - 3.3', + ), + LegendItem( + icon: const OutlinedIcon(Symbols.navigation_rounded, fill: Color(0xff03fff0), size: 20), + label: '3.4 - 7.9', + ), + LegendItem( + icon: const OutlinedIcon(Symbols.navigation_rounded, fill: Color(0xff0385ff), size: 20), + label: '8.0 - 13.8', + ), + LegendItem( + icon: const OutlinedIcon(Symbols.navigation_rounded, fill: Color(0xff8000ff), size: 20), + label: '13.9 - 32.6', + ), + LegendItem( + icon: const OutlinedIcon(Symbols.navigation_rounded, fill: Color(0xffff006b), size: 20), + label: '≥ 32.7', ), ], - ); - }, + ), + ), ), - ); - }, + ), + ], ); } } diff --git a/lib/app/map/_widgets/map_legend.dart b/lib/app/map/_widgets/map_legend.dart new file mode 100644 index 000000000..5bbf23df2 --- /dev/null +++ b/lib/app/map/_widgets/map_legend.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:i18n_extension/i18n_extension.dart'; + +import 'package:dpip/core/i18n.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/widgets/layout.dart'; + +class ColorLegendItem { + final Color color; + final String? label; + final num value; + final bool blendHead; + final bool blendTail; + final bool hidden; + + ColorLegendItem({ + required this.color, + required this.value, + this.label, + this.blendHead = true, + this.blendTail = true, + this.hidden = false, + }); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other is ColorLegendItem) { + return color == other.color && + value == other.value && + label == other.label && + blendHead == other.blendHead && + blendTail == other.blendTail && + hidden == other.hidden; + } + + return false; + } + + @override + int get hashCode => Object.hash(color, value, blendHead, blendTail, hidden); +} + +class ColorLegend extends StatelessWidget { + final List items; + final bool reverse; + final String? unit; + final bool appendUnit; + + const ColorLegend({super.key, required this.items, this.reverse = false, this.appendUnit = false, this.unit}); + + @override + Widget build(BuildContext context) { + final items = reverse ? this.items.reversed.toList() : this.items; + final visibleItems = items.where((item) => !item.hidden).toList(); + + final children = + items.mapIndexed((index, item) { + if (item.hidden) return const SizedBox.shrink(); + + final visibleIndex = visibleItems.indexOf(item); + + final previous = index == 0 ? null : items.elementAtOrNull(index - 1); + final next = items.elementAtOrNull(index + 1); + + final headColor = + item.blendHead + ? (previous != null + ? Color.alphaBlend(item.color.withValues(alpha: 0.5), previous.color) + : item.color) + : item.color; + final tailColor = + item.blendTail + ? (next != null ? Color.alphaBlend(item.color.withValues(alpha: 0.5), next.color) : item.color) + : item.color; + + return IntrinsicHeight( + child: Layout.row.stretch[6]( + children: [ + if (!item.blendHead && !item.blendTail) + ColoredBox(color: item.color) + else + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [headColor, item.color, tailColor], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + borderRadius: + visibleIndex == 0 + ? const BorderRadius.vertical(top: Radius.circular(8)) + : (visibleIndex + 1) == visibleItems.length + ? const BorderRadius.vertical(bottom: Radius.circular(8)) + : null, + ), + width: 8, + ), + RichText( + text: TextSpan( + children: [ + TextSpan(text: item.label ?? '${item.value}'), + if (unit != null && appendUnit) + TextSpan(text: ' $unit', style: TextStyle(color: context.colors.outline)), + ], + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ], + ), + ); + }).toList(); + + return Layout.col.left[2]( + children: [ + Layout.col.left(children: children), + if (unit != null && !appendUnit) + Text( + '單位:{unit}'.i18n.args({'unit': unit!}), + style: context.textTheme.labelSmall?.copyWith(color: context.colors.onSurfaceVariant), + ), + ], + ); + } +} + +class LegendItem { + final Widget icon; + final String label; + + LegendItem({required this.icon, required this.label}); +} + +class Legend extends StatelessWidget { + final List items; + final bool reverse; + final String? unit; + final bool appendUnit; + + const Legend({super.key, required this.items, this.reverse = false, this.appendUnit = false, this.unit}); + + @override + Widget build(BuildContext context) { + final items = reverse ? this.items.reversed.toList() : this.items; + + final children = + items.map((item) { + return Layout.row.left[2]( + children: [ + item.icon, + RichText( + text: TextSpan( + children: [ + TextSpan(text: item.label), + if (unit != null && appendUnit) + TextSpan(text: ' $unit', style: TextStyle(color: context.colors.outline)), + ], + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ], + ); + }).toList(); + + return Layout.col.left[2]( + children: [ + Layout.col.left(children: children), + if (unit != null && !appendUnit) + Text( + '單位:{unit}'.i18n.args({'unit': unit!}), + style: context.textTheme.labelSmall?.copyWith(color: context.colors.onSurfaceVariant), + ), + ], + ); + } +} + +class OutlinedIcon extends StatelessWidget { + final IconData icon; + final Color? fill; + final Color? stroke; + final double? size; + + const OutlinedIcon(this.icon, {super.key, this.fill, this.stroke, this.size}); + + @override + Widget build(BuildContext context) { + return Stack( + children: [Icon(icon, fill: 1, color: fill, size: size), Icon(icon, fill: 0, color: stroke, size: size)], + ); + } +} diff --git a/lib/utils/extensions/int.dart b/lib/utils/extensions/int.dart index 416d6d29a..8aa717177 100644 --- a/lib/utils/extensions/int.dart +++ b/lib/utils/extensions/int.dart @@ -23,6 +23,8 @@ extension CommonContext on int { ][this]; String get asIntensityDisplayLabel => ['0', '1', '2', '3', '4', '5⁻', '5⁺', '6⁻', '6⁺', '7'][this]; TZDateTime get asTZDateTime => parseDateTime(this); + int get asFahrenheit => (this * 9 / 5 + 32).round(); + String toSimpleDateTimeString(BuildContext context) => asTZDateTime.toSimpleDateTimeString(context); String toLocaleFullDateString(BuildContext context) => asTZDateTime.toLocaleFullDateString(context); String toLocaleDateTimeString(BuildContext context) => asTZDateTime.toLocaleDateTimeString(context); diff --git a/lib/utils/radar_color.dart b/lib/utils/radar_color.dart index 24d4cd062..e03b7a0c1 100644 --- a/lib/utils/radar_color.dart +++ b/lib/utils/radar_color.dart @@ -1,68 +1,68 @@ final List dBZColors = [ - '00ffff', - '00ecff', - '00daff', - '00c8ff', - '00b6ff', - '00a3ff', - '0091ff', - '007fff', - '006dff', - '005bff', - '0048ff', - '0036ff', - '0024ff', - '0012ff', - '0000ff', - '00ff00', - '00f400', - '00e900', - '00de00', - '00d300', + '00ffff', // 0 + '00ecff', // + '00daff', // + '00c8ff', // + '00b6ff', // + '00a3ff', // 5 + '0091ff', // + '007fff', // + '006dff', // + '005bff', // 10 + '0048ff', // + '0036ff', // + '0024ff', // + '0012ff', // + '0000ff', // 15 + '00ff00', // 16 + '00f400', // 17 + '00e900', // 18 + '00de00', // + '00d300', // 20 '00c800', '00be00', '00b400', '00aa00', - '00a000', + '00a000', // 25 '009600', '33ab00', '66c000', '99d500', - 'ccea00', + 'ccea00', // 30 'ffff00', 'fff400', 'ffe900', 'ffde00', - 'ffd300', + 'ffd300', // 35 'ffc800', 'ffb800', 'ffa800', 'ff9800', - 'ff8800', + 'ff8800', // 40 'ff7800', 'ff6000', 'ff4800', 'ff3000', - 'ff1800', + 'ff1800', // 45 'ff0000', 'f40000', 'e90000', 'de0000', - 'd30000', + 'd30000', // 50 'c80000', 'be0000', 'b40000', 'aa0000', - 'a00000', + 'a00000', // 55 '960000', 'ab0033', 'c00066', 'd50099', - 'ea00cc', + 'ea00cc', // 60 'ff00ff', 'ea00ff', 'd500ff', 'c000ff', - 'ab00ff', + 'ab00ff', // 65 '9600ff', ]; diff --git a/lib/widgets/blurred_container.dart b/lib/widgets/blurred_container.dart new file mode 100644 index 000000000..8e6ad5c42 --- /dev/null +++ b/lib/widgets/blurred_container.dart @@ -0,0 +1,40 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +import 'package:dpip/utils/extensions/build_context.dart'; + +class BlurredContainer extends StatelessWidget { + final Widget child; + final EdgeInsets padding; + final Color? shadowColor; + final double elevation; + final double sigma; + + const BlurredContainer({ + super.key, + required this.child, + this.padding = const EdgeInsets.all(8), + this.shadowColor, + this.elevation = 0, + this.sigma = 16, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: context.colors.surfaceContainer.withValues(alpha: 0.6), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: context.colors.outline.withValues(alpha: 0.2)), + ), + elevation: elevation, + shadowColor: shadowColor ?? context.colors.shadow.withValues(alpha: 0.4), + clipBehavior: Clip.antiAlias, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + child: Padding(padding: padding, child: child), + ), + ); + } +} From 4c57766208b175cf6a6aba550ac5a84caf801c4b Mon Sep 17 00:00:00 2001 From: Kamiya Date: Wed, 6 Aug 2025 12:17:54 +0800 Subject: [PATCH 2/2] feat: radar unit --- lib/app/map/_lib/managers/radar.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/app/map/_lib/managers/radar.dart b/lib/app/map/_lib/managers/radar.dart index 0a1242439..0a217fe42 100644 --- a/lib/app/map/_lib/managers/radar.dart +++ b/lib/app/map/_lib/managers/radar.dart @@ -705,6 +705,7 @@ class RadarMapLayerSheet extends StatelessWidget { shadowColor: context.colors.shadow.withValues(alpha: 0.4), child: ColorLegend( reverse: true, + unit: 'dBZ', items: [ ColorLegendItem(color: const Color(0xff00ffff), value: 0), ColorLegendItem(color: const Color(0xff00a3ff), value: 5),