From 12084a9d7a3165d955015fff14c16b68252cfd47 Mon Sep 17 00:00:00 2001 From: lowrt Date: Mon, 22 Dec 2025 19:47:42 +0800 Subject: [PATCH 01/10] feat: Supports tablets --- lib/app/home/_widgets/date_timeline_item.dart | 157 +-- lib/app/home/_widgets/eew_card.dart | 346 +++---- lib/app/home/_widgets/forecast_card.dart | 277 +++--- .../home/_widgets/history_timeline_item.dart | 151 +-- lib/app/home/_widgets/radar_card.dart | 175 ++-- lib/app/home/_widgets/thunderstorm_card.dart | 147 +-- lib/app/home/_widgets/weather_header.dart | 369 +++---- lib/app/map/_lib/managers/monitor.dart | 91 +- lib/app/map/_lib/managers/report.dart | 907 +++++++++--------- 9 files changed, 1389 insertions(+), 1231 deletions(-) diff --git a/lib/app/home/_widgets/date_timeline_item.dart b/lib/app/home/_widgets/date_timeline_item.dart index c5a50baae..37862c4a5 100644 --- a/lib/app/home/_widgets/date_timeline_item.dart +++ b/lib/app/home/_widgets/date_timeline_item.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:dpip/app/home/_widgets/mode_toggle_button.dart'; import 'package:dpip/utils/extensions/build_context.dart'; import 'package:flutter/material.dart'; @@ -71,83 +73,100 @@ 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 LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + + final contentMaxWidth = maxWidth >= 600 + ? min(maxWidth * 0.9, 750.0) + : maxWidth; + + return Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: contentMaxWidth + ), + 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), - Text( - mode!.label, - style: context.theme.textTheme.labelMedium?.copyWith( - height: 1, - color: context.colors.onSecondaryContainer, - fontWeight: FontWeight.bold, + 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( + date, + style: context.theme.textTheme.labelLarge?.copyWith( + height: 1, + color: context.colors.onSecondaryContainer, + ), + ), + ], ), - ), - 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..4b3a9fba8 100644 --- a/lib/app/home/_widgets/eew_card.dart +++ b/lib/app/home/_widgets/eew_card.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math'; import 'package:dpip/api/model/eew.dart'; import 'package:dpip/app/map/_lib/utils.dart'; @@ -65,202 +66,219 @@ 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), + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + + final contentMaxWidth = maxWidth >= 600 + ? min(maxWidth * 0.9, 720.0) + : maxWidth; + + return Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: contentMaxWidth ), - padding: const EdgeInsets.all(12), - clipBehavior: Clip.antiAlias, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + child: Stack( 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), - ), - ], + IgnorePointer( + child: Container( + decoration: BoxDecoration( + color: context.colors.errorContainer, + border: Border.all(color: context.colors.error, width: 2), + borderRadius: BorderRadius.circular(16), ), - 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(); - } - - return Padding( - padding: const EdgeInsets.only(top: 8, bottom: 4), - child: IntrinsicHeight( - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + padding: const EdgeInsets.all(12), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, 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( + 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, - color: context.colors.onErrorContainer, - height: 1, - leadingDistribution: TextLeadingDistribution.even, ), - textAlign: TextAlign.center, ), - ), - ], + ], + ), ), - ), + Text( + '第 {serial} 報'.i18n.args({'serial': widget.data.serial}), + style: context.texts.bodyLarge!.copyWith(color: context.colors.onErrorContainer), + ), + ], ), - VerticalDivider(color: context.colors.onErrorContainer.withValues(alpha: 0.4), width: 24), - Expanded( - child: Padding( - padding: const EdgeInsets.all(4), - child: Column( + 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(); + } + + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: IntrinsicHeight( + child: Row( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '震波'.i18n, - style: context.texts.labelLarge!.copyWith( - color: context.colors.onErrorContainer.withValues(alpha: 0.6), + 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, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), ), ), - 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, + 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, ), - TextSpan( - text: ' 秒'.i18n, - style: TextStyle(fontSize: context.texts.labelLarge!.fontSize), - ), - ], + ), + 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, ), - 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..ed57761c4 100644 --- a/lib/app/home/_widgets/forecast_card.dart +++ b/lib/app/home/_widgets/forecast_card.dart @@ -83,146 +83,163 @@ 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) - 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; - } - return height + 4; - } + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; - final calculatedHeight = pages.isNotEmpty ? calculatePageHeight(_currentPage) : 0.0; - final pageHeight = _measuredHeights[_currentPage] ?? calculatedHeight; + final contentMaxWidth = maxWidth >= 600 + ? min(maxWidth * 0.9, 750.0) + : maxWidth; - 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(); - } + return Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: contentMaxWidth, + ), + 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.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) + 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; } - _measuredHeights.removeWhere((key, value) => key != index); - }); - }, - itemBuilder: (context, pageIndex) { - if (!_pageKeys.containsKey(pageIndex)) { - _pageKeys[pageIndex] = GlobalKey(); + return height + 4; } - 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, + + 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; + }); + } + _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(), + ), + ), ); - }).toList(), + }, ), ), ); }, ), - ), - ); - }, - ), - ], - ), + ], + ), + ) + ) + ); + }, ); } catch (e, s) { TalkerManager.instance.error('Failed to render forecast card', e, s); diff --git a/lib/app/home/_widgets/history_timeline_item.dart b/lib/app/home/_widgets/history_timeline_item.dart index 0c7e7f0e4..b89ab7277 100644 --- a/lib/app/home/_widgets/history_timeline_item.dart +++ b/lib/app/home/_widgets/history_timeline_item.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:dpip/api/model/history/history.dart'; import 'package:dpip/utils/extensions/build_context.dart'; import 'package:dpip/utils/list_icon.dart'; @@ -24,83 +26,100 @@ 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, - ), - ), - ), - ], - ), + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + + final contentMaxWidth = maxWidth >= 600 + ? min(maxWidth * 0.9, 750.0) + : maxWidth; + + return Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: contentMaxWidth ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: InkWell( + onTap: hasDetail ? () => handleEventList(context, history) : null, + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, 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), + 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, + ), + ), + ), + ], ), ), - Text( - history.text.content['all']!.subtitle, - style: context.theme.textTheme.titleMedium?.copyWith( - color: context.colors.onSurface.withValues(alpha: expired ? 0.6 : 1), + 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, + ), + ], + ), ), - 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), + if (hasDetail) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Icon(Symbols.chevron_right_rounded, color: context.colors.outline), ), - 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..d521ae43b 100644 --- a/lib/app/home/_widgets/radar_card.dart +++ b/lib/app/home/_widgets/radar_card.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:dpip/api/exptech.dart'; import 'package:dpip/api/route.dart'; import 'package:dpip/app/map/_lib/utils.dart'; @@ -68,91 +70,108 @@ 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), + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + + final contentMaxWidth = maxWidth >= 600 + ? min(maxWidth * 0.9, 720.0) + : maxWidth; + + return Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: contentMaxWidth ), - 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, + child: Stack( + children: [ + IgnorePointer( + child: Container( + decoration: BoxDecoration( + color: context.colors.surfaceContainer, + border: Border.all(color: context.colors.outlineVariant), + borderRadius: BorderRadius.circular(16), ), - ), - 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), - ], - ), - ); - }, + 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, ), - ], - ), - 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..34fc09ed4 100644 --- a/lib/app/home/_widgets/thunderstorm_card.dart +++ b/lib/app/home/_widgets/thunderstorm_card.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:dpip/api/model/history/history.dart'; import 'package:dpip/core/i18n.dart'; import 'package:dpip/route/event_viewer/thunderstorm.dart'; @@ -16,84 +18,101 @@ 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), + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + + final contentMaxWidth = maxWidth >= 600 + ? min(maxWidth * 0.9, 720.0) + : maxWidth; + + return Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: contentMaxWidth ), - padding: const EdgeInsets.all(12), - clipBehavior: Clip.antiAlias, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + child: Stack( children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - spacing: 8, + 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: [ - 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( - color: context.theme.extendedColors.onBlue, - fontWeight: FontWeight.bold, + 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( + 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..6d148784f 100644 --- a/lib/app/home/_widgets/weather_header.dart +++ b/lib/app/home/_widgets/weather_header.dart @@ -59,195 +59,210 @@ 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 LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; - 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), + final contentMaxWidth = maxWidth < 600 + ? maxWidth * 0.95 + : maxWidth; + + return Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: contentMaxWidth, + ), + 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..58b490b06 100644 --- a/lib/app/map/_lib/managers/monitor.dart +++ b/lib/app/map/_lib/managers/monitor.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:collection'; +import 'dart:math'; import 'package:dpip/api/model/eew.dart'; import 'package:dpip/app/map/_lib/manager.dart'; @@ -1303,44 +1304,58 @@ 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, - ); - - 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), + LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + final contentMaxWidth = maxWidth >= 600 + ? min(maxWidth * 0.9, 720.0) + : maxWidth; + + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: contentMaxWidth), + 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), + ), + ); + }, + ), ), ); }, diff --git a/lib/app/map/_lib/managers/report.dart b/lib/app/map/_lib/managers/report.dart index e95f118ed..cd25cce38 100644 --- a/lib/app/map/_lib/managers/report.dart +++ b/lib/app/map/_lib/managers/report.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:dpip/utils/extensions/number.dart'; import 'package:flutter/material.dart'; @@ -327,296 +329,307 @@ 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(); - 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, + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + final isTablet = maxWidth >= 900; + + final contentWidth = isTablet ? 550.0 : maxWidth; + + return Align( + alignment: isTablet ? Alignment.centerRight : Alignment.center, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: contentWidth), + 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.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, children: [ - Icon(Symbols.docs_rounded, size: 24, color: context.colors.onSurface), Expanded( - child: Text( - '近期的地震報告'.i18n, - style: context.texts.titleMedium?.copyWith(color: context.colors.onSurface), + 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), + ), + ], ), ), - Text( - '更多'.i18n, - style: context.texts.labelSmall?.copyWith(color: context.colors.outline), + IntensityBox( + intensity: currentReport.intensity, + size: 56, + borderRadius: 12, + border: !currentReport.hasNumber, ), - Icon(Symbols.swipe_up_rounded, size: 16, color: context.colors.outline), ], ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - spacing: 8, + Row( + spacing: 16, children: [ - IntensityBox( - intensity: report.intensity, - size: 48, - borderRadius: 12, - border: !report.hasNumber, + 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: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - report.hasNumber - ? '編號 {number} 顯著有感地震'.i18n.args({'number': report.number}) - : location, - style: context.texts.titleMedium, + '震源深度'.i18n, + style: context.texts.bodyMedium?.copyWith(color: context.colors.onSurfaceVariant), ), Text( - report.time.toLocaleDateTimeString(context), - style: context.texts.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), + '${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), + ); + }, + 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); + }, ), - Text( - 'M ${currentReport.magnitude.toStringAsFixed(1)}', - style: context.texts.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + 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(), + ); + }, ), - ], + ), + ], + ); + } + + final report = GlobalProviders.data.report[currentReport.id]; + + late List content; + + 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)), + ], + ), + ), + ], + ), ), - ), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Wrap( + spacing: 8, 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); - }, - ), - 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(); + 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); }, - ); - }).toList(), - ); - }, - ), - ), - ], - ); - } - - final report = GlobalProviders.data.report[currentReport.id]; - - late List content; - - 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)), - ], - ), - ), - ], - ), - ), - 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( + /* ActionChip( avatar: const Icon(Symbols.replay), label: const Text('重播'), onPressed: () { @@ -628,207 +641,211 @@ 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), - ), - ), - 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), - ), ], ), - ), - ), - Expanded( - child: DetailFieldTile( - label: '震源深度'.i18n, - child: Row( + 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), + ), + ), + 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: MagnitudeColor.magnitude(report.magnitude), + ), + ), + Text( + 'M ${report.magnitude}', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ], + ), ), ), - Text( - '${report.depth} km', - 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), + ), + ), + 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), + const Divider(), + DetailFieldTile( + label: '各地震度'.i18n, child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, 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), + 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) - 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, - ), - SliverPadding( - padding: EdgeInsets.fromLTRB(16, 0, 16, context.padding.bottom), - sliver: SliverList.list(children: content), - ), - ], - ); - }, + 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) + 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, + ), + SliverPadding( + padding: EdgeInsets.fromLTRB(16, 0, 16, context.padding.bottom), + sliver: SliverList.list(children: content), + ), + ], + ); + }, + ); + }, + ), + ), ); }, ); From f97c7a4d4f6e1dfc7a657da480f9975eb8ac9e35 Mon Sep 17 00:00:00 2001 From: lowrt Date: Mon, 22 Dec 2025 21:11:03 +0800 Subject: [PATCH 02/10] fix: widgets --- lib/app/home/_widgets/date_timeline_item.dart | 148 ++++--- lib/app/home/_widgets/eew_card.dart | 366 +++++++++--------- lib/app/home/_widgets/forecast_card.dart | 282 +++++++------- .../home/_widgets/history_timeline_item.dart | 156 ++++---- lib/app/home/_widgets/radar_card.dart | 171 ++++---- lib/app/home/_widgets/thunderstorm_card.dart | 5 +- lib/app/map/_lib/managers/monitor.dart | 98 +++-- .../responsive/responsive_container.dart | 41 ++ 8 files changed, 612 insertions(+), 655 deletions(-) create mode 100644 lib/widgets/responsive/responsive_container.dart diff --git a/lib/app/home/_widgets/date_timeline_item.dart b/lib/app/home/_widgets/date_timeline_item.dart index 37862c4a5..5df5ad1ef 100644 --- a/lib/app/home/_widgets/date_timeline_item.dart +++ b/lib/app/home/_widgets/date_timeline_item.dart @@ -1,7 +1,6 @@ -import 'dart:math'; - 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 { @@ -73,100 +72,85 @@ class DateTimelineItem extends StatelessWidget { @override Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final maxWidth = constraints.maxWidth; - - final contentMaxWidth = maxWidth >= 600 - ? min(maxWidth * 0.9, 750.0) - : maxWidth; - - return Center( - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: contentMaxWidth - ), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, + return ResponsiveContainer( + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Stack( + alignment: Alignment.centerLeft, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + Positioned( + left: 0, + top: first ? 21 : 0, + bottom: last ? null : 0, + height: last ? 21 : null, child: Stack( - alignment: Alignment.centerLeft, + alignment: Alignment.center, 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), - ), - ), - ], + 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, + ], + ), + ), + 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, + ), ), - 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( - date, - style: context.theme.textTheme.labelLarge?.copyWith( - height: 1, - color: context.colors.onSecondaryContainer, - ), - ), - ], + 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 4b3a9fba8..0c616dd77 100644 --- a/lib/app/home/_widgets/eew_card.dart +++ b/lib/app/home/_widgets/eew_card.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:math'; import 'package:dpip/api/model/eew.dart'; import 'package:dpip/app/map/_lib/utils.dart'; @@ -10,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'; @@ -66,219 +66,205 @@ class _EewCardState extends State { @override Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final maxWidth = constraints.maxWidth; - - final contentMaxWidth = maxWidth >= 600 - ? min(maxWidth * 0.9, 720.0) - : maxWidth; - - return Center( - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: contentMaxWidth - ), - 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, + 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: [ - Container( - decoration: BoxDecoration( - color: context.colors.error, - borderRadius: BorderRadius.circular(8), + 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, ), - padding: const EdgeInsets.fromLTRB(8, 6, 12, 6), - child: Row( + ), + ], + ), + ), + 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))}, + ), + ), + 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, - spacing: 4, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Icon(Symbols.crisis_alert_rounded, color: context.colors.onError, weight: 700, size: 22), Text( - 'EEW'.i18n, + '所在地預估'.i18n, style: context.texts.labelLarge!.copyWith( - color: context.colors.onError, - fontWeight: FontWeight.bold, + 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, + ), + textAlign: TextAlign.center, ), ), ], ), ), - 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))}, - ), - ), - 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), - ), - ), - 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, - ), - ), - ], + ), + 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, - ), - ), - 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, + 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, ), + 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, ), - ], + ), + 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 ed57761c4..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,163 +84,148 @@ class _ForecastCardState extends State { } final pages = _pages; - return LayoutBuilder( - builder: (context, constraints) { - final maxWidth = constraints.maxWidth; - - final contentMaxWidth = maxWidth >= 600 - ? min(maxWidth * 0.9, 750.0) - : maxWidth; - - return Center( - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: contentMaxWidth, - ), - 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, + 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: [ - 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) - 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, - ), - ), - ), - ], + 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), ), - 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; - } + 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; + } + 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) { + 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(() { - _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); + _measuredHeights[pageIndex] = h; }); - }, - 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(), - ), - ), - ); - }, + } + _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) { TalkerManager.instance.error('Failed to render forecast card', e, s); diff --git a/lib/app/home/_widgets/history_timeline_item.dart b/lib/app/home/_widgets/history_timeline_item.dart index b89ab7277..4973f30fe 100644 --- a/lib/app/home/_widgets/history_timeline_item.dart +++ b/lib/app/home/_widgets/history_timeline_item.dart @@ -1,9 +1,8 @@ -import 'dart:math'; - 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'; @@ -26,100 +25,85 @@ class HistoryTimelineItem extends StatelessWidget { Widget build(BuildContext context) { final hasDetail = shouldShowArrow(history); - return LayoutBuilder( - builder: (context, constraints) { - final maxWidth = constraints.maxWidth; - - final contentMaxWidth = maxWidth >= 600 - ? min(maxWidth * 0.9, 750.0) - : maxWidth; - - return Center( - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: contentMaxWidth - ), - child: InkWell( - onTap: hasDetail ? () => handleEventList(context, history) : null, - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, + 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: [ + Positioned( + top: first ? 42 : 0, + bottom: last ? 0 : 0, + width: 1, + child: Container(color: context.colors.outlineVariant), + ), 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, - ), - ), - ), - ], + 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, - 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, - ), - ], + ], + ), + ), + 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), ), ), - ), - if (hasDetail) - Padding( - padding: const EdgeInsets.only(left: 4), - child: Icon(Symbols.chevron_right_rounded, color: context.colors.outline), + 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 d521ae43b..03bb0de92 100644 --- a/lib/app/home/_widgets/radar_card.dart +++ b/lib/app/home/_widgets/radar_card.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:dpip/api/exptech.dart'; import 'package:dpip/api/route.dart'; import 'package:dpip/app/map/_lib/utils.dart'; @@ -11,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'; @@ -70,108 +69,94 @@ class _RadarMapCardState extends State { @override Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final maxWidth = constraints.maxWidth; - - final contentMaxWidth = maxWidth >= 600 - ? min(maxWidth * 0.9, 720.0) - : maxWidth; - - return Center( - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: contentMaxWidth - ), - child: Stack( - children: [ - IgnorePointer( - child: Container( - decoration: BoxDecoration( - color: context.colors.surfaceContainer, - border: Border.all(color: context.colors.outlineVariant), - borderRadius: BorderRadius.circular(16), + 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, + ), ), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Layout.col.min( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Layout.row.between( 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( + 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, - ); - - 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), - ], - ), - ); - }, + ), + Text(data.last.toSimpleDateTimeString(), style: style), + ], ), - ], - ), - const Icon(Symbols.chevron_right_rounded, size: 24), - ], - ), + ); + }, + ), + ], ), + 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 34fc09ed4..f8c48618e 100644 --- a/lib/app/home/_widgets/thunderstorm_card.dart +++ b/lib/app/home/_widgets/thunderstorm_card.dart @@ -6,6 +6,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/utils/responsive_constants.dart'; import 'package:flutter/material.dart'; import 'package:i18n_extension/i18n_extension.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; @@ -22,8 +23,8 @@ class ThunderstormCard extends StatelessWidget { builder: (context, constraints) { final maxWidth = constraints.maxWidth; - final contentMaxWidth = maxWidth >= 600 - ? min(maxWidth * 0.9, 720.0) + final contentMaxWidth = maxWidth >= ResponsiveBreakpoints.tablet + ? min(maxWidth * ResponsiveConstraints.contentPaddingMultiplier, ResponsiveConstraints.mapContentMaxWidth) : maxWidth; return Center( diff --git a/lib/app/map/_lib/managers/monitor.dart b/lib/app/map/_lib/managers/monitor.dart index 58b490b06..2036b0c39 100644 --- a/lib/app/map/_lib/managers/monitor.dart +++ b/lib/app/map/_lib/managers/monitor.dart @@ -18,6 +18,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'; @@ -1304,61 +1305,50 @@ class _MonitorMapLayerSheetState extends State { builder: (context, activeEew, child) { return Stack( children: [ - LayoutBuilder( - builder: (context, constraints) { - final maxWidth = constraints.maxWidth; - final contentMaxWidth = maxWidth >= 600 - ? min(maxWidth * 0.9, 720.0) - : maxWidth; - - return Center( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: contentMaxWidth), - 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), - ), - ); - }, + 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), ), - ), - ); - }, + ); + }, + ), ), Positioned( top: 26, diff --git a/lib/widgets/responsive/responsive_container.dart b/lib/widgets/responsive/responsive_container.dart new file mode 100644 index 000000000..0d6275070 --- /dev/null +++ b/lib/widgets/responsive/responsive_container.dart @@ -0,0 +1,41 @@ +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:dpip/utils/responsive_constants.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 contentMaxWidth = width >= ResponsiveBreakpoints.tablet + ? min(width * 0.9, maxWidth ?? ResponsiveConstraints.homeContentMaxWidth) + : width; + + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: contentMaxWidth), + child: child, + ), + ); + }, + ); + } +} From cd3792cadb1f4410d4c5a439da07542ffcadd1d6 Mon Sep 17 00:00:00 2001 From: lowrt Date: Mon, 22 Dec 2025 21:20:54 +0800 Subject: [PATCH 03/10] fix: widgets --- lib/app/home/_widgets/thunderstorm_card.dart | 158 ++- lib/app/map/_lib/managers/report.dart | 919 +++++++++--------- .../responsive/responsive_container.dart | 27 +- 3 files changed, 546 insertions(+), 558 deletions(-) diff --git a/lib/app/home/_widgets/thunderstorm_card.dart b/lib/app/home/_widgets/thunderstorm_card.dart index f8c48618e..109a9eeb8 100644 --- a/lib/app/home/_widgets/thunderstorm_card.dart +++ b/lib/app/home/_widgets/thunderstorm_card.dart @@ -1,12 +1,10 @@ -import 'dart:math'; - import 'package:dpip/api/model/history/history.dart'; import 'package:dpip/core/i18n.dart'; 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/utils/responsive_constants.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'; @@ -19,101 +17,87 @@ class ThunderstormCard extends StatelessWidget { @override Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final maxWidth = constraints.maxWidth; - - final contentMaxWidth = maxWidth >= ResponsiveBreakpoints.tablet - ? min(maxWidth * ResponsiveConstraints.contentPaddingMultiplier, ResponsiveConstraints.mapContentMaxWidth) - : maxWidth; - - return Center( - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: contentMaxWidth - ), - 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, + 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: [ - 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( - color: context.theme.extendedColors.onBlue, - fontWeight: FontWeight.bold, - ), - ), - ], + Icon( + Symbols.thunderstorm_rounded, + color: context.theme.extendedColors.onBlue, + 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), + ], ), - ), - 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), + 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), + ), ), ), - ); - }, + ], + ), ); } } diff --git a/lib/app/map/_lib/managers/report.dart b/lib/app/map/_lib/managers/report.dart index cd25cce38..fd090062f 100644 --- a/lib/app/map/_lib/managers/report.dart +++ b/lib/app/map/_lib/managers/report.dart @@ -1,6 +1,3 @@ -import 'dart:math'; - -import 'package:dpip/utils/extensions/number.dart'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; @@ -20,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'; @@ -35,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'; @@ -329,307 +328,298 @@ class _ReportMapLayerSheetState extends State { @override Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final maxWidth = constraints.maxWidth; - final isTablet = maxWidth >= 900; - - final contentWidth = isTablet ? 550.0 : maxWidth; - - return Align( - alignment: isTablet ? Alignment.centerRight : Alignment.center, - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: contentWidth), - 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, + 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: [ - 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), - ], + Icon(Symbols.docs_rounded, size: 24, color: context.colors.onSurface), + Expanded( + child: Text( + '近期的地震報告'.i18n, + style: context.texts.titleMedium?.copyWith(color: context.colors.onSurface), ), ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - spacing: 8, + 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: [ - IntensityBox( - intensity: report.intensity, - size: 48, - borderRadius: 12, - border: !report.hasNumber, + Text( + report.hasNumber + ? '編號 {number} 顯著有感地震'.i18n.args({'number': report.number}) + : location, + style: context.texts.titleMedium, ), - 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( + report.time.toLocaleDateTimeString(context), + style: context.texts.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, ), ), - Text('M ${report.magnitude.toStringAsFixed(1)}', style: context.texts.titleMedium), ], ), ), + 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( + }, + ), + ); + } + + // 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: 12, + spacing: 2, 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), - ), - ], - ), + Text( + currentReport.hasNumber + ? '編號 {number} 顯著有感地震'.i18n.args({'number': currentReport.number}) + : '小區域有感地震'.i18n, + style: context.texts.labelMedium?.copyWith(color: context.colors.outline), ), - IntensityBox( - intensity: currentReport.intensity, - size: 56, - borderRadius: 12, - border: !currentReport.hasNumber, + Text(location, style: context.texts.titleLarge?.copyWith(fontWeight: FontWeight.w500)), + Text( + currentReport.time.toLocaleDateTimeString(context), + style: context.texts.bodyMedium?.copyWith(color: context.colors.onSurfaceVariant), ), ], ), - Row( - spacing: 16, + ), + IntensityBox( + intensity: currentReport.intensity, + size: 56, + borderRadius: 12, + border: !currentReport.hasNumber, + ), + ], + ), + Row( + spacing: 16, + children: [ + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, 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), - ), - ], - ), + Text( + '地震規模'.i18n, + style: context.texts.bodyMedium?.copyWith(color: context.colors.onSurfaceVariant), ), - 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), - ), - ], - ), + Text( + 'M ${currentReport.magnitude.toStringAsFixed(1)}', + 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); - }, - ), - 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(), - ); - }, - ), - ), - ], - ); - } - - final report = GlobalProviders.data.report[currentReport.id]; - - late List content; - - 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), + ), + Expanded( child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, 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)), - ], + 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); + }, + ), + 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(), + ); + }, + ), + ), + ], + ); + } + + final report = GlobalProviders.data.report[currentReport.id]; + + late List content; + + 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)), ], ), ), - 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: () { @@ -641,213 +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), + ), + ), + 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), + ), + ], ), ), - 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), - ), - ], + ), + 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, + ), + ], + ), + 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: [ - 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), - ), - ), - ), + 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) - 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, - ), - SliverPadding( - padding: EdgeInsets.fromLTRB(16, 0, 16, context.padding.bottom), - sliver: SliverList.list(children: content), - ), ], - ); - }, - ); - }, - ), - ), - ); - }, + ), + ), + 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) + 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, + ), + 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 index 0d6275070..6f2e79f3a 100644 --- a/lib/widgets/responsive/responsive_container.dart +++ b/lib/widgets/responsive/responsive_container.dart @@ -1,7 +1,6 @@ import 'dart:math'; import 'package:flutter/cupertino.dart'; -import 'package:dpip/utils/responsive_constants.dart'; enum ResponsiveMode { content, @@ -25,11 +24,29 @@ class ResponsiveContainer extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; - final contentMaxWidth = width >= ResponsiveBreakpoints.tablet - ? min(width * 0.9, maxWidth ?? ResponsiveConstraints.homeContentMaxWidth) - : width; + final isLargeTablet = width >= 900; - return Center( + double contentMaxWidth; + Alignment alignment; + + switch (mode) { + case ResponsiveMode.panel: + contentMaxWidth = maxWidth ?? 550; + 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, From 6351a1eb583fc277db25ff9eef135c939f6ea344 Mon Sep 17 00:00:00 2001 From: lowrt Date: Mon, 22 Dec 2025 21:47:21 +0800 Subject: [PATCH 04/10] build: 3.1.401 --- android/app/build.gradle | 2 +- lib/app/map/_lib/managers/monitor.dart | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index efb51851e..1c25dea13 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 300104005 versionName flutterVersionName multiDexEnabled true resConfigs "en", "ko", "zh-rTW", "ja", "zh-rCN" diff --git a/lib/app/map/_lib/managers/monitor.dart b/lib/app/map/_lib/managers/monitor.dart index 2036b0c39..3afea6671 100644 --- a/lib/app/map/_lib/managers/monitor.dart +++ b/lib/app/map/_lib/managers/monitor.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:collection'; -import 'dart:math'; import 'package:dpip/api/model/eew.dart'; import 'package:dpip/app/map/_lib/manager.dart'; From c37462654db5bdd0809e795e70fb0ccdc4ad98ed Mon Sep 17 00:00:00 2001 From: lowrt Date: Mon, 22 Dec 2025 21:58:04 +0800 Subject: [PATCH 05/10] build: 3.1.401 --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 495485a4befbb2ec4162ae5478071546ec3b19e1 Mon Sep 17 00:00:00 2001 From: lowrt Date: Mon, 22 Dec 2025 22:59:22 +0800 Subject: [PATCH 06/10] fix(android): screen Orientation --- android/app/src/main/AndroidManifest.xml | 1 + android/app/src/main/res/values-sw600dp/styles.xml | 5 +++++ 2 files changed, 6 insertions(+) create mode 100644 android/app/src/main/res/values-sw600dp/styles.xml 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"> + + From 78f32ceb0c4f2c6caaf421090f1020b489ab97f6 Mon Sep 17 00:00:00 2001 From: lowrt Date: Mon, 22 Dec 2025 23:00:36 +0800 Subject: [PATCH 07/10] build: 300104006 --- android/app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 1c25dea13..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 300104005 + versionCode 300104006 versionName flutterVersionName multiDexEnabled true resConfigs "en", "ko", "zh-rTW", "ja", "zh-rCN" From c2d5be020fe090f2289466ce309ef57c5e4a4d8e Mon Sep 17 00:00:00 2001 From: lowrt Date: Tue, 23 Dec 2025 09:17:32 +0800 Subject: [PATCH 08/10] fix: widgets --- lib/app/home/_widgets/weather_header.dart | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/lib/app/home/_widgets/weather_header.dart b/lib/app/home/_widgets/weather_header.dart index 6d148784f..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,19 +60,9 @@ 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 LayoutBuilder( - builder: (context, constraints) { - final maxWidth = constraints.maxWidth; - - final contentMaxWidth = maxWidth < 600 - ? maxWidth * 0.95 - : maxWidth; - - return Center( - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: contentMaxWidth, - ), + return ResponsiveContainer( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), child: Column( spacing: 12, children: [ @@ -260,9 +251,7 @@ class WeatherHeader extends StatelessWidget { ), ], ), - ), - ); - }, + ), ); } From b6b1ea12d6c8249106bbff01e8e3e3dde47c3b9e Mon Sep 17 00:00:00 2001 From: lowrt Date: Tue, 23 Dec 2025 13:30:32 +0800 Subject: [PATCH 09/10] fix: Tablet width --- lib/widgets/responsive/responsive_container.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/responsive/responsive_container.dart b/lib/widgets/responsive/responsive_container.dart index 6f2e79f3a..d0995f5cc 100644 --- a/lib/widgets/responsive/responsive_container.dart +++ b/lib/widgets/responsive/responsive_container.dart @@ -24,7 +24,7 @@ class ResponsiveContainer extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; - final isLargeTablet = width >= 900; + final isLargeTablet = width >= 800; double contentMaxWidth; Alignment alignment; From ee49c1efb6d9f354aa91dcc353e91832d7a657a4 Mon Sep 17 00:00:00 2001 From: lowrt Date: Tue, 23 Dec 2025 20:01:53 +0800 Subject: [PATCH 10/10] fix: Tablet width --- lib/widgets/responsive/responsive_container.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/responsive/responsive_container.dart b/lib/widgets/responsive/responsive_container.dart index d0995f5cc..0b69cb77f 100644 --- a/lib/widgets/responsive/responsive_container.dart +++ b/lib/widgets/responsive/responsive_container.dart @@ -31,7 +31,7 @@ class ResponsiveContainer extends StatelessWidget { switch (mode) { case ResponsiveMode.panel: - contentMaxWidth = maxWidth ?? 550; + contentMaxWidth = (maxWidth ?? constraints.maxWidth * 0.45); alignment = isLargeTablet ? Alignment.centerRight : Alignment.center;