diff --git a/android/app/build.gradle b/android/app/build.gradle index efb51851e..cbbefd1a7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -49,7 +49,7 @@ android { applicationId 'com.exptech.dpip' minSdkVersion 26 targetSdkVersion 36 - versionCode 300104004 + versionCode 300104006 versionName flutterVersionName multiDexEnabled true resConfigs "en", "ko", "zh-rTW", "ja", "zh-rCN" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f95d1e5a3..1431169a6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -39,6 +39,7 @@ android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize" + android:screenOrientation="portrait" android:showWhenLocked="true" android:turnScreenOn="true"> + + diff --git a/lib/app/home/_widgets/date_timeline_item.dart b/lib/app/home/_widgets/date_timeline_item.dart index c5a50baae..5df5ad1ef 100644 --- a/lib/app/home/_widgets/date_timeline_item.dart +++ b/lib/app/home/_widgets/date_timeline_item.dart @@ -1,5 +1,6 @@ import 'package:dpip/app/home/_widgets/mode_toggle_button.dart'; import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/widgets/responsive/responsive_container.dart'; import 'package:flutter/material.dart'; class DateTimelineItem extends StatelessWidget { @@ -71,82 +72,84 @@ class DateTimelineItem extends StatelessWidget { @override Widget build(BuildContext context) { - return IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Stack( - alignment: Alignment.centerLeft, - children: [ - Positioned( - left: 0, - top: first ? 21 : 0, - bottom: last ? null : 0, - height: last ? 21 : null, - child: Stack( - alignment: Alignment.center, - children: [ - Positioned(top: 0, bottom: 0, width: 1, child: Container(color: context.colors.outlineVariant)), - SizedBox( - width: 42, - child: Container( - height: 8, - width: 8, - decoration: BoxDecoration(shape: BoxShape.circle, color: context.colors.outlineVariant), + return ResponsiveContainer( + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Stack( + alignment: Alignment.centerLeft, + children: [ + Positioned( + left: 0, + top: first ? 21 : 0, + bottom: last ? null : 0, + height: last ? 21 : null, + child: Stack( + alignment: Alignment.center, + children: [ + Positioned(top: 0, bottom: 0, width: 1, child: Container(color: context.colors.outlineVariant)), + SizedBox( + width: 42, + child: Container( + height: 8, + width: 8, + decoration: BoxDecoration(shape: BoxShape.circle, color: context.colors.outlineVariant), + ), ), - ), - ], + ], + ), ), - ), - Padding( - padding: const EdgeInsets.only(top: 16, bottom: 8), - child: InkWell( - onTap: mode != null && onModeChanged != null ? () => _showModeMenu(context) : null, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: context.colors.secondaryContainer, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 6, - children: [ - if (mode != null) ...[ - Icon(mode!.icon, size: 16, color: context.colors.onSecondaryContainer), + Padding( + padding: const EdgeInsets.only(top: 16, bottom: 8), + child: InkWell( + onTap: mode != null && onModeChanged != null ? () => _showModeMenu(context) : null, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: context.colors.secondaryContainer, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 6, + children: [ + if (mode != null) ...[ + Icon(mode!.icon, size: 16, color: context.colors.onSecondaryContainer), + Text( + mode!.label, + style: context.theme.textTheme.labelMedium?.copyWith( + height: 1, + color: context.colors.onSecondaryContainer, + fontWeight: FontWeight.bold, + ), + ), + Container( + width: 1, + height: 12, + color: context.colors.onSecondaryContainer.withValues(alpha: 0.3), + ), + ], Text( - mode!.label, - style: context.theme.textTheme.labelMedium?.copyWith( + date, + style: context.theme.textTheme.labelLarge?.copyWith( height: 1, color: context.colors.onSecondaryContainer, - fontWeight: FontWeight.bold, ), ), - Container( - width: 1, - height: 12, - color: context.colors.onSecondaryContainer.withValues(alpha: 0.3), - ), ], - Text( - date, - style: context.theme.textTheme.labelLarge?.copyWith( - height: 1, - color: context.colors.onSecondaryContainer, - ), - ), - ], + ), ), ), ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/app/home/_widgets/eew_card.dart b/lib/app/home/_widgets/eew_card.dart index dfeb8fbef..0c616dd77 100644 --- a/lib/app/home/_widgets/eew_card.dart +++ b/lib/app/home/_widgets/eew_card.dart @@ -9,6 +9,7 @@ import 'package:dpip/core/providers.dart'; import 'package:dpip/models/settings/location.dart'; import 'package:dpip/utils/extensions/build_context.dart'; import 'package:dpip/utils/extensions/number.dart'; +import 'package:dpip/widgets/responsive/responsive_container.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:i18n_extension/i18n_extension.dart'; @@ -65,202 +66,205 @@ class _EewCardState extends State { @override Widget build(BuildContext context) { - return Stack( - children: [ - IgnorePointer( - child: Container( - decoration: BoxDecoration( - color: context.colors.errorContainer, - border: Border.all(color: context.colors.error, width: 2), - borderRadius: BorderRadius.circular(16), - ), - padding: const EdgeInsets.all(12), - clipBehavior: Clip.antiAlias, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - spacing: 8, - children: [ - Container( - decoration: BoxDecoration( - color: context.colors.error, - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.fromLTRB(8, 6, 12, 6), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 4, - children: [ - Icon(Symbols.crisis_alert_rounded, color: context.colors.onError, weight: 700, size: 22), - Text( - 'EEW'.i18n, - style: context.texts.labelLarge!.copyWith( - color: context.colors.onError, - fontWeight: FontWeight.bold, + return ResponsiveContainer( + maxWidth: 720, + child: Stack( + children: [ + IgnorePointer( + child: Container( + decoration: BoxDecoration( + color: context.colors.errorContainer, + border: Border.all(color: context.colors.error, width: 2), + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.all(12), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + spacing: 8, + children: [ + Container( + decoration: BoxDecoration( + color: context.colors.error, + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.fromLTRB(8, 6, 12, 6), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Icon(Symbols.crisis_alert_rounded, color: context.colors.onError, weight: 700, size: 22), + Text( + 'EEW'.i18n, + style: context.texts.labelLarge!.copyWith( + color: context.colors.onError, + fontWeight: FontWeight.bold, + ), ), - ), - ], + ], + ), ), - ), - Text( - '第 {serial} 報'.i18n.args({'serial': widget.data.serial}), - style: context.texts.bodyLarge!.copyWith(color: context.colors.onErrorContainer), - ), - ], + Text( + '第 {serial} 報'.i18n.args({'serial': widget.data.serial}), + style: context.texts.bodyLarge!.copyWith(color: context.colors.onErrorContainer), + ), + ], + ), + Icon(Symbols.chevron_right_rounded, color: context.colors.onErrorContainer, size: 24), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 8), + child: StyledText( + text: localIntensity != null + ? '{time} 左右,{location}附近發生有感地震,預估規模 M{magnitude}、所在地最大震度{intensity}。' + .i18n + .args({ + 'time': widget.data.info.time.toSimpleDateTimeString(), + 'location': widget.data.info.location, + 'magnitude': widget.data.info.magnitude.toStringAsFixed(1), + 'intensity': localIntensity!.asIntensityLabel, + }) + : '{time} 左右,{location}附近發生有感地震,預估規模 M{magnitude}、深度{depth}公里。' + .i18n + .args({ + 'time': widget.data.info.time.toSimpleDateTimeString(), + 'location': widget.data.info.location, + 'magnitude': widget.data.info.magnitude.toStringAsFixed(1), + 'depth': widget.data.info.depth.toStringAsFixed(1), + }), + style: context.texts.bodyLarge!.copyWith(color: context.colors.onErrorContainer), + tags: {'bold': StyledTextTag(style: const TextStyle(fontWeight: FontWeight.bold))}, ), - Icon(Symbols.chevron_right_rounded, color: context.colors.onErrorContainer, size: 24), - ], - ), - Padding( - padding: const EdgeInsets.only(top: 8), - child: StyledText( - text: localIntensity != null - ? '{time} 左右,{location}附近發生有感地震,預估規模 M{magnitude}、所在地最大震度{intensity}。' - .i18n - .args({ - 'time': widget.data.info.time.toSimpleDateTimeString(), - 'location': widget.data.info.location, - 'magnitude': widget.data.info.magnitude.toStringAsFixed(1), - 'intensity': localIntensity!.asIntensityLabel, - }) - : '{time} 左右,{location}附近發生有感地震,預估規模 M{magnitude}、深度{depth}公里。' - .i18n - .args({ - 'time': widget.data.info.time.toSimpleDateTimeString(), - 'location': widget.data.info.location, - 'magnitude': widget.data.info.magnitude.toStringAsFixed(1), - 'depth': widget.data.info.depth.toStringAsFixed(1), - }), - style: context.texts.bodyLarge!.copyWith(color: context.colors.onErrorContainer), - tags: {'bold': StyledTextTag(style: const TextStyle(fontWeight: FontWeight.bold))}, ), - ), - Selector( - selector: (context, model) => model.code, - builder: (context, code, child) { - if (code == null || localIntensity == null) { - return const SizedBox.shrink(); - } + Selector( + selector: (context, model) => model.code, + builder: (context, code, child) { + if (code == null || localIntensity == null) { + return const SizedBox.shrink(); + } - return Padding( - padding: const EdgeInsets.only(top: 8, bottom: 4), - child: IntrinsicHeight( - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.all(4), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - '所在地預估'.i18n, - style: context.texts.labelLarge!.copyWith( - color: context.colors.onErrorContainer.withValues(alpha: 0.6), + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: IntrinsicHeight( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(4), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + '所在地預估'.i18n, + style: context.texts.labelLarge!.copyWith( + color: context.colors.onErrorContainer.withValues(alpha: 0.6), + ), ), - ), - Padding( - padding: const EdgeInsets.only(top: 12, bottom: 8), - child: Text( - localIntensity!.asIntensityLabel, - style: context.texts.displayMedium!.copyWith( - fontWeight: FontWeight.bold, - color: context.colors.onErrorContainer, - height: 1, - leadingDistribution: TextLeadingDistribution.even, + Padding( + padding: const EdgeInsets.only(top: 12, bottom: 8), + child: Text( + localIntensity!.asIntensityLabel, + style: context.texts.displayMedium!.copyWith( + fontWeight: FontWeight.bold, + color: context.colors.onErrorContainer, + height: 1, + leadingDistribution: TextLeadingDistribution.even, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, ), - ), - ], + ], + ), ), ), - ), - VerticalDivider(color: context.colors.onErrorContainer.withValues(alpha: 0.4), width: 24), - Expanded( - child: Padding( - padding: const EdgeInsets.all(4), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - '震波'.i18n, - style: context.texts.labelLarge!.copyWith( - color: context.colors.onErrorContainer.withValues(alpha: 0.6), + VerticalDivider(color: context.colors.onErrorContainer.withValues(alpha: 0.4), width: 24), + Expanded( + child: Padding( + padding: const EdgeInsets.all(4), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + '震波'.i18n, + style: context.texts.labelLarge!.copyWith( + color: context.colors.onErrorContainer.withValues(alpha: 0.6), + ), ), - ), - Padding( - padding: const EdgeInsets.only(top: 12, bottom: 8), - child: (countdown > 0) - ? RichText( - text: TextSpan( - children: [ - TextSpan( - text: countdown.toString(), - style: TextStyle( - fontSize: context.texts.displayMedium!.fontSize! * 1.15, - ), - ), - TextSpan( - text: ' 秒'.i18n, - style: TextStyle(fontSize: context.texts.labelLarge!.fontSize), - ), - ], - style: context.texts.displayMedium!.copyWith( - fontWeight: FontWeight.bold, - color: context.colors.onErrorContainer, - height: 1, - leadingDistribution: TextLeadingDistribution.even, + Padding( + padding: const EdgeInsets.only(top: 12, bottom: 8), + child: (countdown > 0) + ? RichText( + text: TextSpan( + children: [ + TextSpan( + text: countdown.toString(), + style: TextStyle( + fontSize: context.texts.displayMedium!.fontSize! * 1.15, ), ), - textAlign: TextAlign.center, - ) - : Text( - '抵達'.i18n, - style: context.texts.displayMedium!.copyWith( - fontSize: context.texts.displayMedium!.fontSize! * 0.92, - fontWeight: FontWeight.bold, - color: context.colors.onErrorContainer, - height: 1, - leadingDistribution: TextLeadingDistribution.even, + TextSpan( + text: ' 秒'.i18n, + style: TextStyle(fontSize: context.texts.labelLarge!.fontSize), ), - textAlign: TextAlign.center, + ], + style: context.texts.displayMedium!.copyWith( + fontWeight: FontWeight.bold, + color: context.colors.onErrorContainer, + height: 1, + leadingDistribution: TextLeadingDistribution.even, ), - ), - ], + ), + textAlign: TextAlign.center, + ) + : Text( + '抵達'.i18n, + style: context.texts.displayMedium!.copyWith( + fontSize: context.texts.displayMedium!.fontSize! * 0.92, + fontWeight: FontWeight.bold, + color: context.colors.onErrorContainer, + height: 1, + leadingDistribution: TextLeadingDistribution.even, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), ), ), - ), - ], + ], + ), ), - ), - ); - }, - ), - ], + ); + }, + ), + ], + ), ), ), - ), - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => context.push(MapPage.route(options: MapPageOptions(initialLayers: {MapLayer.monitor}))), - splashColor: context.colors.error.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(16), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => context.push(MapPage.route(options: MapPageOptions(initialLayers: {MapLayer.monitor}))), + splashColor: context.colors.error.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(16), + ), ), ), - ), - ], + ], + ), ); } diff --git a/lib/app/home/_widgets/forecast_card.dart b/lib/app/home/_widgets/forecast_card.dart index 334f44262..b3e954e06 100644 --- a/lib/app/home/_widgets/forecast_card.dart +++ b/lib/app/home/_widgets/forecast_card.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:dpip/utils/log.dart'; import 'package:flutter/material.dart'; import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/widgets/responsive/responsive_container.dart'; import 'package:dpip/core/i18n.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; @@ -83,145 +84,147 @@ class _ForecastCardState extends State { } final pages = _pages; - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: context.colors.outline.withValues(alpha: 0.1)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(14, 12, 14, 8), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: context.colors.primaryContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(Icons.wb_sunny_outlined, color: context.colors.primary, size: 16), - ), - const SizedBox(width: 8), - Text( - '天氣預報(24h)'.i18n, - style: context.theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - const Spacer(), - if (pages.length > 1) + return ResponsiveContainer( + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: context.colors.outline.withValues(alpha: 0.1)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 8), + child: Row( + children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.all(6), decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), + color: context.colors.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), ), - child: Text( - '${_currentPage + 1}/${pages.length}', - style: context.theme.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - fontWeight: FontWeight.w600, + child: Icon(Icons.wb_sunny_outlined, color: context.colors.primary, size: 16), + ), + const SizedBox(width: 8), + Text( + '天氣預報(24h)'.i18n, + style: context.theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + const Spacer(), + if (pages.length > 1) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '${_currentPage + 1}/${pages.length}', + style: context.theme.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), ), ), - ), - ], + ], + ), ), - ), - Builder( - builder: (context) { - double calculatePageHeight(int pageIndex) { - double height = 0; - final pageData = pages[pageIndex]; - for (int i = 0; i < pageData.length; i++) { - final globalIndex = pageIndex * 6 + i; - final isExpanded = _expandedItems.contains(globalIndex); - height += isExpanded ? 320 : 84; - if (i < pageData.length - 1 && !isExpanded) height += 1; + Builder( + builder: (context) { + double calculatePageHeight(int pageIndex) { + double height = 0; + final pageData = pages[pageIndex]; + for (int i = 0; i < pageData.length; i++) { + final globalIndex = pageIndex * 6 + i; + final isExpanded = _expandedItems.contains(globalIndex); + height += isExpanded ? 320 : 84; + if (i < pageData.length - 1 && !isExpanded) height += 1; + } + return height + 4; } - return height + 4; - } - final calculatedHeight = pages.isNotEmpty ? calculatePageHeight(_currentPage) : 0.0; - final pageHeight = _measuredHeights[_currentPage] ?? calculatedHeight; + final calculatedHeight = pages.isNotEmpty ? calculatePageHeight(_currentPage) : 0.0; + final pageHeight = _measuredHeights[_currentPage] ?? calculatedHeight; - return AnimatedSize( - duration: const Duration(milliseconds: 150), - curve: Curves.easeOut, - child: SizedBox( - height: pageHeight, - child: PageView.builder( - controller: _pageController, - scrollDirection: Axis.vertical, - itemCount: pages.length, - physics: const ClampingScrollPhysics(), - onPageChanged: (index) { - setState(() { - _currentPage = index; - if (index < _pages.length) { - final currentPageStart = index * 6; - final currentPageEnd = currentPageStart + _pages[index].length - 1; - _expandedItems.removeWhere((expandedIndex) { - return expandedIndex < currentPageStart || expandedIndex > currentPageEnd; - }); - if (_expandedItems.isNotEmpty) { - _measuredHeights.clear(); - } - } - _measuredHeights.removeWhere((key, value) => key != index); - }); - }, - itemBuilder: (context, pageIndex) { - if (!_pageKeys.containsKey(pageIndex)) { - _pageKeys[pageIndex] = GlobalKey(); - } - final key = _pageKeys[pageIndex]!; - if (!_measuredHeights.containsKey(pageIndex) && !_measuringPages.contains(pageIndex)) { - _measuringPages.add(pageIndex); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - final ctx = key.currentContext; - if (ctx == null) return; - final box = ctx.findRenderObject() as RenderBox?; - if (box == null || !box.hasSize) return; - final h = box.size.height; - if (!_measuredHeights.containsKey(pageIndex)) { - setState(() { - _measuredHeights[pageIndex] = h; + return AnimatedSize( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: SizedBox( + height: pageHeight, + child: PageView.builder( + controller: _pageController, + scrollDirection: Axis.vertical, + itemCount: pages.length, + physics: const ClampingScrollPhysics(), + onPageChanged: (index) { + setState(() { + _currentPage = index; + if (index < _pages.length) { + final currentPageStart = index * 6; + final currentPageEnd = currentPageStart + _pages[index].length - 1; + _expandedItems.removeWhere((expandedIndex) { + return expandedIndex < currentPageStart || expandedIndex > currentPageEnd; }); + if (_expandedItems.isNotEmpty) { + _measuredHeights.clear(); + } } - _measuringPages.remove(pageIndex); + _measuredHeights.removeWhere((key, value) => key != index); }); - } - return SingleChildScrollView( - physics: const NeverScrollableScrollPhysics(), - child: Padding( - key: key, - padding: const EdgeInsets.fromLTRB(10, 0, 10, 6), - child: Column( - mainAxisSize: MainAxisSize.min, - children: pages[pageIndex].asMap().entries.map((entry) { - final globalIndex = pageIndex * 6 + entry.key; - return _buildForecastItem( - context, - entry.value as Map, - minTemp, - maxTemp, - globalIndex, - ); - }).toList(), + }, + itemBuilder: (context, pageIndex) { + if (!_pageKeys.containsKey(pageIndex)) { + _pageKeys[pageIndex] = GlobalKey(); + } + final key = _pageKeys[pageIndex]!; + if (!_measuredHeights.containsKey(pageIndex) && !_measuringPages.contains(pageIndex)) { + _measuringPages.add(pageIndex); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final ctx = key.currentContext; + if (ctx == null) return; + final box = ctx.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return; + final h = box.size.height; + if (!_measuredHeights.containsKey(pageIndex)) { + setState(() { + _measuredHeights[pageIndex] = h; + }); + } + _measuringPages.remove(pageIndex); + }); + } + return SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Padding( + key: key, + padding: const EdgeInsets.fromLTRB(10, 0, 10, 6), + child: Column( + mainAxisSize: MainAxisSize.min, + children: pages[pageIndex].asMap().entries.map((entry) { + final globalIndex = pageIndex * 6 + entry.key; + return _buildForecastItem( + context, + entry.value as Map, + minTemp, + maxTemp, + globalIndex, + ); + }).toList(), + ), ), - ), - ); - }, + ); + }, + ), ), - ), - ); - }, - ), - ], + ); + }, + ), + ], + ), ), ); } catch (e, s) { diff --git a/lib/app/home/_widgets/history_timeline_item.dart b/lib/app/home/_widgets/history_timeline_item.dart index 0c7e7f0e4..4973f30fe 100644 --- a/lib/app/home/_widgets/history_timeline_item.dart +++ b/lib/app/home/_widgets/history_timeline_item.dart @@ -2,6 +2,7 @@ import 'package:dpip/api/model/history/history.dart'; import 'package:dpip/utils/extensions/build_context.dart'; import 'package:dpip/utils/list_icon.dart'; import 'package:dpip/widgets/home/event_list_route.dart'; +import 'package:dpip/widgets/responsive/responsive_container.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -24,81 +25,83 @@ class HistoryTimelineItem extends StatelessWidget { Widget build(BuildContext context) { final hasDetail = shouldShowArrow(history); - return InkWell( - onTap: hasDetail ? () => handleEventList(context, history) : null, - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Stack( - alignment: Alignment.topCenter, - children: [ - Positioned( - top: first ? 42 : 0, - bottom: last ? 0 : 0, - width: 1, - child: Container(color: context.colors.outlineVariant), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 20), - child: Container( - height: 42, - width: 42, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: expired ? context.colors.surface : context.colors.primaryContainer, - border: expired ? Border.all(color: context.colors.outlineVariant) : null, - ), - child: Icon( - getListIcon(history.icon), - color: expired ? context.colors.outline : context.colors.onPrimaryContainer, - ), - ), - ), - ], - ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return ResponsiveContainer( + child: InkWell( + onTap: hasDetail ? () => handleEventList(context, history) : null, + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Stack( + alignment: Alignment.topCenter, children: [ - Text( - DateFormat('HH:mm:ss').format(history.time.send), - style: context.theme.textTheme.labelMedium?.copyWith( - color: context.colors.outline.withValues(alpha: expired ? 0.6 : 1), - ), + Positioned( + top: first ? 42 : 0, + bottom: last ? 0 : 0, + width: 1, + child: Container(color: context.colors.outlineVariant), ), - Text( - history.text.content['all']!.subtitle, - style: context.theme.textTheme.titleMedium?.copyWith( - color: context.colors.onSurface.withValues(alpha: expired ? 0.6 : 1), + Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Container( + height: 42, + width: 42, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: expired ? context.colors.surface : context.colors.primaryContainer, + border: expired ? Border.all(color: context.colors.outlineVariant) : null, + ), + child: Icon( + getListIcon(history.icon), + color: expired ? context.colors.outline : context.colors.onPrimaryContainer, + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - history.text.description['all']!, - style: context.theme.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurface.withValues(alpha: expired ? 0.6 : 1), - ), - textAlign: TextAlign.justify, - maxLines: 3, - overflow: TextOverflow.ellipsis, ), ], ), ), - ), - if (hasDetail) - Padding( - padding: const EdgeInsets.only(left: 4), - child: Icon(Symbols.chevron_right_rounded, color: context.colors.outline), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + DateFormat('HH:mm:ss').format(history.time.send), + style: context.theme.textTheme.labelMedium?.copyWith( + color: context.colors.outline.withValues(alpha: expired ? 0.6 : 1), + ), + ), + Text( + history.text.content['all']!.subtitle, + style: context.theme.textTheme.titleMedium?.copyWith( + color: context.colors.onSurface.withValues(alpha: expired ? 0.6 : 1), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + history.text.description['all']!, + style: context.theme.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurface.withValues(alpha: expired ? 0.6 : 1), + ), + textAlign: TextAlign.justify, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), ), - ], + if (hasDetail) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Icon(Symbols.chevron_right_rounded, color: context.colors.outline), + ), + ], + ), ), ), ); diff --git a/lib/app/home/_widgets/radar_card.dart b/lib/app/home/_widgets/radar_card.dart index 28e46516d..03bb0de92 100644 --- a/lib/app/home/_widgets/radar_card.dart +++ b/lib/app/home/_widgets/radar_card.dart @@ -9,6 +9,7 @@ import 'package:dpip/utils/extensions/string.dart'; import 'package:dpip/utils/log.dart'; import 'package:dpip/widgets/layout.dart'; import 'package:dpip/widgets/map/map.dart'; +import 'package:dpip/widgets/responsive/responsive_container.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -68,91 +69,94 @@ class _RadarMapCardState extends State { @override Widget build(BuildContext context) { - return Stack( - children: [ - IgnorePointer( - child: Container( - decoration: BoxDecoration( - color: context.colors.surfaceContainer, - border: Border.all(color: context.colors.outlineVariant), - borderRadius: BorderRadius.circular(16), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Layout.col.min( - children: [ - SizedBox( - height: 200, - child: DpipMap( - key: _key, - onMapCreated: (controller) => mapController = controller, - onStyleLoadedCallback: () => _setupMapLayers(), - dragEnabled: false, - rotateGesturesEnabled: false, - zoomGesturesEnabled: false, - focusUserLocationWhenUpdated: true, + return ResponsiveContainer( + maxWidth: 720, + child: Stack( + children: [ + IgnorePointer( + child: Container( + decoration: BoxDecoration( + color: context.colors.surfaceContainer, + border: Border.all(color: context.colors.outlineVariant), + borderRadius: BorderRadius.circular(16), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Layout.col.min( + children: [ + SizedBox( + height: 200, + child: DpipMap( + key: _key, + onMapCreated: (controller) => mapController = controller, + onStyleLoadedCallback: () => _setupMapLayers(), + dragEnabled: false, + rotateGesturesEnabled: false, + zoomGesturesEnabled: false, + focusUserLocationWhenUpdated: true, + ), ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Layout.row.between( - children: [ - Layout.row[8]( - children: [ - const Icon(Symbols.radar, size: 24), - Text('雷達回波'.i18n, style: context.texts.titleMedium), - FutureBuilder( - future: radarListFuture, - builder: (context, snapshot) { - final data = snapshot.data; - - if (data == null) return const SizedBox.shrink(); - - final style = context.texts.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - ); - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: context.colors.surfaceContainer, - border: Border.all(color: context.colors.outlineVariant), - borderRadius: BorderRadius.circular(16), - ), - child: Layout.row[4]( - children: [ - Icon( - Symbols.schedule_rounded, - size: (style?.fontSize ?? 12) * 1.25, - color: context.colors.onSurfaceVariant, - ), - Text(data.last.toSimpleDateTimeString(), style: style), - ], - ), - ); - }, - ), - ], - ), - const Icon(Symbols.chevron_right_rounded, size: 24), - ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Layout.row.between( + children: [ + Layout.row[8]( + children: [ + const Icon(Symbols.radar, size: 24), + Text('雷達回波'.i18n, style: context.texts.titleMedium), + FutureBuilder( + future: radarListFuture, + builder: (context, snapshot) { + final data = snapshot.data; + + if (data == null) return const SizedBox.shrink(); + + final style = context.texts.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: context.colors.surfaceContainer, + border: Border.all(color: context.colors.outlineVariant), + borderRadius: BorderRadius.circular(16), + ), + child: Layout.row[4]( + children: [ + Icon( + Symbols.schedule_rounded, + size: (style?.fontSize ?? 12) * 1.25, + color: context.colors.onSurfaceVariant, + ), + Text(data.last.toSimpleDateTimeString(), style: style), + ], + ), + ); + }, + ), + ], + ), + const Icon(Symbols.chevron_right_rounded, size: 24), + ], + ), ), - ), - ], + ], + ), ), ), ), - ), - Positioned.fill( - child: Material( - type: MaterialType.transparency, - child: InkWell( - onTap: () => context.push(MapPage.route(options: MapPageOptions(initialLayers: {MapLayer.radar}))), - borderRadius: BorderRadius.circular(16), + Positioned.fill( + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () => context.push(MapPage.route(options: MapPageOptions(initialLayers: {MapLayer.radar}))), + borderRadius: BorderRadius.circular(16), + ), ), ), - ), - ], + ], + ), ); } } diff --git a/lib/app/home/_widgets/thunderstorm_card.dart b/lib/app/home/_widgets/thunderstorm_card.dart index 7a410329f..109a9eeb8 100644 --- a/lib/app/home/_widgets/thunderstorm_card.dart +++ b/lib/app/home/_widgets/thunderstorm_card.dart @@ -4,6 +4,7 @@ import 'package:dpip/route/event_viewer/thunderstorm.dart'; import 'package:dpip/utils/extensions/build_context.dart'; import 'package:dpip/utils/extensions/color_scheme.dart'; import 'package:dpip/utils/extensions/datetime.dart'; +import 'package:dpip/widgets/responsive/responsive_container.dart'; import 'package:flutter/material.dart'; import 'package:i18n_extension/i18n_extension.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; @@ -16,84 +17,87 @@ class ThunderstormCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Stack( - children: [ - IgnorePointer( - child: Container( - decoration: BoxDecoration( - color: context.theme.extendedColors.blueContainer, - border: Border.all(color: context.theme.extendedColors.blue, width: 2), - borderRadius: BorderRadius.circular(16), - ), - padding: const EdgeInsets.all(12), - clipBehavior: Clip.antiAlias, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - spacing: 8, - children: [ - Container( - decoration: BoxDecoration( - color: context.theme.extendedColors.blue, - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.fromLTRB(8, 6, 12, 6), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 4, - children: [ - Icon( - Symbols.thunderstorm_rounded, - color: context.theme.extendedColors.onBlue, - weight: 700, - size: 22, - ), - Text( - '雷雨即時訊息'.i18n, - style: context.texts.labelLarge!.copyWith( + return ResponsiveContainer( + maxWidth: 720, + child: Stack( + children: [ + IgnorePointer( + child: Container( + decoration: BoxDecoration( + color: context.theme.extendedColors.blueContainer, + border: Border.all(color: context.theme.extendedColors.blue, width: 2), + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.all(12), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + spacing: 8, + children: [ + Container( + decoration: BoxDecoration( + color: context.theme.extendedColors.blue, + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.fromLTRB(8, 6, 12, 6), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Icon( + Symbols.thunderstorm_rounded, color: context.theme.extendedColors.onBlue, - fontWeight: FontWeight.bold, + weight: 700, + size: 22, ), - ), - ], + Text( + '雷雨即時訊息'.i18n, + style: context.texts.labelLarge!.copyWith( + color: context.theme.extendedColors.onBlue, + fontWeight: FontWeight.bold, + ), + ), + ], + ), ), - ), - ], + ], + ), + Icon(Symbols.chevron_right_rounded, color: context.colors.onErrorContainer, size: 24), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 8), + child: StyledText( + text: '您所在區域附近有劇烈雷雨或降雨發生,請注意防範,持續至 {time} 。'.i18n.args({ + 'time': history.time.expiresAt.toSimpleDateTimeString(), + }), + style: context.texts.bodyLarge!.copyWith(color: context.theme.extendedColors.onBlueContainer), + tags: {'bold': StyledTextTag(style: const TextStyle(fontWeight: FontWeight.bold))}, ), - Icon(Symbols.chevron_right_rounded, color: context.colors.onErrorContainer, size: 24), - ], - ), - Padding( - padding: const EdgeInsets.only(top: 8), - child: StyledText( - text: '您所在區域附近有劇烈雷雨或降雨發生,請注意防範,持續至 {time} 。'.i18n.args({ - 'time': history.time.expiresAt.toSimpleDateTimeString(), - }), - style: context.texts.bodyLarge!.copyWith(color: context.theme.extendedColors.onBlueContainer), - tags: {'bold': StyledTextTag(style: const TextStyle(fontWeight: FontWeight.bold))}, ), - ), - ], + ], + ), ), ), - ), - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => - Navigator.of(context).push(MaterialPageRoute(builder: (context) => ThunderstormPage(item: history))), - splashColor: context.theme.extendedColors.blue.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(16), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => + Navigator.of(context).push(MaterialPageRoute(builder: (context) => ThunderstormPage(item: history))), + splashColor: context.theme.extendedColors.blue.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(16), + ), ), ), - ), - ], + ], + ), ); } } diff --git a/lib/app/home/_widgets/weather_header.dart b/lib/app/home/_widgets/weather_header.dart index c5cfcc8bc..dd762a10b 100644 --- a/lib/app/home/_widgets/weather_header.dart +++ b/lib/app/home/_widgets/weather_header.dart @@ -5,6 +5,7 @@ import 'package:dpip/models/settings/ui.dart'; import 'package:dpip/utils/extensions/build_context.dart'; import 'package:dpip/utils/extensions/number.dart'; import 'package:dpip/utils/weather_icon.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'; @@ -59,195 +60,198 @@ class WeatherHeader extends StatelessWidget { exp(17.27 * weather.data.temperature / (weather.data.temperature + 237.3)); final feelsLike = weather.data.temperature + 0.33 * e - 0.7 * weather.data.wind.speed - 4.0; - return Center( - child: Column( - spacing: 12, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - spacing: 6, - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: context.colors.secondaryContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - WeatherIcons.getWeatherIcon(weather.data.weatherCode, true), - size: 32, - color: context.colors.secondary, - ), - ), - Text( - WeatherIcons.getWeatherContent(context, weather.data.weatherCode), - style: context.theme.textTheme.titleLarge!.copyWith( - color: context.colors.secondary, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - Selector( - selector: (context, model) => model.useFahrenheit, - builder: (context, useFahrenheit, child) { - final value = weather.data.temperature; - final displayTemp = (useFahrenheit ? value.asFahrenheit : value).round(); - final displayFeelsLike = (useFahrenheit ? feelsLike.asFahrenheit : feelsLike).round(); - - return Column( - spacing: 8, - children: [ - Text( - '$displayTemp°', - style: context.theme.textTheme.displayLarge!.copyWith( - fontSize: 64, - color: context.colors.onSurface, - fontWeight: FontWeight.w200, - height: 1.0, - letterSpacing: -2, - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(20), + return ResponsiveContainer( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + spacing: 12, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + spacing: 6, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: context.colors.secondaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + WeatherIcons.getWeatherIcon(weather.data.weatherCode, true), + size: 32, + color: context.colors.secondary, + ), ), - child: Text( - '體感 $displayFeelsLike°'.i18n, - style: context.theme.textTheme.bodyLarge!.copyWith( - color: context.colors.onSurfaceVariant, - fontWeight: FontWeight.w500, + Text( + WeatherIcons.getWeatherContent(context, weather.data.weatherCode), + style: context.theme.textTheme.titleLarge!.copyWith( + color: context.colors.secondary, + fontWeight: FontWeight.bold, ), ), - ), - ], - ); - }, - ), - Wrap( - spacing: 10, - runSpacing: 8, - alignment: WrapAlignment.center, - children: [ - _buildInfoChip( - context, - Symbols.water_drop_rounded, - '濕度'.i18n, - '${weather.data.humidity >= 0 ? weather.data.humidity.round() : "-"}%', - Colors.blue, - ), - _buildInfoChip( - context, - Symbols.wind_power_rounded, - '風速'.i18n, - weather.data.wind.speed >= 0 ? '${weather.data.wind.speed}m/s' : '-', - Colors.teal, - ), - _buildInfoChip( - context, - Symbols.explore_rounded, - '風向'.i18n, - weather.data.wind.direction.isNotEmpty ? weather.data.wind.direction : '-', - Colors.cyan, - ), - _buildInfoChip( - context, - Symbols.wind_power_rounded, - '風級'.i18n, - weather.data.wind.beaufort > 0 ? '${weather.data.wind.beaufort}級'.i18n : '-', - Colors.teal, - ), - _buildInfoChip( - context, - Symbols.compress_rounded, - '氣壓'.i18n, - weather.data.pressure >= 0 ? '${weather.data.pressure.round()}hPa' : '-', - Colors.orange, - ), - _buildInfoChip( - context, - Symbols.rainy_rounded, - '降雨'.i18n, - weather.data.rain >= 0 ? '${weather.data.rain}mm' : '-', - Colors.indigo, - ), - _buildInfoChip( - context, - Symbols.visibility_rounded, - '能見度'.i18n, - weather.data.visibility >= 0 ? '${weather.data.visibility.round()}km' : '-', - Colors.grey, - ), - if (weather.data.gust.speed > 0) - _buildInfoChip( - context, - Symbols.air_rounded, - '陣風'.i18n, - '${weather.data.gust.speed}m/s', - Colors.purple, - ), - if (weather.data.gust.beaufort > 0) - _buildInfoChip( - context, - Symbols.wind_power_rounded, - '陣風級'.i18n, - '${weather.data.gust.beaufort}級'.i18n, - Colors.deepPurple, - ), - if (weather.data.sunshine >= 0) - _buildInfoChip( - context, - Symbols.wb_sunny_rounded, - '日照'.i18n, - '${weather.data.sunshine.toStringAsFixed(1)}h', - Colors.amber, + ], ), - ], - ), - Container( - margin: const EdgeInsets.only(top: 4), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(16), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 6, - children: [ - Icon(Symbols.pin_drop_rounded, size: 14, color: context.colors.onSurfaceVariant), - Text( - '${weather.station.name}氣象站'.i18n, - style: context.theme.textTheme.bodySmall!.copyWith(color: context.colors.onSurfaceVariant), - ), - Container( - width: 1, - height: 12, - margin: const EdgeInsets.symmetric(horizontal: 4), - color: context.colors.onSurfaceVariant.withValues(alpha: 0.3), + Selector( + selector: (context, model) => model.useFahrenheit, + builder: (context, useFahrenheit, child) { + final value = weather.data.temperature; + final displayTemp = (useFahrenheit ? value.asFahrenheit : value).round(); + final displayFeelsLike = (useFahrenheit ? feelsLike.asFahrenheit : feelsLike).round(); + + return Column( + spacing: 8, + children: [ + Text( + '$displayTemp°', + style: context.theme.textTheme.displayLarge!.copyWith( + fontSize: 64, + color: context.colors.onSurface, + fontWeight: FontWeight.w200, + height: 1.0, + letterSpacing: -2, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '體感 $displayFeelsLike°'.i18n, + style: context.theme.textTheme.bodyLarge!.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ); + }, ), - Text( - '${weather.station.distance.toStringAsFixed(1)}km', - style: context.theme.textTheme.bodySmall!.copyWith(color: context.colors.onSurfaceVariant), + Wrap( + spacing: 10, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + _buildInfoChip( + context, + Symbols.water_drop_rounded, + '濕度'.i18n, + '${weather.data.humidity >= 0 ? weather.data.humidity.round() : "-"}%', + Colors.blue, + ), + _buildInfoChip( + context, + Symbols.wind_power_rounded, + '風速'.i18n, + weather.data.wind.speed >= 0 ? '${weather.data.wind.speed}m/s' : '-', + Colors.teal, + ), + _buildInfoChip( + context, + Symbols.explore_rounded, + '風向'.i18n, + weather.data.wind.direction.isNotEmpty ? weather.data.wind.direction : '-', + Colors.cyan, + ), + _buildInfoChip( + context, + Symbols.wind_power_rounded, + '風級'.i18n, + weather.data.wind.beaufort > 0 ? '${weather.data.wind.beaufort}級'.i18n : '-', + Colors.teal, + ), + _buildInfoChip( + context, + Symbols.compress_rounded, + '氣壓'.i18n, + weather.data.pressure >= 0 ? '${weather.data.pressure.round()}hPa' : '-', + Colors.orange, + ), + _buildInfoChip( + context, + Symbols.rainy_rounded, + '降雨'.i18n, + weather.data.rain >= 0 ? '${weather.data.rain}mm' : '-', + Colors.indigo, + ), + _buildInfoChip( + context, + Symbols.visibility_rounded, + '能見度'.i18n, + weather.data.visibility >= 0 ? '${weather.data.visibility.round()}km' : '-', + Colors.grey, + ), + if (weather.data.gust.speed > 0) + _buildInfoChip( + context, + Symbols.air_rounded, + '陣風'.i18n, + '${weather.data.gust.speed}m/s', + Colors.purple, + ), + if (weather.data.gust.beaufort > 0) + _buildInfoChip( + context, + Symbols.wind_power_rounded, + '陣風級'.i18n, + '${weather.data.gust.beaufort}級'.i18n, + Colors.deepPurple, + ), + if (weather.data.sunshine >= 0) + _buildInfoChip( + context, + Symbols.wb_sunny_rounded, + '日照'.i18n, + '${weather.data.sunshine.toStringAsFixed(1)}h', + Colors.amber, + ), + ], ), Container( - width: 1, - height: 12, - margin: const EdgeInsets.symmetric(horizontal: 4), - color: context.colors.onSurfaceVariant.withValues(alpha: 0.3), - ), - Icon(Symbols.schedule_rounded, size: 14, color: context.colors.onSurfaceVariant), - Text( - weather.time.toLocaleTimeString(context).substring(0, 5), - style: context.theme.textTheme.bodySmall!.copyWith(color: context.colors.onSurfaceVariant), + margin: const EdgeInsets.only(top: 4), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 6, + children: [ + Icon(Symbols.pin_drop_rounded, size: 14, color: context.colors.onSurfaceVariant), + Text( + '${weather.station.name}氣象站'.i18n, + style: context.theme.textTheme.bodySmall!.copyWith(color: context.colors.onSurfaceVariant), + ), + Container( + width: 1, + height: 12, + margin: const EdgeInsets.symmetric(horizontal: 4), + color: context.colors.onSurfaceVariant.withValues(alpha: 0.3), + ), + Text( + '${weather.station.distance.toStringAsFixed(1)}km', + style: context.theme.textTheme.bodySmall!.copyWith(color: context.colors.onSurfaceVariant), + ), + Container( + width: 1, + height: 12, + margin: const EdgeInsets.symmetric(horizontal: 4), + color: context.colors.onSurfaceVariant.withValues(alpha: 0.3), + ), + Icon(Symbols.schedule_rounded, size: 14, color: context.colors.onSurfaceVariant), + Text( + weather.time.toLocaleTimeString(context).substring(0, 5), + style: context.theme.textTheme.bodySmall!.copyWith(color: context.colors.onSurfaceVariant), + ), + ], + ), ), ], ), - ), - ], - ), + ), ); } diff --git a/lib/app/map/_lib/managers/monitor.dart b/lib/app/map/_lib/managers/monitor.dart index fddab9a78..3afea6671 100644 --- a/lib/app/map/_lib/managers/monitor.dart +++ b/lib/app/map/_lib/managers/monitor.dart @@ -17,6 +17,7 @@ import 'package:dpip/utils/extensions/number.dart'; 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/responsive/responsive_container.dart'; import 'package:dpip/widgets/sheet/morphing_sheet.dart'; import 'package:flutter/material.dart'; import 'package:geojson_vi/geojson_vi.dart'; @@ -1303,47 +1304,50 @@ class _MonitorMapLayerSheetState extends State { builder: (context, activeEew, child) { return Stack( children: [ - MorphingSheet( - title: '強震監視器'.i18n, - borderRadius: BorderRadius.circular(16), - elevation: 4, - borderWidth: activeEew.isNotEmpty ? 2 : null, - borderColor: activeEew.isNotEmpty ? context.colors.error : null, - backgroundColor: activeEew.isNotEmpty ? context.colors.errorContainer : null, - partialBuilder: (context, controller, sheetController) { - if (activeEew.isEmpty) { - return Padding(padding: const EdgeInsets.all(12), child: Text('目前沒有生效中的地震速報'.i18n)); - } - - final data = activeEew.first; - final hasLocation = GlobalProviders.location.coordinates != null; - - // Calculate location-specific info if available - if (hasLocation) { - final info = eewLocationInfo( - data.info.magnitude, - data.info.depth, - data.info.latitude, - data.info.longitude, - GlobalProviders.location.coordinates!.latitude, - GlobalProviders.location.coordinates!.longitude, + ResponsiveContainer( + maxWidth: 720, + child: MorphingSheet( + title: '強震監視器'.i18n, + borderRadius: BorderRadius.circular(16), + elevation: 4, + borderWidth: activeEew.isNotEmpty ? 2 : null, + borderColor: activeEew.isNotEmpty ? context.colors.error : null, + backgroundColor: activeEew.isNotEmpty ? context.colors.errorContainer : null, + partialBuilder: (context, controller, sheetController) { + if (activeEew.isEmpty) { + return Padding(padding: const EdgeInsets.all(12), child: Text('目前沒有生效中的地震速報'.i18n)); + } + + final data = activeEew.first; + final hasLocation = GlobalProviders.location.coordinates != null; + + // Calculate location-specific info if available + if (hasLocation) { + final info = eewLocationInfo( + data.info.magnitude, + data.info.depth, + data.info.latitude, + data.info.longitude, + GlobalProviders.location.coordinates!.latitude, + GlobalProviders.location.coordinates!.longitude, + ); + + localIntensity = intensityFloatToInt(info.i); + localArrivalTime = (data.info.time + sWaveTimeByDistance(data.info.depth, info.dist)).floor(); + + WidgetsBinding.instance.addPostFrameCallback((_) => _updateCountdown()); + _timer ??= Timer.periodic(const Duration(seconds: 1), (_) => _updateCountdown()); + } + + return InkWell( + onTap: _toggleCollapse, + child: Padding( + padding: const EdgeInsets.all(12), + child: _buildEewContent(data, activeEew.length, hasLocation), + ), ); - - localIntensity = intensityFloatToInt(info.i); - localArrivalTime = (data.info.time + sWaveTimeByDistance(data.info.depth, info.dist)).floor(); - - WidgetsBinding.instance.addPostFrameCallback((_) => _updateCountdown()); - _timer ??= Timer.periodic(const Duration(seconds: 1), (_) => _updateCountdown()); - } - - return InkWell( - onTap: _toggleCollapse, - child: Padding( - padding: const EdgeInsets.all(12), - child: _buildEewContent(data, activeEew.length, hasLocation), - ), - ); - }, + }, + ), ), Positioned( top: 26, diff --git a/lib/app/map/_lib/managers/report.dart b/lib/app/map/_lib/managers/report.dart index e95f118ed..fd090062f 100644 --- a/lib/app/map/_lib/managers/report.dart +++ b/lib/app/map/_lib/managers/report.dart @@ -1,4 +1,3 @@ -import 'package:dpip/utils/extensions/number.dart'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; @@ -18,11 +17,12 @@ import 'package:dpip/app/map/_lib/utils.dart'; import 'package:dpip/core/i18n.dart'; import 'package:dpip/core/providers.dart'; import 'package:dpip/models/data.dart'; -import 'package:dpip/utils/constants.dart'; -import 'package:dpip/utils/depth_color.dart'; import 'package:dpip/utils/extensions/build_context.dart'; import 'package:dpip/utils/extensions/datetime.dart'; import 'package:dpip/utils/extensions/iterable.dart'; +import 'package:dpip/utils/extensions/number.dart'; +import 'package:dpip/utils/constants.dart'; +import 'package:dpip/utils/depth_color.dart'; import 'package:dpip/utils/geojson.dart'; import 'package:dpip/utils/intensity_color.dart'; import 'package:dpip/utils/log.dart'; @@ -33,6 +33,7 @@ import 'package:dpip/widgets/list/list_tile.dart'; import 'package:dpip/widgets/map/map.dart'; import 'package:dpip/widgets/report/enlargeable_image.dart'; import 'package:dpip/widgets/report/intensity_box.dart'; +import 'package:dpip/widgets/responsive/responsive_container.dart'; import 'package:dpip/widgets/sheet/morphing_sheet.dart'; import 'package:dpip/widgets/sheet/morphing_sheet_controller.dart'; @@ -327,296 +328,298 @@ class _ReportMapLayerSheetState extends State { @override Widget build(BuildContext context) { - return MorphingSheet( - controller: morphingSheetController, - title: '地震報告'.i18n, - borderRadius: BorderRadius.circular(16), - elevation: 4, - partialBuilder: (context, controller, sheetController) { - if (GlobalProviders.data.partialReport.isEmpty) { - return const SizedBox.shrink(); - } - - return ValueListenableBuilder( - valueListenable: widget.manager.currentReport, - builder: (context, currentReport, child) { - // Show the first report from partial report list - if (currentReport == null) { - final report = GlobalProviders.data.partialReport.first; - - final locationString = report.extractLocation(); + return ResponsiveContainer( + mode: ResponsiveMode.panel, + child: MorphingSheet( + controller: morphingSheetController, + title: '地震報告'.i18n, + borderRadius: BorderRadius.circular(16), + elevation: 4, + partialBuilder: (context, controller, sheetController) { + if (GlobalProviders.data.partialReport.isEmpty) { + return const SizedBox.shrink(); + } + + return ValueListenableBuilder( + valueListenable: widget.manager.currentReport, + builder: (context, currentReport, child) { + // Show the first report from partial report list + if (currentReport == null) { + final report = GlobalProviders.data.partialReport.first; + + final locationString = report.extractLocation(); + final location = Location.tryParse(locationString)?.dynamicName ?? locationString; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Selector>( + selector: (context, model) => model.radar, + builder: (context, radar, child) { + return Column( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + spacing: 8, + children: [ + Icon(Symbols.docs_rounded, size: 24, color: context.colors.onSurface), + Expanded( + child: Text( + '近期的地震報告'.i18n, + style: context.texts.titleMedium?.copyWith(color: context.colors.onSurface), + ), + ), + Text( + '更多'.i18n, + style: context.texts.labelSmall?.copyWith(color: context.colors.outline), + ), + Icon(Symbols.swipe_up_rounded, size: 16, color: context.colors.outline), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + spacing: 8, + children: [ + IntensityBox( + intensity: report.intensity, + size: 48, + borderRadius: 12, + border: !report.hasNumber, + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + report.hasNumber + ? '編號 {number} 顯著有感地震'.i18n.args({'number': report.number}) + : location, + style: context.texts.titleMedium, + ), + Text( + report.time.toLocaleDateTimeString(context), + style: context.texts.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ), + Text('M ${report.magnitude.toStringAsFixed(1)}', style: context.texts.titleMedium), + ], + ), + ), + ], + ); + }, + ), + ); + } + + // Show the current report with details + + final locationString = currentReport.extractLocation(); final location = Location.tryParse(locationString)?.dynamicName ?? locationString; return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Selector>( - selector: (context, model) => model.radar, - builder: (context, radar, child) { - return Column( - mainAxisSize: MainAxisSize.min, - spacing: 4, + padding: const EdgeInsets.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 2, + children: [ + Text( + currentReport.hasNumber + ? '編號 {number} 顯著有感地震'.i18n.args({'number': currentReport.number}) + : '小區域有感地震'.i18n, + style: context.texts.labelMedium?.copyWith(color: context.colors.outline), + ), + Text(location, style: context.texts.titleLarge?.copyWith(fontWeight: FontWeight.w500)), + Text( + currentReport.time.toLocaleDateTimeString(context), + style: context.texts.bodyMedium?.copyWith(color: context.colors.onSurfaceVariant), + ), + ], + ), + ), + IntensityBox( + intensity: currentReport.intensity, + size: 56, + borderRadius: 12, + border: !currentReport.hasNumber, + ), + ], + ), + Row( + spacing: 16, + children: [ + Expanded( child: Row( - spacing: 8, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Icon(Symbols.docs_rounded, size: 24, color: context.colors.onSurface), - Expanded( - child: Text( - '近期的地震報告'.i18n, - style: context.texts.titleMedium?.copyWith(color: context.colors.onSurface), - ), + Text( + '地震規模'.i18n, + style: context.texts.bodyMedium?.copyWith(color: context.colors.onSurfaceVariant), ), Text( - '更多'.i18n, - style: context.texts.labelSmall?.copyWith(color: context.colors.outline), + 'M ${currentReport.magnitude.toStringAsFixed(1)}', + style: context.texts.bodyLarge?.copyWith(fontWeight: FontWeight.w500), ), - Icon(Symbols.swipe_up_rounded, size: 16, color: context.colors.outline), ], ), ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + Expanded( child: Row( - spacing: 8, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - IntensityBox( - intensity: report.intensity, - size: 48, - borderRadius: 12, - border: !report.hasNumber, + Text( + '震源深度'.i18n, + style: context.texts.bodyMedium?.copyWith(color: context.colors.onSurfaceVariant), ), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - report.hasNumber - ? '編號 {number} 顯著有感地震'.i18n.args({'number': report.number}) - : location, - style: context.texts.titleMedium, - ), - Text( - report.time.toLocaleDateTimeString(context), - style: context.texts.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], - ), + Text( + '${currentReport.depth}km', + style: context.texts.bodyLarge?.copyWith(fontWeight: FontWeight.w500), ), - Text('M ${report.magnitude.toStringAsFixed(1)}', style: context.texts.titleMedium), ], ), ), ], - ); - }, + ), + ], ), ); - } - - // Show the current report with details - - final locationString = currentReport.extractLocation(); - final location = Location.tryParse(locationString)?.dynamicName ?? locationString; - - return Padding( - padding: const EdgeInsets.all(12), - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: 4, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 12, - children: [ - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 2, - children: [ - Text( - currentReport.hasNumber - ? '編號 {number} 顯著有感地震'.i18n.args({'number': currentReport.number}) - : '小區域有感地震'.i18n, - style: context.texts.labelMedium?.copyWith(color: context.colors.outline), - ), - Text(location, style: context.texts.titleLarge?.copyWith(fontWeight: FontWeight.w500)), - Text( - currentReport.time.toLocaleDateTimeString(context), - style: context.texts.bodyMedium?.copyWith(color: context.colors.onSurfaceVariant), - ), - ], - ), - ), - IntensityBox( - intensity: currentReport.intensity, - size: 56, - borderRadius: 12, - border: !currentReport.hasNumber, - ), - ], - ), - Row( - spacing: 16, - children: [ - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '地震規模'.i18n, - style: context.texts.bodyMedium?.copyWith(color: context.colors.onSurfaceVariant), - ), - Text( - 'M ${currentReport.magnitude.toStringAsFixed(1)}', - style: context.texts.bodyLarge?.copyWith(fontWeight: FontWeight.w500), - ), - ], - ), - ), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '震源深度'.i18n, - style: context.texts.bodyMedium?.copyWith(color: context.colors.onSurfaceVariant), - ), - Text( - '${currentReport.depth}km', - style: context.texts.bodyLarge?.copyWith(fontWeight: FontWeight.w500), - ), - ], - ), + }, + ); + }, + fullBuilder: (context, controller, sheetController) { + return ValueListenableBuilder( + valueListenable: widget.manager.currentReport, + builder: (context, currentReport, child) { + if (currentReport == null) { + final grouped = GlobalProviders.data.partialReport + .groupListsBy((report) => report.time.toLocaleFullDateString(context)) + .entries + .toList(); + + return CustomScrollView( + controller: controller, + slivers: [ + SliverAppBar( + title: Text('地震報告'.i18n), + leading: BackButton( + onPressed: () { + sheetController.collapse(); + controller.animateTo(0, duration: Durations.short4, curve: Easing.emphasizedDecelerate); + }, ), - ], - ), - ], - ), - ); - }, - ); - }, - fullBuilder: (context, controller, sheetController) { - return ValueListenableBuilder( - valueListenable: widget.manager.currentReport, - builder: (context, currentReport, child) { - if (currentReport == null) { - final grouped = GlobalProviders.data.partialReport - .groupListsBy((report) => report.time.toLocaleFullDateString(context)) - .entries - .toList(); - - return CustomScrollView( - controller: controller, - slivers: [ - SliverAppBar( - title: Text('地震報告'.i18n), - leading: BackButton( - onPressed: () { - sheetController.collapse(); - controller.animateTo(0, duration: Durations.short4, curve: Easing.emphasizedDecelerate); - }, + floating: true, + snap: true, + pinned: true, ), - floating: true, - snap: true, - pinned: true, - ), - SliverPadding( - padding: EdgeInsets.only(bottom: context.padding.bottom), - sliver: SliverList.builder( - itemCount: grouped.length, - itemBuilder: (context, index) { - final MapEntry(key: date, value: reports) = grouped[index]; - - return ListSection( - title: date, - children: reports.map((report) { - final locationString = report.extractLocation(); - final location = Location.tryParse(locationString)?.dynamicName ?? locationString; - - return ListSectionTile( - leading: IntensityBox( - intensity: report.intensity, - size: 36, - borderRadius: 8, - border: !report.hasNumber, - ), - title: location, - subtitle: Text( - '${report.hasNumber ? '${'編號 {number} 顯著有感地震'.i18n.args({'number': report.number})}\n' : ''}${report.time.toLocaleTimeString(context)}・${report.depth}km', - ), - trailing: Text( - 'M ${report.magnitude.toStringAsFixed(1)}', - style: context.texts.labelLarge, - ), - onTap: () { - widget.manager.setReport(report.id); - sheetController.collapse(); - }, - ); - }).toList(), - ); - }, + SliverPadding( + padding: EdgeInsets.only(bottom: context.padding.bottom), + sliver: SliverList.builder( + itemCount: grouped.length, + itemBuilder: (context, index) { + final MapEntry(key: date, value: reports) = grouped[index]; + + return ListSection( + title: date, + children: reports.map((report) { + final locationString = report.extractLocation(); + final location = Location.tryParse(locationString)?.dynamicName ?? locationString; + + return ListSectionTile( + leading: IntensityBox( + intensity: report.intensity, + size: 36, + borderRadius: 8, + border: !report.hasNumber, + ), + title: location, + subtitle: Text( + '${report.hasNumber ? '${'編號 {number} 顯著有感地震'.i18n.args({'number': report.number})}\n' : ''}${report.time.toLocaleTimeString(context)}・${report.depth}km', + ), + trailing: Text( + 'M ${report.magnitude.toStringAsFixed(1)}', + style: context.texts.labelLarge, + ), + onTap: () { + widget.manager.setReport(report.id); + sheetController.collapse(); + }, + ); + }).toList(), + ); + }, + ), ), - ), - ], - ); - } + ], + ); + } - final report = GlobalProviders.data.report[currentReport.id]; + final report = GlobalProviders.data.report[currentReport.id]; - late List content; + late List content; - if (report == null) { - content = [const Center(child: CircularProgressIndicator())]; - } else { - final locationString = report.getLocation(); - final location = Location.tryParse(locationString)?.dynamicName ?? locationString; + if (report == null) { + content = [const Center(child: CircularProgressIndicator())]; + } else { + final locationString = report.getLocation(); + final location = Location.tryParse(locationString)?.dynamicName ?? locationString; - content = [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - IntensityBox(intensity: report.getMaxIntensity()), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - report.hasNumber - ? '編號 {number} 顯著有感地震'.i18n.args({'number': report.number}) - : '小區域有感地震'.i18n, - style: TextStyle(color: context.colors.onSurfaceVariant, fontSize: 14), - ), - Text(location, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - ], + content = [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + IntensityBox(intensity: report.getMaxIntensity()), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + report.hasNumber + ? '編號 {number} 顯著有感地震'.i18n.args({'number': report.number}) + : '小區域有感地震'.i18n, + style: TextStyle(color: context.colors.onSurfaceVariant, fontSize: 14), + ), + Text(location, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + ], + ), ), - ), - ], - ), - ), - Wrap( - spacing: 8, - children: [ - ActionChip( - avatar: Icon(Symbols.open_in_new, color: context.colors.onPrimary), - label: Text('報告頁面'.i18n), - backgroundColor: context.colors.primary, - labelStyle: TextStyle(color: context.colors.onPrimary), - side: BorderSide(color: context.colors.primary), - onPressed: () { - launchUrl(report.reportUrl); - }, + ], ), - /* ActionChip( + ), + Wrap( + spacing: 8, + children: [ + ActionChip( + avatar: Icon(Symbols.open_in_new, color: context.colors.onPrimary), + label: Text('報告頁面'.i18n), + backgroundColor: context.colors.primary, + labelStyle: TextStyle(color: context.colors.onPrimary), + side: BorderSide(color: context.colors.primary), + onPressed: () { + launchUrl(report.reportUrl); + }, + ), + /* ActionChip( avatar: const Icon(Symbols.replay), label: const Text('重播'), onPressed: () { @@ -628,209 +631,210 @@ class _ReportMapLayerSheetState extends State { ); }, ), */ - ], - ), - const Divider(), - DetailFieldTile( - label: '發震時間'.i18n, - child: Text( - DateFormat('yyyy/MM/dd HH:mm:ss').format(report.time), - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ], ), - ), - DetailFieldTile( - label: '位於'.i18n, - child: Text( - report.convertLatLon(), - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + const Divider(), + DetailFieldTile( + label: '發震時間'.i18n, + child: Text( + DateFormat('yyyy/MM/dd HH:mm:ss').format(report.time), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), ), - ), - Row( - children: [ - Expanded( - child: DetailFieldTile( - label: '地震規模'.i18n, - child: Row( - children: [ - Container( - height: 12, - width: 12, - margin: const EdgeInsets.only(right: 6), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: MagnitudeColor.magnitude(report.magnitude), + DetailFieldTile( + label: '位於'.i18n, + child: Text( + report.convertLatLon(), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + Row( + children: [ + Expanded( + child: DetailFieldTile( + label: '地震規模'.i18n, + child: Row( + children: [ + Container( + height: 12, + width: 12, + margin: const EdgeInsets.only(right: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: MagnitudeColor.magnitude(report.magnitude), + ), ), - ), - Text( - 'M ${report.magnitude}', - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ], + Text( + 'M ${report.magnitude}', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ], + ), ), ), - ), - Expanded( - child: DetailFieldTile( - label: '震源深度'.i18n, - child: Row( - children: [ - Container( - height: 12, - width: 12, - margin: const EdgeInsets.only(right: 6), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: getDepthColor(report.depth), + Expanded( + child: DetailFieldTile( + label: '震源深度'.i18n, + child: Row( + children: [ + Container( + height: 12, + width: 12, + margin: const EdgeInsets.only(right: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: getDepthColor(report.depth), + ), ), - ), - Text( - '${report.depth} km', - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ], + Text( + '${report.depth} km', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ], + ), ), ), - ), - ], - ), - const Divider(), - DetailFieldTile( - label: '各地震度'.i18n, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final MapEntry(key: areaName, value: area) in report.list.entries) - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Column( - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text(areaName, style: const TextStyle(fontWeight: FontWeight.bold)), - ), - const SizedBox(width: 20), - Expanded( - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final MapEntry(key: townName, value: town) in area.town.entries) - ActionChip( - padding: const EdgeInsets.all(4), - side: BorderSide(color: IntensityColor.intensity(town.intensity)), - backgroundColor: IntensityColor.intensity( - town.intensity, - ).withValues(alpha: 0.16), - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - avatar: AspectRatio( - aspectRatio: 1, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - color: IntensityColor.intensity(town.intensity), - ), - child: Center( - child: Text( - town.intensity.asIntensityDisplayLabel, - style: TextStyle( - height: 1, - fontSize: 15, - fontWeight: FontWeight.bold, - color: IntensityColor.onIntensity(town.intensity), + ], + ), + const Divider(), + DetailFieldTile( + label: '各地震度'.i18n, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final MapEntry(key: areaName, value: area) in report.list.entries) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(areaName, style: const TextStyle(fontWeight: FontWeight.bold)), + ), + const SizedBox(width: 20), + Expanded( + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final MapEntry(key: townName, value: town) in area.town.entries) + ActionChip( + padding: const EdgeInsets.all(4), + side: BorderSide(color: IntensityColor.intensity(town.intensity)), + backgroundColor: IntensityColor.intensity( + town.intensity, + ).withValues(alpha: 0.16), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + avatar: AspectRatio( + aspectRatio: 1, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: IntensityColor.intensity(town.intensity), + ), + child: Center( + child: Text( + town.intensity.asIntensityDisplayLabel, + style: TextStyle( + height: 1, + fontSize: 15, + fontWeight: FontWeight.bold, + color: IntensityColor.onIntensity(town.intensity), + ), ), ), ), ), + label: Text(townName), + onPressed: () { + sheetController.collapse(); + widget.manager.controller.animateCamera( + CameraUpdate.newLatLng(LatLng(town.lat, town.lon)), + ); + }, ), - label: Text(townName), - onPressed: () { - sheetController.collapse(); - widget.manager.controller.animateCamera( - CameraUpdate.newLatLng(LatLng(town.lat, town.lon)), - ); - }, - ), - ], + ], + ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), - ), - ], - ), - ), - const Divider(), - DetailFieldTile( - label: '地震報告圖'.i18n, - child: EnlargeableImage( - aspectRatio: 4 / 3, - heroTag: 'report-image-${report.id}', - imageUrl: report.reportImageUrl, - imageName: report.reportImageName, - ), - ), - if (report.hasNumber) - DetailFieldTile( - label: '震度圖'.i18n, - child: EnlargeableImage( - aspectRatio: 2334 / 2977, - heroTag: 'intensity-image-${report.id}', - imageUrl: report.intensityMapImageUrl!, - imageName: report.intensityMapImageName!, + ], ), ), - if (report.hasNumber) + const Divider(), DetailFieldTile( - label: '最大地動加速度圖'.i18n, + label: '地震報告圖'.i18n, child: EnlargeableImage( - aspectRatio: 2334 / 2977, - heroTag: 'pga-image-${report.id}', - imageUrl: report.pgaMapImageUrl!, - imageName: report.pgaMapImageName!, + aspectRatio: 4 / 3, + heroTag: 'report-image-${report.id}', + imageUrl: report.reportImageUrl, + imageName: report.reportImageName, ), ), - if (report.hasNumber) - DetailFieldTile( - label: '最大地動速度圖'.i18n, - child: EnlargeableImage( - aspectRatio: 2334 / 2977, - heroTag: 'pgv-image-${report.id}', - imageUrl: report.pgvMapImageUrl!, - imageName: report.pgvMapImageName!, + if (report.hasNumber) + DetailFieldTile( + label: '震度圖'.i18n, + child: EnlargeableImage( + aspectRatio: 2334 / 2977, + heroTag: 'intensity-image-${report.id}', + imageUrl: report.intensityMapImageUrl!, + imageName: report.intensityMapImageName!, + ), + ), + if (report.hasNumber) + DetailFieldTile( + label: '最大地動加速度圖'.i18n, + child: EnlargeableImage( + aspectRatio: 2334 / 2977, + heroTag: 'pga-image-${report.id}', + imageUrl: report.pgaMapImageUrl!, + imageName: report.pgaMapImageName!, + ), + ), + if (report.hasNumber) + DetailFieldTile( + label: '最大地動速度圖'.i18n, + child: EnlargeableImage( + aspectRatio: 2334 / 2977, + heroTag: 'pgv-image-${report.id}', + imageUrl: report.pgvMapImageUrl!, + imageName: report.pgvMapImageName!, + ), + ), + ]; + } + + return CustomScrollView( + controller: controller, + slivers: [ + SliverAppBar( + title: Text('地震報告'.i18n), + leading: BackButton( + onPressed: () { + widget.manager.setReport(null); + controller.animateTo(0, duration: Durations.short4, curve: Easing.emphasizedDecelerate); + }, ), + floating: true, + snap: true, + pinned: true, ), - ]; - } - - return CustomScrollView( - controller: controller, - slivers: [ - SliverAppBar( - title: Text('地震報告'.i18n), - leading: BackButton( - onPressed: () { - widget.manager.setReport(null); - controller.animateTo(0, duration: Durations.short4, curve: Easing.emphasizedDecelerate); - }, + SliverPadding( + padding: EdgeInsets.fromLTRB(16, 0, 16, context.padding.bottom), + sliver: SliverList.list(children: content), ), - floating: true, - snap: true, - pinned: true, - ), - SliverPadding( - padding: EdgeInsets.fromLTRB(16, 0, 16, context.padding.bottom), - sliver: SliverList.list(children: content), - ), - ], - ); - }, - ); - }, + ], + ); + }, + ); + }, + ), ); } } diff --git a/lib/widgets/responsive/responsive_container.dart b/lib/widgets/responsive/responsive_container.dart new file mode 100644 index 000000000..0b69cb77f --- /dev/null +++ b/lib/widgets/responsive/responsive_container.dart @@ -0,0 +1,58 @@ +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; + +enum ResponsiveMode { + content, + panel, +} + +class ResponsiveContainer extends StatelessWidget { + final Widget child; + final double? maxWidth; + final ResponsiveMode mode; + + const ResponsiveContainer({ + required this.child, + this.maxWidth, + this.mode = ResponsiveMode.content, + super.key, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final isLargeTablet = width >= 800; + + double contentMaxWidth; + Alignment alignment; + + switch (mode) { + case ResponsiveMode.panel: + contentMaxWidth = (maxWidth ?? constraints.maxWidth * 0.45); + alignment = isLargeTablet + ? Alignment.centerRight + : Alignment.center; + break; + + case ResponsiveMode.content: + default: + contentMaxWidth = width >= 600 + ? min(width * 0.9, maxWidth ?? 750) + : width; + alignment = Alignment.center; + } + + return Align( + alignment: alignment, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: contentMaxWidth), + child: child, + ), + ); + }, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 4207d9129..3df172fa7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: dpip description: "Disaster Prevention Information Platform" publish_to: "none" -version: 3.1.4 +version: 3.1.401+1 environment: sdk: ">=3.10.0 <4.0.0"