diff --git a/assets/map/icons/cross.png b/assets/map/icons/cross.png new file mode 100644 index 000000000..4e177ddbb Binary files /dev/null and b/assets/map/icons/cross.png differ diff --git a/assets/map/icons/intensity-1-dark.png b/assets/map/icons/intensity-1-dark.png new file mode 100644 index 000000000..b28a3d6a1 Binary files /dev/null and b/assets/map/icons/intensity-1-dark.png differ diff --git a/assets/map/icons/intensity-1.png b/assets/map/icons/intensity-1.png new file mode 100644 index 000000000..0b6649fa1 Binary files /dev/null and b/assets/map/icons/intensity-1.png differ diff --git a/assets/map/icons/intensity-2-dark.png b/assets/map/icons/intensity-2-dark.png new file mode 100644 index 000000000..8caa74b21 Binary files /dev/null and b/assets/map/icons/intensity-2-dark.png differ diff --git a/assets/map/icons/intensity-2.png b/assets/map/icons/intensity-2.png new file mode 100644 index 000000000..e9004fe7a Binary files /dev/null and b/assets/map/icons/intensity-2.png differ diff --git a/assets/map/icons/intensity-3-dark.png b/assets/map/icons/intensity-3-dark.png new file mode 100644 index 000000000..a449b02ba Binary files /dev/null and b/assets/map/icons/intensity-3-dark.png differ diff --git a/assets/map/icons/intensity-3.png b/assets/map/icons/intensity-3.png new file mode 100644 index 000000000..26c4a2ecd Binary files /dev/null and b/assets/map/icons/intensity-3.png differ diff --git a/assets/map/icons/intensity-4-dark.png b/assets/map/icons/intensity-4-dark.png new file mode 100644 index 000000000..f991a0a1d Binary files /dev/null and b/assets/map/icons/intensity-4-dark.png differ diff --git a/assets/map/icons/intensity-4.png b/assets/map/icons/intensity-4.png new file mode 100644 index 000000000..7c79b08d2 Binary files /dev/null and b/assets/map/icons/intensity-4.png differ diff --git a/assets/map/icons/intensity-5-dark.png b/assets/map/icons/intensity-5-dark.png new file mode 100644 index 000000000..75b7730c6 Binary files /dev/null and b/assets/map/icons/intensity-5-dark.png differ diff --git a/assets/map/icons/intensity-5.png b/assets/map/icons/intensity-5.png new file mode 100644 index 000000000..1cd02c24c Binary files /dev/null and b/assets/map/icons/intensity-5.png differ diff --git a/assets/map/icons/intensity-6-dark.png b/assets/map/icons/intensity-6-dark.png new file mode 100644 index 000000000..f87d569de Binary files /dev/null and b/assets/map/icons/intensity-6-dark.png differ diff --git a/assets/map/icons/intensity-6.png b/assets/map/icons/intensity-6.png new file mode 100644 index 000000000..f7afc68ab Binary files /dev/null and b/assets/map/icons/intensity-6.png differ diff --git a/assets/map/icons/intensity-7-dark.png b/assets/map/icons/intensity-7-dark.png new file mode 100644 index 000000000..867a20de4 Binary files /dev/null and b/assets/map/icons/intensity-7-dark.png differ diff --git a/assets/map/icons/intensity-7.png b/assets/map/icons/intensity-7.png new file mode 100644 index 000000000..eca3d1e99 Binary files /dev/null and b/assets/map/icons/intensity-7.png differ diff --git a/assets/map/icons/intensity-8-dark.png b/assets/map/icons/intensity-8-dark.png new file mode 100644 index 000000000..02f2e07b6 Binary files /dev/null and b/assets/map/icons/intensity-8-dark.png differ diff --git a/assets/map/icons/intensity-8.png b/assets/map/icons/intensity-8.png new file mode 100644 index 000000000..4480547ca Binary files /dev/null and b/assets/map/icons/intensity-8.png differ diff --git a/assets/map/icons/intensity-9-dark.png b/assets/map/icons/intensity-9-dark.png new file mode 100644 index 000000000..525654176 Binary files /dev/null and b/assets/map/icons/intensity-9-dark.png differ diff --git a/assets/map/icons/intensity-9.png b/assets/map/icons/intensity-9.png new file mode 100644 index 000000000..3f1fd0d64 Binary files /dev/null and b/assets/map/icons/intensity-9.png differ diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 77d233cea..53d0fa9df 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -2,6 +2,7 @@ import 'package:dpip/app/shell/main_shell.dart'; import 'package:dpip/core/settings/onboarding_store.dart'; import 'package:dpip/features/data/presentation/pages/data_page.dart'; import 'package:dpip/features/earthquake/presentation/pages/earthquake_page.dart'; +import 'package:dpip/features/earthquake/presentation/pages/report_detail_page.dart'; import 'package:dpip/features/earthquake/presentation/pages/report_list_page.dart'; import 'package:dpip/features/events/presentation/pages/events_page.dart'; import 'package:dpip/features/home/presentation/pages/home_page.dart'; @@ -71,6 +72,15 @@ final GoRouter appRouter = GoRouter( path: AppRoutes.earthquakePath, name: AppRoutes.earthquake, builder: (_, _) => const ReportListPage(), + routes: [ + GoRoute( + path: AppRoutes.earthquakeReportPath, + name: AppRoutes.earthquakeReport, + builder: (_, state) => ReportDetailPage( + reportId: state.pathParameters['id']!, + ), + ), + ], ), GoRoute( path: AppRoutes.eewPath, diff --git a/lib/features/earthquake/data/report_repository_impl.dart b/lib/features/earthquake/data/report_repository_impl.dart index 69f896631..bbc66b02f 100644 --- a/lib/features/earthquake/data/report_repository_impl.dart +++ b/lib/features/earthquake/data/report_repository_impl.dart @@ -4,6 +4,7 @@ library; import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/network/api_exception.dart'; import 'package:dpip/features/earthquake/data/earthquake_api.dart'; +import 'package:dpip/features/earthquake/domain/earthquake_report.dart'; import 'package:dpip/features/earthquake/domain/partial_earthquake_report.dart'; import 'package:dpip/features/earthquake/domain/report_list_query.dart'; import 'package:dpip/features/earthquake/domain/report_repository.dart'; @@ -40,6 +41,12 @@ class ReportRepositoryImpl implements ReportRepository { return parseReportList(raw); }); + @override + Future> get(String id) => guardResult(() async { + final raw = await _api.getReport(id); + return EarthquakeReport.fromJson((raw as Map).cast()); + }); + /// Skips malformed rows so one bad record cannot blank the catalogue. static List parseReportList(List raw) { return [ diff --git a/lib/features/earthquake/domain/earthquake_report.dart b/lib/features/earthquake/domain/earthquake_report.dart new file mode 100644 index 000000000..64e6d964c --- /dev/null +++ b/lib/features/earthquake/domain/earthquake_report.dart @@ -0,0 +1,127 @@ +/// Full earthquake report — `GET /api/v2/eq/report/{id}`, the per-area/town +/// intensity breakdown behind a catalogue row ([PartialEarthquakeReport]). +library; + +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'earthquake_report.freezed.dart'; +part 'earthquake_report.g.dart'; + +/// The full earthquake report — epicentre, magnitude, and the per-area/town +/// felt-intensity breakdown. Fetched by id from [ReportRepository.get] when a +/// catalogue row is opened. +@freezed +abstract class EarthquakeReport with _$EarthquakeReport { + const EarthquakeReport._(); + + const factory EarthquakeReport({ + required String id, + @JsonKey(name: 'lon') required double longitude, + @JsonKey(name: 'lat') required double latitude, + @JsonKey(name: 'loc') required String location, + required double depth, + @JsonKey(name: 'mag') required double magnitude, + required Map list, + + /// Origin time as Unix **milliseconds**. + required int time, + required int trem, + }) = _EarthquakeReport; + + factory EarthquakeReport.fromJson(Map json) => + _$EarthquakeReportFromJson(json); + + /// Leading CWA serial segment (e.g. `115032` or `115000`). + String get serial => id.split('-').first; + + /// `…000` serials are 小區域有感 — no numbered CWA report. + bool get isLocalFelt => serial.endsWith('000'); + + /// Numbered CWA report id, or null when [isLocalFelt]. + String? get number => isLocalFelt ? null : serial; + + bool get hasNumber => number != null; + + /// Origin time in UTC. + DateTime get originTimeUtc => + DateTime.fromMillisecondsSinceEpoch(time, isUtc: true); + + /// Short place string — prefer the parenthetical CWA locality when present. + String get shortLocation { + final open = location.indexOf('('); + final close = location.indexOf(')'); + if (open >= 0 && close > open) { + var inner = location.substring(open + 1, close); + if (inner.startsWith('位於')) inner = inner.substring(2); + return inner.trim(); + } + final fang = location.indexOf('方'); + if (fang >= 0) return location.substring(0, fang + 1).trim(); + return location.trim(); + } + + /// Highest observed intensity across every area/town in [list]. + int get maxIntensity { + var max = 0; + for (final area in list.values) { + for (final town in area.town.values) { + if (town.intensity > max) max = town.intensity; + } + } + return max; + } + + /// The official CWA report page for this event. + Uri get reportUrl { + final segments = id.split('-')..removeAt(0); + final magCode = (magnitude * 10).floor(); + final numberSuffix = hasNumber ? number!.substring(3) : ''; + return Uri.parse( + 'https://scweb.cwa.gov.tw/zh-tw/earthquake/details/' + '${segments.join()}$magCode$numberSuffix', + ); + } + + /// CWA's rendered report image (地震報告圖). The filename is derived from the + /// Taipei-local origin time, magnitude, and (when numbered) the report's + /// serial suffix — CWA doesn't expose this as a field, only as a static path. + Uri get reportImageUrl { + final t = originTimeUtc.add(const Duration(hours: 8)); // Asia/Taipei + final y = t.year.toString(); + final mo = t.month.toString().padLeft(2, '0'); + final d = t.day.toString().padLeft(2, '0'); + final h = t.hour.toString().padLeft(2, '0'); + final mi = t.minute.toString().padLeft(2, '0'); + final s = t.second.toString().padLeft(2, '0'); + final magCode = (magnitude * 10).floor(); + final numberSuffix = hasNumber ? number!.substring(3) : ''; + final name = '$y$mo$d$h$mi$s$magCode${numberSuffix}_H.png'; + final yearMonth = name.substring(0, 6); + return Uri.parse('https://scweb.cwa.gov.tw/webdata/OLDEQ/$yearMonth/$name'); + } +} + +/// One area's (縣市) maximum observed intensity and its station/town breakdown. +@freezed +abstract class AreaIntensity with _$AreaIntensity { + const factory AreaIntensity({ + @JsonKey(name: 'int') required int intensity, + required Map town, + }) = _AreaIntensity; + + factory AreaIntensity.fromJson(Map json) => + _$AreaIntensityFromJson(json); +} + +/// One station/town's observed intensity and coordinates. +@freezed +abstract class StationIntensity with _$StationIntensity { + const factory StationIntensity({ + @JsonKey(name: 'lon') required double longitude, + @JsonKey(name: 'lat') required double latitude, + @JsonKey(name: 'int') required int intensity, + }) = _StationIntensity; + + factory StationIntensity.fromJson(Map json) => + _$StationIntensityFromJson(json); +} diff --git a/lib/features/earthquake/domain/earthquake_report.freezed.dart b/lib/features/earthquake/domain/earthquake_report.freezed.dart new file mode 100644 index 000000000..af998b702 --- /dev/null +++ b/lib/features/earthquake/domain/earthquake_report.freezed.dart @@ -0,0 +1,850 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'earthquake_report.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$EarthquakeReport { + + String get id;@JsonKey(name: 'lon') double get longitude;@JsonKey(name: 'lat') double get latitude;@JsonKey(name: 'loc') String get location; double get depth;@JsonKey(name: 'mag') double get magnitude; Map get list;/// Origin time as Unix **milliseconds**. + int get time; int get trem; +/// Create a copy of EarthquakeReport +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EarthquakeReportCopyWith get copyWith => _$EarthquakeReportCopyWithImpl(this as EarthquakeReport, _$identity); + + /// Serializes this EarthquakeReport to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is EarthquakeReport&&(identical(other.id, id) || other.id == id)&&(identical(other.longitude, longitude) || other.longitude == longitude)&&(identical(other.latitude, latitude) || other.latitude == latitude)&&(identical(other.location, location) || other.location == location)&&(identical(other.depth, depth) || other.depth == depth)&&(identical(other.magnitude, magnitude) || other.magnitude == magnitude)&&const DeepCollectionEquality().equals(other.list, list)&&(identical(other.time, time) || other.time == time)&&(identical(other.trem, trem) || other.trem == trem)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,longitude,latitude,location,depth,magnitude,const DeepCollectionEquality().hash(list),time,trem); + +@override +String toString() { + return 'EarthquakeReport(id: $id, longitude: $longitude, latitude: $latitude, location: $location, depth: $depth, magnitude: $magnitude, list: $list, time: $time, trem: $trem)'; +} + + +} + +/// @nodoc +abstract mixin class $EarthquakeReportCopyWith<$Res> { + factory $EarthquakeReportCopyWith(EarthquakeReport value, $Res Function(EarthquakeReport) _then) = _$EarthquakeReportCopyWithImpl; +@useResult +$Res call({ + String id,@JsonKey(name: 'lon') double longitude,@JsonKey(name: 'lat') double latitude,@JsonKey(name: 'loc') String location, double depth,@JsonKey(name: 'mag') double magnitude, Map list, int time, int trem +}); + + + + +} +/// @nodoc +class _$EarthquakeReportCopyWithImpl<$Res> + implements $EarthquakeReportCopyWith<$Res> { + _$EarthquakeReportCopyWithImpl(this._self, this._then); + + final EarthquakeReport _self; + final $Res Function(EarthquakeReport) _then; + +/// Create a copy of EarthquakeReport +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? longitude = null,Object? latitude = null,Object? location = null,Object? depth = null,Object? magnitude = null,Object? list = null,Object? time = null,Object? trem = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable +as double,latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable +as double,location: null == location ? _self.location : location // ignore: cast_nullable_to_non_nullable +as String,depth: null == depth ? _self.depth : depth // ignore: cast_nullable_to_non_nullable +as double,magnitude: null == magnitude ? _self.magnitude : magnitude // ignore: cast_nullable_to_non_nullable +as double,list: null == list ? _self.list : list // ignore: cast_nullable_to_non_nullable +as Map,time: null == time ? _self.time : time // ignore: cast_nullable_to_non_nullable +as int,trem: null == trem ? _self.trem : trem // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [EarthquakeReport]. +extension EarthquakeReportPatterns on EarthquakeReport { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _EarthquakeReport value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _EarthquakeReport() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _EarthquakeReport value) $default,){ +final _that = this; +switch (_that) { +case _EarthquakeReport(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _EarthquakeReport value)? $default,){ +final _that = this; +switch (_that) { +case _EarthquakeReport() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, @JsonKey(name: 'lon') double longitude, @JsonKey(name: 'lat') double latitude, @JsonKey(name: 'loc') String location, double depth, @JsonKey(name: 'mag') double magnitude, Map list, int time, int trem)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _EarthquakeReport() when $default != null: +return $default(_that.id,_that.longitude,_that.latitude,_that.location,_that.depth,_that.magnitude,_that.list,_that.time,_that.trem);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, @JsonKey(name: 'lon') double longitude, @JsonKey(name: 'lat') double latitude, @JsonKey(name: 'loc') String location, double depth, @JsonKey(name: 'mag') double magnitude, Map list, int time, int trem) $default,) {final _that = this; +switch (_that) { +case _EarthquakeReport(): +return $default(_that.id,_that.longitude,_that.latitude,_that.location,_that.depth,_that.magnitude,_that.list,_that.time,_that.trem);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, @JsonKey(name: 'lon') double longitude, @JsonKey(name: 'lat') double latitude, @JsonKey(name: 'loc') String location, double depth, @JsonKey(name: 'mag') double magnitude, Map list, int time, int trem)? $default,) {final _that = this; +switch (_that) { +case _EarthquakeReport() when $default != null: +return $default(_that.id,_that.longitude,_that.latitude,_that.location,_that.depth,_that.magnitude,_that.list,_that.time,_that.trem);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _EarthquakeReport extends EarthquakeReport { + const _EarthquakeReport({required this.id, @JsonKey(name: 'lon') required this.longitude, @JsonKey(name: 'lat') required this.latitude, @JsonKey(name: 'loc') required this.location, required this.depth, @JsonKey(name: 'mag') required this.magnitude, required final Map list, required this.time, required this.trem}): _list = list,super._(); + factory _EarthquakeReport.fromJson(Map json) => _$EarthquakeReportFromJson(json); + +@override final String id; +@override@JsonKey(name: 'lon') final double longitude; +@override@JsonKey(name: 'lat') final double latitude; +@override@JsonKey(name: 'loc') final String location; +@override final double depth; +@override@JsonKey(name: 'mag') final double magnitude; + final Map _list; +@override Map get list { + if (_list is EqualUnmodifiableMapView) return _list; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_list); +} + +/// Origin time as Unix **milliseconds**. +@override final int time; +@override final int trem; + +/// Create a copy of EarthquakeReport +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EarthquakeReportCopyWith<_EarthquakeReport> get copyWith => __$EarthquakeReportCopyWithImpl<_EarthquakeReport>(this, _$identity); + +@override +Map toJson() { + return _$EarthquakeReportToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EarthquakeReport&&(identical(other.id, id) || other.id == id)&&(identical(other.longitude, longitude) || other.longitude == longitude)&&(identical(other.latitude, latitude) || other.latitude == latitude)&&(identical(other.location, location) || other.location == location)&&(identical(other.depth, depth) || other.depth == depth)&&(identical(other.magnitude, magnitude) || other.magnitude == magnitude)&&const DeepCollectionEquality().equals(other._list, _list)&&(identical(other.time, time) || other.time == time)&&(identical(other.trem, trem) || other.trem == trem)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,longitude,latitude,location,depth,magnitude,const DeepCollectionEquality().hash(_list),time,trem); + +@override +String toString() { + return 'EarthquakeReport(id: $id, longitude: $longitude, latitude: $latitude, location: $location, depth: $depth, magnitude: $magnitude, list: $list, time: $time, trem: $trem)'; +} + + +} + +/// @nodoc +abstract mixin class _$EarthquakeReportCopyWith<$Res> implements $EarthquakeReportCopyWith<$Res> { + factory _$EarthquakeReportCopyWith(_EarthquakeReport value, $Res Function(_EarthquakeReport) _then) = __$EarthquakeReportCopyWithImpl; +@override @useResult +$Res call({ + String id,@JsonKey(name: 'lon') double longitude,@JsonKey(name: 'lat') double latitude,@JsonKey(name: 'loc') String location, double depth,@JsonKey(name: 'mag') double magnitude, Map list, int time, int trem +}); + + + + +} +/// @nodoc +class __$EarthquakeReportCopyWithImpl<$Res> + implements _$EarthquakeReportCopyWith<$Res> { + __$EarthquakeReportCopyWithImpl(this._self, this._then); + + final _EarthquakeReport _self; + final $Res Function(_EarthquakeReport) _then; + +/// Create a copy of EarthquakeReport +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? longitude = null,Object? latitude = null,Object? location = null,Object? depth = null,Object? magnitude = null,Object? list = null,Object? time = null,Object? trem = null,}) { + return _then(_EarthquakeReport( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable +as double,latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable +as double,location: null == location ? _self.location : location // ignore: cast_nullable_to_non_nullable +as String,depth: null == depth ? _self.depth : depth // ignore: cast_nullable_to_non_nullable +as double,magnitude: null == magnitude ? _self.magnitude : magnitude // ignore: cast_nullable_to_non_nullable +as double,list: null == list ? _self._list : list // ignore: cast_nullable_to_non_nullable +as Map,time: null == time ? _self.time : time // ignore: cast_nullable_to_non_nullable +as int,trem: null == trem ? _self.trem : trem // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + + +/// @nodoc +mixin _$AreaIntensity { + +@JsonKey(name: 'int') int get intensity; Map get town; +/// Create a copy of AreaIntensity +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AreaIntensityCopyWith get copyWith => _$AreaIntensityCopyWithImpl(this as AreaIntensity, _$identity); + + /// Serializes this AreaIntensity to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AreaIntensity&&(identical(other.intensity, intensity) || other.intensity == intensity)&&const DeepCollectionEquality().equals(other.town, town)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,intensity,const DeepCollectionEquality().hash(town)); + +@override +String toString() { + return 'AreaIntensity(intensity: $intensity, town: $town)'; +} + + +} + +/// @nodoc +abstract mixin class $AreaIntensityCopyWith<$Res> { + factory $AreaIntensityCopyWith(AreaIntensity value, $Res Function(AreaIntensity) _then) = _$AreaIntensityCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: 'int') int intensity, Map town +}); + + + + +} +/// @nodoc +class _$AreaIntensityCopyWithImpl<$Res> + implements $AreaIntensityCopyWith<$Res> { + _$AreaIntensityCopyWithImpl(this._self, this._then); + + final AreaIntensity _self; + final $Res Function(AreaIntensity) _then; + +/// Create a copy of AreaIntensity +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? intensity = null,Object? town = null,}) { + return _then(_self.copyWith( +intensity: null == intensity ? _self.intensity : intensity // ignore: cast_nullable_to_non_nullable +as int,town: null == town ? _self.town : town // ignore: cast_nullable_to_non_nullable +as Map, + )); +} + +} + + +/// Adds pattern-matching-related methods to [AreaIntensity]. +extension AreaIntensityPatterns on AreaIntensity { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _AreaIntensity value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _AreaIntensity() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _AreaIntensity value) $default,){ +final _that = this; +switch (_that) { +case _AreaIntensity(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _AreaIntensity value)? $default,){ +final _that = this; +switch (_that) { +case _AreaIntensity() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: 'int') int intensity, Map town)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _AreaIntensity() when $default != null: +return $default(_that.intensity,_that.town);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: 'int') int intensity, Map town) $default,) {final _that = this; +switch (_that) { +case _AreaIntensity(): +return $default(_that.intensity,_that.town);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: 'int') int intensity, Map town)? $default,) {final _that = this; +switch (_that) { +case _AreaIntensity() when $default != null: +return $default(_that.intensity,_that.town);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _AreaIntensity implements AreaIntensity { + const _AreaIntensity({@JsonKey(name: 'int') required this.intensity, required final Map town}): _town = town; + factory _AreaIntensity.fromJson(Map json) => _$AreaIntensityFromJson(json); + +@override@JsonKey(name: 'int') final int intensity; + final Map _town; +@override Map get town { + if (_town is EqualUnmodifiableMapView) return _town; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_town); +} + + +/// Create a copy of AreaIntensity +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AreaIntensityCopyWith<_AreaIntensity> get copyWith => __$AreaIntensityCopyWithImpl<_AreaIntensity>(this, _$identity); + +@override +Map toJson() { + return _$AreaIntensityToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AreaIntensity&&(identical(other.intensity, intensity) || other.intensity == intensity)&&const DeepCollectionEquality().equals(other._town, _town)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,intensity,const DeepCollectionEquality().hash(_town)); + +@override +String toString() { + return 'AreaIntensity(intensity: $intensity, town: $town)'; +} + + +} + +/// @nodoc +abstract mixin class _$AreaIntensityCopyWith<$Res> implements $AreaIntensityCopyWith<$Res> { + factory _$AreaIntensityCopyWith(_AreaIntensity value, $Res Function(_AreaIntensity) _then) = __$AreaIntensityCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: 'int') int intensity, Map town +}); + + + + +} +/// @nodoc +class __$AreaIntensityCopyWithImpl<$Res> + implements _$AreaIntensityCopyWith<$Res> { + __$AreaIntensityCopyWithImpl(this._self, this._then); + + final _AreaIntensity _self; + final $Res Function(_AreaIntensity) _then; + +/// Create a copy of AreaIntensity +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? intensity = null,Object? town = null,}) { + return _then(_AreaIntensity( +intensity: null == intensity ? _self.intensity : intensity // ignore: cast_nullable_to_non_nullable +as int,town: null == town ? _self._town : town // ignore: cast_nullable_to_non_nullable +as Map, + )); +} + + +} + + +/// @nodoc +mixin _$StationIntensity { + +@JsonKey(name: 'lon') double get longitude;@JsonKey(name: 'lat') double get latitude;@JsonKey(name: 'int') int get intensity; +/// Create a copy of StationIntensity +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$StationIntensityCopyWith get copyWith => _$StationIntensityCopyWithImpl(this as StationIntensity, _$identity); + + /// Serializes this StationIntensity to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is StationIntensity&&(identical(other.longitude, longitude) || other.longitude == longitude)&&(identical(other.latitude, latitude) || other.latitude == latitude)&&(identical(other.intensity, intensity) || other.intensity == intensity)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,longitude,latitude,intensity); + +@override +String toString() { + return 'StationIntensity(longitude: $longitude, latitude: $latitude, intensity: $intensity)'; +} + + +} + +/// @nodoc +abstract mixin class $StationIntensityCopyWith<$Res> { + factory $StationIntensityCopyWith(StationIntensity value, $Res Function(StationIntensity) _then) = _$StationIntensityCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: 'lon') double longitude,@JsonKey(name: 'lat') double latitude,@JsonKey(name: 'int') int intensity +}); + + + + +} +/// @nodoc +class _$StationIntensityCopyWithImpl<$Res> + implements $StationIntensityCopyWith<$Res> { + _$StationIntensityCopyWithImpl(this._self, this._then); + + final StationIntensity _self; + final $Res Function(StationIntensity) _then; + +/// Create a copy of StationIntensity +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? longitude = null,Object? latitude = null,Object? intensity = null,}) { + return _then(_self.copyWith( +longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable +as double,latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable +as double,intensity: null == intensity ? _self.intensity : intensity // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [StationIntensity]. +extension StationIntensityPatterns on StationIntensity { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _StationIntensity value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _StationIntensity() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _StationIntensity value) $default,){ +final _that = this; +switch (_that) { +case _StationIntensity(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _StationIntensity value)? $default,){ +final _that = this; +switch (_that) { +case _StationIntensity() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: 'lon') double longitude, @JsonKey(name: 'lat') double latitude, @JsonKey(name: 'int') int intensity)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _StationIntensity() when $default != null: +return $default(_that.longitude,_that.latitude,_that.intensity);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: 'lon') double longitude, @JsonKey(name: 'lat') double latitude, @JsonKey(name: 'int') int intensity) $default,) {final _that = this; +switch (_that) { +case _StationIntensity(): +return $default(_that.longitude,_that.latitude,_that.intensity);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: 'lon') double longitude, @JsonKey(name: 'lat') double latitude, @JsonKey(name: 'int') int intensity)? $default,) {final _that = this; +switch (_that) { +case _StationIntensity() when $default != null: +return $default(_that.longitude,_that.latitude,_that.intensity);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _StationIntensity implements StationIntensity { + const _StationIntensity({@JsonKey(name: 'lon') required this.longitude, @JsonKey(name: 'lat') required this.latitude, @JsonKey(name: 'int') required this.intensity}); + factory _StationIntensity.fromJson(Map json) => _$StationIntensityFromJson(json); + +@override@JsonKey(name: 'lon') final double longitude; +@override@JsonKey(name: 'lat') final double latitude; +@override@JsonKey(name: 'int') final int intensity; + +/// Create a copy of StationIntensity +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$StationIntensityCopyWith<_StationIntensity> get copyWith => __$StationIntensityCopyWithImpl<_StationIntensity>(this, _$identity); + +@override +Map toJson() { + return _$StationIntensityToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _StationIntensity&&(identical(other.longitude, longitude) || other.longitude == longitude)&&(identical(other.latitude, latitude) || other.latitude == latitude)&&(identical(other.intensity, intensity) || other.intensity == intensity)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,longitude,latitude,intensity); + +@override +String toString() { + return 'StationIntensity(longitude: $longitude, latitude: $latitude, intensity: $intensity)'; +} + + +} + +/// @nodoc +abstract mixin class _$StationIntensityCopyWith<$Res> implements $StationIntensityCopyWith<$Res> { + factory _$StationIntensityCopyWith(_StationIntensity value, $Res Function(_StationIntensity) _then) = __$StationIntensityCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: 'lon') double longitude,@JsonKey(name: 'lat') double latitude,@JsonKey(name: 'int') int intensity +}); + + + + +} +/// @nodoc +class __$StationIntensityCopyWithImpl<$Res> + implements _$StationIntensityCopyWith<$Res> { + __$StationIntensityCopyWithImpl(this._self, this._then); + + final _StationIntensity _self; + final $Res Function(_StationIntensity) _then; + +/// Create a copy of StationIntensity +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? longitude = null,Object? latitude = null,Object? intensity = null,}) { + return _then(_StationIntensity( +longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable +as double,latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable +as double,intensity: null == intensity ? _self.intensity : intensity // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +// dart format on diff --git a/lib/features/earthquake/domain/earthquake_report.g.dart b/lib/features/earthquake/domain/earthquake_report.g.dart new file mode 100644 index 000000000..c7e62994d --- /dev/null +++ b/lib/features/earthquake/domain/earthquake_report.g.dart @@ -0,0 +1,65 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'earthquake_report.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_EarthquakeReport _$EarthquakeReportFromJson(Map json) => + _EarthquakeReport( + id: json['id'] as String, + longitude: (json['lon'] as num).toDouble(), + latitude: (json['lat'] as num).toDouble(), + location: json['loc'] as String, + depth: (json['depth'] as num).toDouble(), + magnitude: (json['mag'] as num).toDouble(), + list: (json['list'] as Map).map( + (k, e) => + MapEntry(k, AreaIntensity.fromJson(e as Map)), + ), + time: (json['time'] as num).toInt(), + trem: (json['trem'] as num).toInt(), + ); + +Map _$EarthquakeReportToJson(_EarthquakeReport instance) => + { + 'id': instance.id, + 'lon': instance.longitude, + 'lat': instance.latitude, + 'loc': instance.location, + 'depth': instance.depth, + 'mag': instance.magnitude, + 'list': instance.list.map((k, e) => MapEntry(k, e.toJson())), + 'time': instance.time, + 'trem': instance.trem, + }; + +_AreaIntensity _$AreaIntensityFromJson(Map json) => + _AreaIntensity( + intensity: (json['int'] as num).toInt(), + town: (json['town'] as Map).map( + (k, e) => + MapEntry(k, StationIntensity.fromJson(e as Map)), + ), + ); + +Map _$AreaIntensityToJson(_AreaIntensity instance) => + { + 'int': instance.intensity, + 'town': instance.town.map((k, e) => MapEntry(k, e.toJson())), + }; + +_StationIntensity _$StationIntensityFromJson(Map json) => + _StationIntensity( + longitude: (json['lon'] as num).toDouble(), + latitude: (json['lat'] as num).toDouble(), + intensity: (json['int'] as num).toInt(), + ); + +Map _$StationIntensityToJson(_StationIntensity instance) => + { + 'lon': instance.longitude, + 'lat': instance.latitude, + 'int': instance.intensity, + }; diff --git a/lib/features/earthquake/domain/report_repository.dart b/lib/features/earthquake/domain/report_repository.dart index 9d532db34..2a17ece9f 100644 --- a/lib/features/earthquake/domain/report_repository.dart +++ b/lib/features/earthquake/domain/report_repository.dart @@ -2,6 +2,7 @@ library; import 'package:dpip/core/error/result.dart'; +import 'package:dpip/features/earthquake/domain/earthquake_report.dart'; import 'package:dpip/features/earthquake/domain/partial_earthquake_report.dart'; import 'package:dpip/features/earthquake/domain/report_list_query.dart'; @@ -13,4 +14,8 @@ abstract interface class ReportRepository { int page = 1, ReportListQuery query = ReportListQuery.empty, }); + + /// The full report — including the per-area/town intensity breakdown — by + /// [id], for the detail page behind a catalogue row. + Future> get(String id); } diff --git a/lib/features/earthquake/presentation/pages/report_detail_page.dart b/lib/features/earthquake/presentation/pages/report_detail_page.dart new file mode 100644 index 000000000..6dff3f801 --- /dev/null +++ b/lib/features/earthquake/presentation/pages/report_detail_page.dart @@ -0,0 +1,1034 @@ +/// Full report detail — the app's own map (epicentre + per-town intensity +/// dots) with a swipe-up sheet for 詳細資訊 + 各地震度, opened from a +/// [ReportListPage] catalogue row. +library; + +import 'dart:async'; +import 'dart:math' as math; + +import 'package:dpip/app/theme/app_motion.dart'; +import 'package:dpip/app/theme/app_radius.dart'; +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/features/earthquake/domain/earthquake_report.dart'; +import 'package:dpip/features/earthquake/domain/intensity.dart'; +import 'package:dpip/features/earthquake/domain/report_repository.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/base_map.dart'; +import 'package:dpip/shared/map/camera_fit.dart'; +import 'package:dpip/shared/seismic/intensity_colors.dart'; +import 'package:dpip/shared/seismic/report_colors.dart'; +import 'package:dpip/shared/widgets/async_view.dart'; +import 'package:dpip/shared/widgets/frosted_surface.dart'; +import 'package:dpip/shared/widgets/intensity_badge.dart'; +import 'package:dpip/shared/widgets/section_header.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Fetches and shows the full report for [reportId] — epicentre, magnitude, +/// depth, and the per-area/town felt-intensity breakdown. +/// +/// No persistent app bar: the map fills the whole page behind a floating back +/// button (peek state); a pinned "地震報告" header — with its own back button — +/// only appears once the sheet is dragged up to its expanded content (see +/// [_ExpandedHeader]) and stays fixed while that content scrolls, matching +/// the legacy app, where that title bar was part of the sheet's full state, +/// not the page chrome. Exactly one back button is ever on screen: +/// [_sheetExpanded] toggles which one, so expanding the sheet hides the +/// floating button instead of stacking both. +class ReportDetailPage extends StatefulWidget { + const ReportDetailPage({super.key, required this.reportId}); + + /// The report id (e.g. `115053-2026-0731-005836`), from the route's `:id`. + final String reportId; + + @override + State createState() => _ReportDetailPageState(); +} + +class _ReportDetailPageState extends State { + final ValueNotifier _sheetExpanded = ValueNotifier(false); + + @override + void dispose() { + _sheetExpanded.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final repository = context.read(); + return Scaffold( + body: Stack( + children: [ + Positioned.fill( + child: AsyncView( + future: () => repository.get(widget.reportId), + builder: (context, report) => _ReportMapDetail( + report: report, + sheetExpanded: _sheetExpanded, + ), + ), + ), + Positioned( + top: 0, + left: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: ValueListenableBuilder( + valueListenable: _sheetExpanded, + builder: (context, expanded, child) => + expanded ? const SizedBox.shrink() : child!, + child: FrostedSurface( + borderRadius: AppRadius.large, + child: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +/// The report's epicentre + station bounds, in the map library's coordinate +/// type — computed here (not on the domain model) so the domain layer stays +/// free of a `maplibre_gl` dependency. +LatLngBounds _reportBounds(EarthquakeReport report) { + var minLat = report.latitude, maxLat = report.latitude; + var minLon = report.longitude, maxLon = report.longitude; + for (final area in report.list.values) { + for (final town in area.town.values) { + minLat = math.min(minLat, town.latitude); + maxLat = math.max(maxLat, town.latitude); + minLon = math.min(minLon, town.longitude); + maxLon = math.max(maxLon, town.longitude); + } + } + return LatLngBounds( + southwest: LatLng(minLat, minLon), + northeast: LatLng(maxLat, maxLon), + ); +} + +/// One 震度 level's counties (縣市) and, per county, the felt area/town names — +/// grouping by intensity first (highest first), not by county, mirrors the +/// reference report layout: the level is stated once per group instead of +/// once per chip. +class _IntensityGroup { + const _IntensityGroup({required this.intensity, required this.counties}); + + final int intensity; + final List<_CountyAreas> counties; +} + +/// One county's felt area/town names at its parent [_IntensityGroup]'s level. +class _CountyAreas { + const _CountyAreas({required this.countyName, required this.areaNames}); + + final String countyName; + final List areaNames; +} + +/// Regroups the report's county→town map by intensity level (highest first), +/// then by county, preserving each county's/town's original (API) order +/// within that. +List<_IntensityGroup> _buildIntensityGroups(EarthquakeReport report) { + final byIntensity = >>{}; + for (final MapEntry(key: countyName, value: area) in report.list.entries) { + for (final MapEntry(key: townName, value: town) in area.town.entries) { + byIntensity + .putIfAbsent(town.intensity, () => {}) + .putIfAbsent(countyName, () => []) + .add(townName); + } + } + final levels = byIntensity.keys.toList()..sort((a, b) => b.compareTo(a)); + return [ + for (final intensity in levels) + _IntensityGroup( + intensity: intensity, + counties: [ + for (final MapEntry(key: countyName, value: areaNames) + in byIntensity[intensity]!.entries) + _CountyAreas(countyName: countyName, areaNames: areaNames), + ], + ), + ]; +} + +/// The map surface: epicentre + per-town intensity dots, framed to the +/// report's extent, with [_ReportSheet] docked at the bottom. +class _ReportMapDetail extends StatefulWidget { + const _ReportMapDetail({required this.report, required this.sheetExpanded}); + + final EarthquakeReport report; + + /// Set by [_ReportSheet] as it's dragged — drives the page's floating back + /// button's visibility (see [ReportDetailPage]). + final ValueNotifier sheetExpanded; + + @override + State<_ReportMapDetail> createState() => _ReportMapDetailState(); +} + +class _ReportMapDetailState extends State<_ReportMapDetail> { + static const String _sourceId = 'report-detail-src'; + static const String _symbolLayerId = 'report-detail-symbol'; + + /// Icon size by zoom — far past the legacy app's `kSymbolIconSize` (5 → + /// 0.2, 15 → 0.6): those source PNGs render at native (device-pixel) size, + /// so on a modern 3x display that old scale is only ~20 logical px — this + /// page needs the marker legible at a glance, so it's sized up accordingly. + static const List _iconSizeExpression = [ + 'interpolate', + ['linear'], + ['zoom'], + 5, + 0.75, + 15, + 1.7, + ]; + + /// `intensity-1`…`intensity-9` (+ `-dark`) and `cross` — the legacy app's + /// own map-marker artwork, ported verbatim (same PNGs) so the report map + /// looks exactly as it did before, not a new icon style. + static final List _iconNames = [ + 'cross', + for (var i = 1; i <= 9; i++) 'intensity-$i', + for (var i = 1; i <= 9; i++) 'intensity-$i-dark', + ]; + + MapLibreMapController? _controller; + bool _iconsLoaded = false; + + void _onMapCreated(MapLibreMapController controller) { + _controller = controller; + } + + Future _onStyleLoaded() async { + final controller = _controller; + if (controller == null) return; + final dark = Theme.of(context).brightness == Brightness.dark; + try { + if (!_iconsLoaded) { + await _loadIcons(controller); + _iconsLoaded = true; + } + await controller.addSource( + _sourceId, + GeojsonSourceProperties(data: _geoJson(dark: dark)), + ); + await controller.addSymbolLayer( + _sourceId, + _symbolLayerId, + const SymbolLayerProperties( + iconImage: ['get', 'icon'], + iconSize: _iconSizeExpression, + iconAllowOverlap: true, + iconIgnorePlacement: true, + symbolZOrder: 'source', + ), + ); + } catch (e, st) { + Log.handle(e, st, 'report detail map render failed'); + } + _frame(); + } + + /// Registers every marker icon once — cheap enough (19 small PNGs) to load + /// eagerly rather than lazily per feature. + Future _loadIcons(MapLibreMapController controller) async { + for (final name in _iconNames) { + final data = await rootBundle.load('assets/map/icons/$name.png'); + await controller.addImage(name, data.buffer.asUint8List()); + } + } + + /// Fits the camera once, leaving room for the sheet at its resting height — + /// not re-run on drag (the sheet's resting fraction is a fixed chrome + /// allowance, matching [MapScaffold]'s per-layer `bottomChromeFraction`). + void _frame() { + final controller = _controller; + if (controller == null || !mounted) return; + final bounds = safeFitBounds(_reportBounds(widget.report)); + if (bounds == null) return; + final size = MediaQuery.sizeOf(context); + final fit = boundsFitCamera( + bounds, + viewport: size, + topInset: MediaQuery.paddingOf(context).top, + bottomInset: size.height * _ReportSheet.rest, + ); + if (fit == null) return; + controller.moveCamera(CameraUpdate.newLatLngZoom(fit.target, fit.zoom)); + } + + /// Every town marker (sorted low→high intensity, so a hot reading draws on + /// top of a calm one) plus the epicentre cross last — same draw order as + /// the legacy app's `EarthquakeReport.toGeoJson()`. + Map _geoJson({required bool dark}) { + final report = widget.report; + final towns = [ + for (final area in report.list.values) ...area.town.values, + ]..sort((a, b) => a.intensity.compareTo(b.intensity)); + + final features = >[ + for (final town in towns) + { + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [town.longitude, town.latitude], + }, + 'properties': { + 'icon': 'intensity-${town.intensity}${dark ? '' : '-dark'}', + }, + }, + { + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [report.longitude, report.latitude], + }, + 'properties': {'icon': 'cross'}, + }, + ]; + return {'type': 'FeatureCollection', 'features': features}; + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill( + child: BaseMap( + showUserLocation: false, + onMapCreated: _onMapCreated, + onStyleLoaded: () => unawaited(_onStyleLoaded()), + ), + ), + Positioned.fill( + child: _ReportSheet( + report: widget.report, + expandedNotifier: widget.sheetExpanded, + ), + ), + ], + ); + } +} + +/// The draggable bottom sheet — mirrors the legacy `MorphingSheet`'s two +/// distinct layouts (not one continuously-revealed list): [peek] shows a +/// compact summary card, dragged past the midpoint swaps to the full +/// 詳細資訊 + 各地震度 + report-image content under a pinned "地震報告" header +/// that stays fixed while that content scrolls. +class _ReportSheet extends StatefulWidget { + const _ReportSheet({required this.report, required this.expandedNotifier}); + + final EarthquakeReport report; + + /// Mirrors this sheet's collapsed/expanded state up to the page, so it can + /// hide its floating back button while the sheet's own header has one. + final ValueNotifier expandedNotifier; + + /// Collapsed height — sized to the compact summary card. + static const double peek = 0.32; + + /// Alias kept for the map's bottom-inset framing (see [_ReportMapDetailState._frame]). + static const double rest = peek; + + static const double _expanded = 1.0; + static const double _switchThreshold = (peek + _expanded) / 2; + + @override + State<_ReportSheet> createState() => _ReportSheetState(); +} + +class _ReportSheetState extends State<_ReportSheet> { + final ValueNotifier _extent = ValueNotifier(_ReportSheet.peek); + + /// Programmatically collapses the sheet back to its [peek] state — used by + /// the expanded header's back button, which folds the sheet down to the map + /// rather than popping the page. + final DraggableScrollableController _dragController = + DraggableScrollableController(); + + @override + void dispose() { + _extent.dispose(); + _dragController.dispose(); + super.dispose(); + } + + void _collapse() { + _dragController.animateTo( + _ReportSheet.peek, + duration: AppMotion.medium, + curve: Curves.easeOutCubic, + ); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final report = widget.report; + + return Align( + alignment: Alignment.bottomCenter, + child: NotificationListener( + onNotification: (notification) { + _extent.value = notification.extent; + // Runs from the scroll-notification dispatch, not mid-build, so + // it's safe to update the page-level notifier the floating back + // button listens to. + widget.expandedNotifier.value = + notification.extent >= _ReportSheet._switchThreshold; + return false; + }, + child: DraggableScrollableSheet( + controller: _dragController, + initialChildSize: _ReportSheet.peek, + minChildSize: _ReportSheet.peek, + maxChildSize: _ReportSheet._expanded, + snap: true, + builder: (context, scrollController) { + return DecoratedBox( + decoration: BoxDecoration( + color: colors.surface, + borderRadius: AppRadius.topSheet, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 16, + offset: const Offset(0, -2), + ), + ], + ), + child: ClipRRect( + borderRadius: AppRadius.topSheet, + child: ValueListenableBuilder( + valueListenable: _extent, + builder: (context, extent, _) { + final expanded = extent >= _ReportSheet._switchThreshold; + // Flush with the screen top at full extent — the status + // bar/notch inset has to come back as padding on the + // expanded header, or it renders underneath it. + final atTop = extent >= _ReportSheet._expanded - 0.02; + return Column( + children: [ + // Pinned above the scroll body, not a scrolling item: + // the "地震報告" bar (back button + title) stays while + // the 詳細資訊 content scrolls underneath it. Keep the + // ListView's horizontal inset on the header too, so it + // sits where it did when it was the list's first child. + if (expanded) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + ), + child: _ExpandedHeader( + atTop: atTop, + onBack: _collapse, + ), + ), + Expanded( + child: ListView( + controller: scrollController, + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + expanded ? 0 : AppSpacing.md, + AppSpacing.lg, + AppSpacing.xl + + MediaQuery.paddingOf(context).bottom, + ), + children: expanded + ? _expandedContent(context, report) + : _peekContent(context, report), + ), + ), + ], + ); + }, + ), + ), + ); + }, + ), + ), + ); + } + + List _peekContent(BuildContext context, EarthquakeReport report) => [ + _ReportPeekSummary(report: report), + ]; + + List _expandedContent(BuildContext context, EarthquakeReport report) { + final l10n = AppLocalizations.of(context); + final groups = _buildIntensityGroups(report); + return [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ReportHeader(report: report), + const SizedBox(height: AppSpacing.lg), + SectionHeader(l10n.reportDetailInfo), + _ReportInfoCard(report: report), + if (groups.isNotEmpty) ...[ + SectionHeader(l10n.reportDetailAreaIntensity), + _AreaIntensityCard(groups: groups), + ], + SectionHeader(l10n.reportDetailImage), + _ReportImageCard(report: report), + ], + ), + ), + ]; + } +} + +/// The expanded sheet's pinned header — appears only once dragged up (matches +/// the legacy `MorphingSheet.fullBuilder`'s `SliverAppBar`) and then stays +/// fixed above the scroll body while the 詳細資訊 content scrolls underneath: +/// a back button at the left and the "地震報告" title, flush at the top when +/// the sheet is fully extended. The page's floating back button hides itself +/// whenever this is showing (see [ReportDetailPage]), so there's never more +/// than one on screen. +/// +/// Its back button **collapses** the sheet back to the map ([onBack]), not +/// pops the page — the floating button is what leaves (and only it is visible +/// in the collapsed state), so each state has exactly one sensible action. +class _ExpandedHeader extends StatelessWidget { + const _ExpandedHeader({required this.atTop, required this.onBack}); + + /// Whether the sheet is flush with the screen top — adds the status-bar + /// inset back as padding so the row doesn't render underneath it. + final bool atTop; + + /// Collapses the sheet to its peek state (showing the epicentre-intensity + /// map), replacing the page-pop the floating back button does. + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final topInset = atTop ? MediaQuery.paddingOf(context).top : 0.0; + + return Padding( + padding: EdgeInsets.fromLTRB( + AppSpacing.sm, + topInset + AppSpacing.xs, + AppSpacing.sm, + AppSpacing.xs, + ), + child: Row( + children: [ + IconButton(icon: const Icon(Icons.arrow_back), onPressed: onBack), + Expanded( + child: Text( + l10n.reportDetailTitle, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + // Balances the leading back button so the title stays centred. + const SizedBox(width: 48), + ], + ), + ); + } +} + +/// The collapsed sheet's compact summary — eyebrow/location/time to the left, +/// a large intensity badge to the right, then a magnitude/depth stat row. +/// Mirrors the legacy `MorphingSheet.partialBuilder` layout exactly. +class _ReportPeekSummary extends StatelessWidget { + const _ReportPeekSummary({required this.report}); + + final EarthquakeReport report; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final colors = theme.colorScheme; + final presentation = Intensity.displayForReport( + report.maxIntensity, + report.originTimeUtc, + ); + final taipei = report.originTimeUtc.add(const Duration(hours: 8)); + final time = DateFormat('yyyy/MM/dd HH:mm:ss').format(taipei); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + report.hasNumber + ? l10n.reportDetailNumbered(report.number!) + : l10n.reportDetailLocalFelt, + style: theme.textTheme.labelMedium?.copyWith( + color: colors.outline, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + report.shortLocation, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + time, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + const SizedBox(width: AppSpacing.md), + IntensityBadge( + label: presentation.label, + color: IntensityColors.discrete(presentation.colorLevel), + size: 56, + ), + ], + ), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + Expanded( + child: _PeekStat( + label: l10n.reportDetailMagnitude, + value: l10n.reportListMagnitude( + report.magnitude.toStringAsFixed(1), + ), + ), + ), + Expanded( + child: _PeekStat( + label: l10n.reportDetailDepth, + value: l10n.reportFilterDepthKm( + report.depth.toStringAsFixed(1), + ), + ), + ), + ], + ), + ], + ); + } +} + +class _PeekStat extends StatelessWidget { + const _PeekStat({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Text( + value, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ); + } +} + +/// Intensity badge + numbered/local-felt eyebrow + location — the expanded +/// sheet's header, with a filled chip opening the official CWA report page. +class _ReportHeader extends StatelessWidget { + const _ReportHeader({required this.report}); + + final EarthquakeReport report; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final colors = theme.colorScheme; + final presentation = Intensity.displayForReport( + report.maxIntensity, + report.originTimeUtc, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IntensityBadge( + label: presentation.label, + color: IntensityColors.discrete(presentation.colorLevel), + size: 56, + ), + const SizedBox(width: AppSpacing.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + report.hasNumber + ? l10n.reportDetailNumbered(report.number!) + : l10n.reportDetailLocalFelt, + style: theme.textTheme.labelMedium?.copyWith( + color: colors.outline, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + report.shortLocation, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + ActionChip( + avatar: Icon(Icons.open_in_new, size: 16, color: colors.onPrimary), + label: Text(l10n.reportDetailOpenReport), + backgroundColor: colors.primary, + labelStyle: TextStyle(color: colors.onPrimary), + side: BorderSide(color: colors.primary), + onPressed: () => _openReportPage(context), + ), + ], + ); + } + + Future _openReportPage(BuildContext context) async { + try { + await launchUrl(report.reportUrl, mode: LaunchMode.externalApplication); + } catch (e, st) { + Log.handle(e, st, 'open report page failed: ${report.reportUrl}'); + } + } +} + +/// 發震時間 / 震央座標 / 地震規模 / 震源深度 as a bordered info card. +class _ReportInfoCard extends StatelessWidget { + const _ReportInfoCard({required this.report}); + + final EarthquakeReport report; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final colors = Theme.of(context).colorScheme; + final taipei = report.originTimeUtc.add(const Duration(hours: 8)); + final originTime = DateFormat('yyyy/MM/dd HH:mm:ss').format(taipei); + final coordinates = + '${report.latitude.toStringAsFixed(2)}°N・' + '${report.longitude.toStringAsFixed(2)}°E'; + + return Material( + color: colors.surfaceContainer, + borderRadius: AppRadius.medium, + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + _InfoRow(label: l10n.reportDetailOriginTime, value: originTime), + const Divider(height: 1), + _InfoRow(label: l10n.reportDetailEpicenter, value: coordinates), + const Divider(height: 1), + _InfoRow( + label: l10n.reportDetailMagnitude, + value: l10n.reportListMagnitude( + report.magnitude.toStringAsFixed(1), + ), + dotColor: MagnitudeColors.of(report.magnitude), + ), + const Divider(height: 1), + _InfoRow( + label: l10n.reportDetailDepth, + value: l10n.reportFilterDepthKm(report.depth.toStringAsFixed(1)), + dotColor: DepthColors.of(report.depth), + ), + ], + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow({required this.label, required this.value, this.dotColor}); + + final String label; + final String value; + final Color? dotColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + child: Row( + children: [ + Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const Spacer(), + if (dotColor != null) ...[ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: dotColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: AppSpacing.sm), + ], + Text( + value, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ); + } +} + +/// 各地震度 — grouped by felt intensity (highest first), not by county: each +/// [_IntensityGroup] states its level once in a left-hand column via +/// [IntensityBadge], with every county (bold) and its felt areas listed to +/// the right, left-to-right/top-to-bottom. +class _AreaIntensityCard extends StatelessWidget { + const _AreaIntensityCard({required this.groups}); + + final List<_IntensityGroup> groups; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Material( + color: colors.surfaceContainer, + borderRadius: AppRadius.medium, + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < groups.length; i++) + _IntensityGroupRow(group: groups[i]), + ], + ), + ); + } +} + +/// One 震度 level's band — a background wash of its palette [IntensityColors] +/// colour (strongest at the top, fading as intensity drops down the card) +/// stands in for the vertical divider line a plain list would need, reading +/// as a single severity gradient at a glance (the broadcast-graphic +/// convention this section's reference design followed). +class _IntensityGroupRow extends StatelessWidget { + const _IntensityGroupRow({required this.group}); + + final _IntensityGroup group; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + // Raw wire intensity, not [Intensity.displayForReport] — per-town/group + // readings have always been on the fine 0–9 scale (only the top-level + // report max needed the pre-2020 legacy-scale remap). + final label = Intensity.label(group.intensity); + final color = IntensityColors.discrete(group.intensity); + + return DecoratedBox( + decoration: BoxDecoration(color: color.withValues(alpha: 0.14)), + child: Row( + // Default cross-axis alignment (center), not .stretch — the badge is + // a fixed-size square ([IntensityBadge]); stretching would force it + // to the county column's height whenever a group spans multiple rows. + children: [ + Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: IntensityBadge(label: label, color: color, size: 40), + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < group.counties.length; i++) ...[ + if (i > 0) + Divider( + height: 1, + indent: AppSpacing.md, + endIndent: AppSpacing.md, + color: colors.outlineVariant.withValues(alpha: 0.4), + ), + _CountyAreasRow(county: group.counties[i]), + ], + ], + ), + ), + ], + ), + ); + } +} + +class _CountyAreasRow extends StatelessWidget { + const _CountyAreasRow({required this.county}); + + final _CountyAreas county; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: AppSpacing.sm, + runSpacing: AppSpacing.xs, + children: [ + Text( + county.countyName, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + Text( + // l10n-ignore: CJK enumeration comma (頓號) joining names, not display text itself. + county.areaNames.join('、'), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} + +/// 地震報告圖 — CWA's rendered report image; falls back to a plain message when +/// it hasn't been generated yet or fails to load. +class _ReportImageCard extends StatefulWidget { + const _ReportImageCard({required this.report}); + + final EarthquakeReport report; + + @override + State<_ReportImageCard> createState() => _ReportImageCardState(); +} + +class _ReportImageCardState extends State<_ReportImageCard> { + bool _failed = false; + + /// Placeholder height while loading / on failure — a guess, not the real + /// aspect ratio (that varies per event), so it's only a skeleton size; the + /// image itself sizes to its own natural proportions once loaded. + static const double _placeholderHeight = 200; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final colors = Theme.of(context).colorScheme; + + if (_failed) { + return ClipRRect( + borderRadius: AppRadius.medium, + child: Container( + height: _placeholderHeight, + width: double.infinity, + color: colors.surfaceContainer, + alignment: Alignment.center, + child: Text( + l10n.reportDetailImageUnavailable, + style: TextStyle(color: colors.onSurfaceVariant), + ), + ), + ); + } + + return ClipRRect( + borderRadius: AppRadius.medium, + child: Image.network( + widget.report.reportImageUrl.toString(), + // No forced aspect ratio / BoxFit.cover — the report image's real + // proportions vary per event, so this sizes to the image's own + // natural aspect ratio at full width instead of cropping or padding. + width: double.infinity, + fit: BoxFit.contain, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return Container( + height: _placeholderHeight, + width: double.infinity, + color: colors.surfaceContainer, + alignment: Alignment.center, + child: const CircularProgressIndicator(), + ); + }, + errorBuilder: (context, error, stackTrace) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _failed = true); + }); + return const SizedBox.shrink(); + }, + ), + ); + } +} diff --git a/lib/features/earthquake/presentation/pages/report_list_page.dart b/lib/features/earthquake/presentation/pages/report_list_page.dart index 4c26f884c..d52bd4952 100644 --- a/lib/features/earthquake/presentation/pages/report_list_page.dart +++ b/lib/features/earthquake/presentation/pages/report_list_page.dart @@ -10,11 +10,14 @@ import 'package:dpip/features/earthquake/domain/report_repository.dart'; import 'package:dpip/features/earthquake/presentation/report_list_controller.dart'; import 'package:dpip/features/earthquake/presentation/widgets/report_filter_sheet.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:dpip/shared/seismic/intensity_colors.dart'; import 'package:dpip/shared/widgets/empty_view.dart'; import 'package:dpip/shared/widgets/error_view.dart'; +import 'package:dpip/shared/widgets/intensity_badge.dart'; import 'package:dpip/shared/widgets/loading_view.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; @@ -334,12 +337,15 @@ class _ReportTile extends StatelessWidget { final mag = report.magnitude.toStringAsFixed(1); return InkWell( - onTap: null, // Detail route lands next. + onTap: () => context.pushNamed( + AppRoutes.earthquakeReport, + pathParameters: {'id': report.id}, + ), child: Padding( padding: const EdgeInsets.all(AppSpacing.md), child: Row( children: [ - _IntensityBadge(label: intensity.label, color: intensityColor), + IntensityBadge(label: intensity.label, color: intensityColor), const SizedBox(width: AppSpacing.md), Expanded( child: Column( @@ -382,36 +388,3 @@ class _ReportTile extends StatelessWidget { ); } } - -class _IntensityBadge extends StatelessWidget { - const _IntensityBadge({required this.label, required this.color}); - - final String label; - final Color color; - - @override - Widget build(BuildContext context) { - final onBadge = - ThemeData.estimateBrightnessForColor(color) == Brightness.dark - ? Colors.white - : Colors.black87; - return DecoratedBox( - decoration: BoxDecoration(color: color, borderRadius: AppRadius.small), - child: SizedBox( - width: 48, - height: 48, - child: Center( - child: Text( - label, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: onBadge, - fontWeight: FontWeight.w900, - height: 1, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ), - ), - ); - } -} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e62b4835f..2d5d90f0e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -328,6 +328,59 @@ "@reportListSearch": { "description": "Fetches the report list with the current draft filters" }, + "reportDetailTitle": "Earthquake Report", + "@reportDetailTitle": { + "description": "Header title over the report detail map's back button" + }, + "reportDetailNumbered": "No. {number} Significant Earthquake", + "@reportDetailNumbered": { + "description": "Eyebrow label on the detail header for a numbered CWA report", + "placeholders": { + "number": { + "type": "String" + } + } + }, + "reportDetailLocalFelt": "Local Felt Earthquake", + "@reportDetailLocalFelt": { + "description": "Eyebrow label on the detail header for a …000 (unnumbered) report" + }, + "reportDetailInfo": "Details", + "@reportDetailInfo": { + "description": "Section header over origin time / epicenter / magnitude / depth" + }, + "reportDetailOriginTime": "Origin time", + "@reportDetailOriginTime": { + "description": "Row label for the report's origin date/time" + }, + "reportDetailEpicenter": "Epicenter", + "@reportDetailEpicenter": { + "description": "Row label for the epicenter's latitude/longitude" + }, + "reportDetailMagnitude": "Magnitude", + "@reportDetailMagnitude": { + "description": "Row label for the report's magnitude" + }, + "reportDetailDepth": "Depth", + "@reportDetailDepth": { + "description": "Row label for the report's hypocentral depth" + }, + "reportDetailAreaIntensity": "Intensity by area", + "@reportDetailAreaIntensity": { + "description": "Section header over the per-area/town felt-intensity breakdown" + }, + "reportDetailImage": "Report image", + "@reportDetailImage": { + "description": "Section header over the CWA-rendered report image" + }, + "reportDetailImageUnavailable": "Report image not available", + "@reportDetailImageUnavailable": { + "description": "Shown in place of the report image when it fails to load" + }, + "reportDetailOpenReport": "Report page", + "@reportDetailOpenReport": { + "description": "Button that opens the official CWA report page in a browser" + }, "navMore": "More", "@navMore": { "description": "Bottom-nav label and page title for the More tab" diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index ac152c635..a59e1566f 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -69,6 +69,18 @@ "reportFilterApply": "I-apply", "reportFilterReset": "I-reset", "reportListSearch": "Maghanap", + "reportDetailTitle": "Ulat ng Lindol", + "reportDetailNumbered": "Blg. {number} Makabuluhang Naramdamang Lindol", + "reportDetailLocalFelt": "Lokal na Naramdamang Lindol", + "reportDetailInfo": "Mga Detalye", + "reportDetailOriginTime": "Oras ng pangyayari", + "reportDetailEpicenter": "Coordinates ng Epicenter", + "reportDetailMagnitude": "Magnitude", + "reportDetailDepth": "Lalim ng Hypocenter", + "reportDetailAreaIntensity": "Intensity ayon sa lugar", + "reportDetailImage": "Larawan ng Ulat", + "reportDetailImageUnavailable": "Wala pang available na larawan ng ulat", + "reportDetailOpenReport": "Pahina ng Ulat", "navMore": "Higit Pa", "appLogs": "Mga log ng app", "changelogTitle": "Changelog", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 76e761d48..5d50fb11f 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -69,6 +69,18 @@ "reportFilterApply": "Terapkan", "reportFilterReset": "Reset", "reportListSearch": "Cari", + "reportDetailTitle": "Laporan Gempa", + "reportDetailNumbered": "Gempa Dirasakan Signifikan No. {number}", + "reportDetailLocalFelt": "Gempa Dirasakan Lokal", + "reportDetailInfo": "Detail", + "reportDetailOriginTime": "Waktu kejadian", + "reportDetailEpicenter": "Koordinat episentrum", + "reportDetailMagnitude": "Magnitudo", + "reportDetailDepth": "Kedalaman hiposenter", + "reportDetailAreaIntensity": "Intensitas per wilayah", + "reportDetailImage": "Gambar laporan", + "reportDetailImageUnavailable": "Gambar laporan belum tersedia", + "reportDetailOpenReport": "Halaman laporan", "navMore": "Lainnya", "appLogs": "Log aplikasi", "changelogTitle": "Catatan pembaruan", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 636d97f85..3351341ca 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -69,6 +69,18 @@ "reportFilterApply": "適用", "reportFilterReset": "リセット", "reportListSearch": "検索", + "reportDetailTitle": "地震レポート", + "reportDetailNumbered": "No.{number} 顕著有感地震", + "reportDetailLocalFelt": "局地的な有感地震", + "reportDetailInfo": "詳細情報", + "reportDetailOriginTime": "発震時刻", + "reportDetailEpicenter": "震央座標", + "reportDetailMagnitude": "地震規模", + "reportDetailDepth": "震源の深さ", + "reportDetailAreaIntensity": "地域別震度", + "reportDetailImage": "地震レポート画像", + "reportDetailImageUnavailable": "レポート画像はまだありません", + "reportDetailOpenReport": "レポートページ", "navMore": "その他", "appLogs": "アプリログ", "changelogTitle": "更新履歴", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 15b7a8043..fd50c70a7 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -69,6 +69,18 @@ "reportFilterApply": "적용", "reportFilterReset": "초기화", "reportListSearch": "조회", + "reportDetailTitle": "지진 보고서", + "reportDetailNumbered": "번호 {number} 유의미 유감지진", + "reportDetailLocalFelt": "국지적 유감지진", + "reportDetailInfo": "상세 정보", + "reportDetailOriginTime": "발생 시각", + "reportDetailEpicenter": "진앙 좌표", + "reportDetailMagnitude": "지진 규모", + "reportDetailDepth": "진원 깊이", + "reportDetailAreaIntensity": "지역별 진도", + "reportDetailImage": "지진 보고서 이미지", + "reportDetailImageUnavailable": "보고서 이미지가 아직 없습니다", + "reportDetailOpenReport": "보고서 페이지", "navMore": "더보기", "appLogs": "앱 로그", "changelogTitle": "변경 로그", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index c380cf107..cbd25010c 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -69,6 +69,18 @@ "reportFilterApply": "ใช้", "reportFilterReset": "รีเซ็ต", "reportListSearch": "ค้นหา", + "reportDetailTitle": "รายงานแผ่นดินไหว", + "reportDetailNumbered": "แผ่นดินไหวรู้สึกได้อย่างมีนัยสำคัญ หมายเลข {number}", + "reportDetailLocalFelt": "แผ่นดินไหวรู้สึกได้เฉพาะพื้นที่", + "reportDetailInfo": "รายละเอียด", + "reportDetailOriginTime": "เวลาเกิดเหตุ", + "reportDetailEpicenter": "พิกัดศูนย์กลาง", + "reportDetailMagnitude": "ขนาดแผ่นดินไหว", + "reportDetailDepth": "ความลึกจุดศูนย์กลาง", + "reportDetailAreaIntensity": "ความเข้มแยกตามพื้นที่", + "reportDetailImage": "ภาพรายงานแผ่นดินไหว", + "reportDetailImageUnavailable": "ยังไม่มีภาพรายงาน", + "reportDetailOpenReport": "หน้ารายงาน", "navMore": "เพิ่มเติม", "appLogs": "บันทึกแอป", "changelogTitle": "บันทึกการอัปเดต", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index ef4987c95..928b9bdec 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -69,6 +69,18 @@ "reportFilterApply": "Áp dụng", "reportFilterReset": "Đặt lại", "reportListSearch": "Tìm", + "reportDetailTitle": "Báo cáo động đất", + "reportDetailNumbered": "Động đất có cảm nhận đáng kể số {number}", + "reportDetailLocalFelt": "Động đất cảm nhận cục bộ", + "reportDetailInfo": "Chi tiết", + "reportDetailOriginTime": "Thời gian xảy ra", + "reportDetailEpicenter": "Tọa độ tâm chấn", + "reportDetailMagnitude": "Độ lớn", + "reportDetailDepth": "Độ sâu chấn tiêu", + "reportDetailAreaIntensity": "Cường độ theo khu vực", + "reportDetailImage": "Hình ảnh báo cáo", + "reportDetailImageUnavailable": "Hình ảnh báo cáo chưa có sẵn", + "reportDetailOpenReport": "Trang báo cáo", "navMore": "Thêm", "appLogs": "Nhật ký ứng dụng", "changelogTitle": "Nhật ký cập nhật", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 89989330e..43a945c5d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -69,6 +69,18 @@ "reportFilterApply": "套用", "reportFilterReset": "重設", "reportListSearch": "查詢", + "reportDetailTitle": "地震報告", + "reportDetailNumbered": "編號 {number} 顯著有感地震", + "reportDetailLocalFelt": "小區域有感地震", + "reportDetailInfo": "詳細資訊", + "reportDetailOriginTime": "發震時間", + "reportDetailEpicenter": "震央座標", + "reportDetailMagnitude": "地震規模", + "reportDetailDepth": "震源深度", + "reportDetailAreaIntensity": "各地震度", + "reportDetailImage": "地震報告圖", + "reportDetailImageUnavailable": "報告圖尚未提供", + "reportDetailOpenReport": "報告頁面", "navMore": "更多", "appLogs": "App 日誌", "changelogTitle": "更新日誌", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 4e8e8e7b8..a6735050d 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -69,6 +69,18 @@ "reportFilterApply": "应用", "reportFilterReset": "重置", "reportListSearch": "查询", + "reportDetailTitle": "地震报告", + "reportDetailNumbered": "编号 {number} 显著有感地震", + "reportDetailLocalFelt": "小区域有感地震", + "reportDetailInfo": "详细信息", + "reportDetailOriginTime": "发震时间", + "reportDetailEpicenter": "震中坐标", + "reportDetailMagnitude": "地震规模", + "reportDetailDepth": "震源深度", + "reportDetailAreaIntensity": "各地震度", + "reportDetailImage": "地震报告图", + "reportDetailImageUnavailable": "报告图尚未提供", + "reportDetailOpenReport": "报告页面", "navMore": "更多", "appLogs": "应用日志", "changelogTitle": "更新日志", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index fd522fd86..f7b8ff9a9 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -69,6 +69,18 @@ "reportFilterApply": "套用", "reportFilterReset": "重設", "reportListSearch": "查詢", + "reportDetailTitle": "地震報告", + "reportDetailNumbered": "編號 {number} 顯著有感地震", + "reportDetailLocalFelt": "小區域有感地震", + "reportDetailInfo": "詳細資訊", + "reportDetailOriginTime": "發震時間", + "reportDetailEpicenter": "震央座標", + "reportDetailMagnitude": "地震規模", + "reportDetailDepth": "震源深度", + "reportDetailAreaIntensity": "各地震度", + "reportDetailImage": "地震報告圖", + "reportDetailImageUnavailable": "報告圖尚未提供", + "reportDetailOpenReport": "報告頁面", "navMore": "更多", "appLogs": "App 日誌", "changelogTitle": "更新日誌", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 9539e8056..b86ab9ce7 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -69,6 +69,18 @@ "reportFilterApply": "套用", "reportFilterReset": "重設", "reportListSearch": "查詢", + "reportDetailTitle": "地震報告", + "reportDetailNumbered": "編號 {number} 顯著有感地震", + "reportDetailLocalFelt": "小區域有感地震", + "reportDetailInfo": "詳細資訊", + "reportDetailOriginTime": "發震時間", + "reportDetailEpicenter": "震央座標", + "reportDetailMagnitude": "地震規模", + "reportDetailDepth": "震源深度", + "reportDetailAreaIntensity": "各地震度", + "reportDetailImage": "地震報告圖", + "reportDetailImageUnavailable": "報告圖尚未提供", + "reportDetailOpenReport": "報告頁面", "navMore": "更多", "appLogs": "App 日誌", "changelogTitle": "更新日誌", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 4858bf433..83bfa55df 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -531,6 +531,78 @@ abstract class AppLocalizations { /// **'Search'** String get reportListSearch; + /// Header title over the report detail map's back button + /// + /// In en, this message translates to: + /// **'Earthquake Report'** + String get reportDetailTitle; + + /// Eyebrow label on the detail header for a numbered CWA report + /// + /// In en, this message translates to: + /// **'No. {number} Significant Earthquake'** + String reportDetailNumbered(String number); + + /// Eyebrow label on the detail header for a …000 (unnumbered) report + /// + /// In en, this message translates to: + /// **'Local Felt Earthquake'** + String get reportDetailLocalFelt; + + /// Section header over origin time / epicenter / magnitude / depth + /// + /// In en, this message translates to: + /// **'Details'** + String get reportDetailInfo; + + /// Row label for the report's origin date/time + /// + /// In en, this message translates to: + /// **'Origin time'** + String get reportDetailOriginTime; + + /// Row label for the epicenter's latitude/longitude + /// + /// In en, this message translates to: + /// **'Epicenter'** + String get reportDetailEpicenter; + + /// Row label for the report's magnitude + /// + /// In en, this message translates to: + /// **'Magnitude'** + String get reportDetailMagnitude; + + /// Row label for the report's hypocentral depth + /// + /// In en, this message translates to: + /// **'Depth'** + String get reportDetailDepth; + + /// Section header over the per-area/town felt-intensity breakdown + /// + /// In en, this message translates to: + /// **'Intensity by area'** + String get reportDetailAreaIntensity; + + /// Section header over the CWA-rendered report image + /// + /// In en, this message translates to: + /// **'Report image'** + String get reportDetailImage; + + /// Shown in place of the report image when it fails to load + /// + /// In en, this message translates to: + /// **'Report image not available'** + String get reportDetailImageUnavailable; + + /// Button that opens the official CWA report page in a browser + /// + /// In en, this message translates to: + /// **'Report page'** + String get reportDetailOpenReport; + /// Bottom-nav label and page title for the More tab /// /// In en, this message translates to: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 88b0faf9b..f04ceac98 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -241,6 +241,44 @@ class AppLocalizationsEn extends AppLocalizations { @override String get reportListSearch => 'Search'; + @override + String get reportDetailTitle => 'Earthquake Report'; + + @override + String reportDetailNumbered(String number) { + return 'No. $number Significant Earthquake'; + } + + @override + String get reportDetailLocalFelt => 'Local Felt Earthquake'; + + @override + String get reportDetailInfo => 'Details'; + + @override + String get reportDetailOriginTime => 'Origin time'; + + @override + String get reportDetailEpicenter => 'Epicenter'; + + @override + String get reportDetailMagnitude => 'Magnitude'; + + @override + String get reportDetailDepth => 'Depth'; + + @override + String get reportDetailAreaIntensity => 'Intensity by area'; + + @override + String get reportDetailImage => 'Report image'; + + @override + String get reportDetailImageUnavailable => 'Report image not available'; + + @override + String get reportDetailOpenReport => 'Report page'; + @override String get navMore => 'More'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 13c714c72..005088857 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -242,6 +242,45 @@ class AppLocalizationsFil extends AppLocalizations { @override String get reportListSearch => 'Maghanap'; + @override + String get reportDetailTitle => 'Ulat ng Lindol'; + + @override + String reportDetailNumbered(String number) { + return 'Blg. $number Makabuluhang Naramdamang Lindol'; + } + + @override + String get reportDetailLocalFelt => 'Lokal na Naramdamang Lindol'; + + @override + String get reportDetailInfo => 'Mga Detalye'; + + @override + String get reportDetailOriginTime => 'Oras ng pangyayari'; + + @override + String get reportDetailEpicenter => 'Coordinates ng Epicenter'; + + @override + String get reportDetailMagnitude => 'Magnitude'; + + @override + String get reportDetailDepth => 'Lalim ng Hypocenter'; + + @override + String get reportDetailAreaIntensity => 'Intensity ayon sa lugar'; + + @override + String get reportDetailImage => 'Larawan ng Ulat'; + + @override + String get reportDetailImageUnavailable => + 'Wala pang available na larawan ng ulat'; + + @override + String get reportDetailOpenReport => 'Pahina ng Ulat'; + @override String get navMore => 'Higit Pa'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index e48954000..68a73e0c3 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -241,6 +241,44 @@ class AppLocalizationsId extends AppLocalizations { @override String get reportListSearch => 'Cari'; + @override + String get reportDetailTitle => 'Laporan Gempa'; + + @override + String reportDetailNumbered(String number) { + return 'Gempa Dirasakan Signifikan No. $number'; + } + + @override + String get reportDetailLocalFelt => 'Gempa Dirasakan Lokal'; + + @override + String get reportDetailInfo => 'Detail'; + + @override + String get reportDetailOriginTime => 'Waktu kejadian'; + + @override + String get reportDetailEpicenter => 'Koordinat episentrum'; + + @override + String get reportDetailMagnitude => 'Magnitudo'; + + @override + String get reportDetailDepth => 'Kedalaman hiposenter'; + + @override + String get reportDetailAreaIntensity => 'Intensitas per wilayah'; + + @override + String get reportDetailImage => 'Gambar laporan'; + + @override + String get reportDetailImageUnavailable => 'Gambar laporan belum tersedia'; + + @override + String get reportDetailOpenReport => 'Halaman laporan'; + @override String get navMore => 'Lainnya'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index aa1a36b57..623438a26 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -240,6 +240,44 @@ class AppLocalizationsJa extends AppLocalizations { @override String get reportListSearch => '検索'; + @override + String get reportDetailTitle => '地震レポート'; + + @override + String reportDetailNumbered(String number) { + return 'No.$number 顕著有感地震'; + } + + @override + String get reportDetailLocalFelt => '局地的な有感地震'; + + @override + String get reportDetailInfo => '詳細情報'; + + @override + String get reportDetailOriginTime => '発震時刻'; + + @override + String get reportDetailEpicenter => '震央座標'; + + @override + String get reportDetailMagnitude => '地震規模'; + + @override + String get reportDetailDepth => '震源の深さ'; + + @override + String get reportDetailAreaIntensity => '地域別震度'; + + @override + String get reportDetailImage => '地震レポート画像'; + + @override + String get reportDetailImageUnavailable => 'レポート画像はまだありません'; + + @override + String get reportDetailOpenReport => 'レポートページ'; + @override String get navMore => 'その他'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 424c0b382..8b95e928d 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -240,6 +240,44 @@ class AppLocalizationsKo extends AppLocalizations { @override String get reportListSearch => '조회'; + @override + String get reportDetailTitle => '지진 보고서'; + + @override + String reportDetailNumbered(String number) { + return '번호 $number 유의미 유감지진'; + } + + @override + String get reportDetailLocalFelt => '국지적 유감지진'; + + @override + String get reportDetailInfo => '상세 정보'; + + @override + String get reportDetailOriginTime => '발생 시각'; + + @override + String get reportDetailEpicenter => '진앙 좌표'; + + @override + String get reportDetailMagnitude => '지진 규모'; + + @override + String get reportDetailDepth => '진원 깊이'; + + @override + String get reportDetailAreaIntensity => '지역별 진도'; + + @override + String get reportDetailImage => '지진 보고서 이미지'; + + @override + String get reportDetailImageUnavailable => '보고서 이미지가 아직 없습니다'; + + @override + String get reportDetailOpenReport => '보고서 페이지'; + @override String get navMore => '더보기'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index a77423eab..9992e1b78 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -240,6 +240,44 @@ class AppLocalizationsTh extends AppLocalizations { @override String get reportListSearch => 'ค้นหา'; + @override + String get reportDetailTitle => 'รายงานแผ่นดินไหว'; + + @override + String reportDetailNumbered(String number) { + return 'แผ่นดินไหวรู้สึกได้อย่างมีนัยสำคัญ หมายเลข $number'; + } + + @override + String get reportDetailLocalFelt => 'แผ่นดินไหวรู้สึกได้เฉพาะพื้นที่'; + + @override + String get reportDetailInfo => 'รายละเอียด'; + + @override + String get reportDetailOriginTime => 'เวลาเกิดเหตุ'; + + @override + String get reportDetailEpicenter => 'พิกัดศูนย์กลาง'; + + @override + String get reportDetailMagnitude => 'ขนาดแผ่นดินไหว'; + + @override + String get reportDetailDepth => 'ความลึกจุดศูนย์กลาง'; + + @override + String get reportDetailAreaIntensity => 'ความเข้มแยกตามพื้นที่'; + + @override + String get reportDetailImage => 'ภาพรายงานแผ่นดินไหว'; + + @override + String get reportDetailImageUnavailable => 'ยังไม่มีภาพรายงาน'; + + @override + String get reportDetailOpenReport => 'หน้ารายงาน'; + @override String get navMore => 'เพิ่มเติม'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 645b63138..bf78def1d 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -240,6 +240,44 @@ class AppLocalizationsVi extends AppLocalizations { @override String get reportListSearch => 'Tìm'; + @override + String get reportDetailTitle => 'Báo cáo động đất'; + + @override + String reportDetailNumbered(String number) { + return 'Động đất có cảm nhận đáng kể số $number'; + } + + @override + String get reportDetailLocalFelt => 'Động đất cảm nhận cục bộ'; + + @override + String get reportDetailInfo => 'Chi tiết'; + + @override + String get reportDetailOriginTime => 'Thời gian xảy ra'; + + @override + String get reportDetailEpicenter => 'Tọa độ tâm chấn'; + + @override + String get reportDetailMagnitude => 'Độ lớn'; + + @override + String get reportDetailDepth => 'Độ sâu chấn tiêu'; + + @override + String get reportDetailAreaIntensity => 'Cường độ theo khu vực'; + + @override + String get reportDetailImage => 'Hình ảnh báo cáo'; + + @override + String get reportDetailImageUnavailable => 'Hình ảnh báo cáo chưa có sẵn'; + + @override + String get reportDetailOpenReport => 'Trang báo cáo'; + @override String get navMore => 'Thêm'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 912e22f47..e9ddb9226 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -239,6 +239,44 @@ class AppLocalizationsZh extends AppLocalizations { @override String get reportListSearch => '查詢'; + @override + String get reportDetailTitle => '地震報告'; + + @override + String reportDetailNumbered(String number) { + return '編號 $number 顯著有感地震'; + } + + @override + String get reportDetailLocalFelt => '小區域有感地震'; + + @override + String get reportDetailInfo => '詳細資訊'; + + @override + String get reportDetailOriginTime => '發震時間'; + + @override + String get reportDetailEpicenter => '震央座標'; + + @override + String get reportDetailMagnitude => '地震規模'; + + @override + String get reportDetailDepth => '震源深度'; + + @override + String get reportDetailAreaIntensity => '各地震度'; + + @override + String get reportDetailImage => '地震報告圖'; + + @override + String get reportDetailImageUnavailable => '報告圖尚未提供'; + + @override + String get reportDetailOpenReport => '報告頁面'; + @override String get navMore => '更多'; @@ -1526,6 +1564,44 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get reportListSearch => '查询'; + @override + String get reportDetailTitle => '地震报告'; + + @override + String reportDetailNumbered(String number) { + return '编号 $number 显著有感地震'; + } + + @override + String get reportDetailLocalFelt => '小区域有感地震'; + + @override + String get reportDetailInfo => '详细信息'; + + @override + String get reportDetailOriginTime => '发震时间'; + + @override + String get reportDetailEpicenter => '震中坐标'; + + @override + String get reportDetailMagnitude => '地震规模'; + + @override + String get reportDetailDepth => '震源深度'; + + @override + String get reportDetailAreaIntensity => '各地震度'; + + @override + String get reportDetailImage => '地震报告图'; + + @override + String get reportDetailImageUnavailable => '报告图尚未提供'; + + @override + String get reportDetailOpenReport => '报告页面'; + @override String get navMore => '更多'; @@ -2813,6 +2889,44 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get reportListSearch => '查詢'; + @override + String get reportDetailTitle => '地震報告'; + + @override + String reportDetailNumbered(String number) { + return '編號 $number 顯著有感地震'; + } + + @override + String get reportDetailLocalFelt => '小區域有感地震'; + + @override + String get reportDetailInfo => '詳細資訊'; + + @override + String get reportDetailOriginTime => '發震時間'; + + @override + String get reportDetailEpicenter => '震央座標'; + + @override + String get reportDetailMagnitude => '地震規模'; + + @override + String get reportDetailDepth => '震源深度'; + + @override + String get reportDetailAreaIntensity => '各地震度'; + + @override + String get reportDetailImage => '地震報告圖'; + + @override + String get reportDetailImageUnavailable => '報告圖尚未提供'; + + @override + String get reportDetailOpenReport => '報告頁面'; + @override String get navMore => '更多'; @@ -4100,6 +4214,44 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get reportListSearch => '查詢'; + @override + String get reportDetailTitle => '地震報告'; + + @override + String reportDetailNumbered(String number) { + return '編號 $number 顯著有感地震'; + } + + @override + String get reportDetailLocalFelt => '小區域有感地震'; + + @override + String get reportDetailInfo => '詳細資訊'; + + @override + String get reportDetailOriginTime => '發震時間'; + + @override + String get reportDetailEpicenter => '震央座標'; + + @override + String get reportDetailMagnitude => '地震規模'; + + @override + String get reportDetailDepth => '震源深度'; + + @override + String get reportDetailAreaIntensity => '各地震度'; + + @override + String get reportDetailImage => '地震報告圖'; + + @override + String get reportDetailImageUnavailable => '報告圖尚未提供'; + + @override + String get reportDetailOpenReport => '報告頁面'; + @override String get navMore => '更多'; diff --git a/lib/shared/navigation/app_routes.dart b/lib/shared/navigation/app_routes.dart index fc6a76704..522085545 100644 --- a/lib/shared/navigation/app_routes.dart +++ b/lib/shared/navigation/app_routes.dart @@ -26,6 +26,11 @@ abstract final class AppRoutes { static const String earthquake = 'earthquake'; static const String earthquakePath = 'earthquake'; + /// A single report's detail (震度/規模 breakdown) — nested under + /// [earthquakePath] with an `:id` path parameter (the report id). + static const String earthquakeReport = 'earthquakeReport'; + static const String earthquakeReportPath = ':id'; + /// Live EEW monitor — nested under [dataPath]. static const String eew = 'eew'; static const String eewPath = 'eew'; diff --git a/lib/shared/seismic/report_colors.dart b/lib/shared/seismic/report_colors.dart new file mode 100644 index 000000000..42a827a6c --- /dev/null +++ b/lib/shared/seismic/report_colors.dart @@ -0,0 +1,60 @@ +/// Colour ramps for an earthquake report's magnitude and hypocentral depth — +/// small indicator dots next to the numeric value on the report detail page. +/// Ported verbatim (same hex values) from the legacy palette. +library; + +import 'package:flutter/material.dart'; + +/// Continuous colour ramp for magnitude, interpolated between named stops. +abstract final class MagnitudeColors { + static const List<(double magnitude, Color color)> _stops = [ + (2.5, Color(0xFF00C8C8)), + (3.5, Color(0xFF00C800)), + (4.5, Color(0xFFFFC800)), + (6.0, Color(0xFFFF0000)), + (7.0, Color(0xFF9600FF)), + ]; + + /// The colour for [magnitude], linearly interpolated between stops and + /// clamped to the end colours outside the ramp's range. + static Color of(double magnitude) { + if (magnitude <= _stops.first.$1) return _stops.first.$2; + if (magnitude >= _stops.last.$1) return _stops.last.$2; + for (var i = 0; i < _stops.length - 1; i++) { + final (lo, loColor) = _stops[i]; + final (hi, hiColor) = _stops[i + 1]; + if (magnitude >= lo && magnitude < hi) { + return Color.lerp(loColor, hiColor, (magnitude - lo) / (hi - lo))!; + } + } + return _stops.first.$2; + } +} + +/// Continuous colour ramp for hypocentral depth (km), interpolated between +/// named stops — shallow (red) to deep (blue). +abstract final class DepthColors { + static const List<(double depthKm, Color color)> _stops = [ + (5, Color(0xFFFF0000)), + (15, Color(0xFFFF6400)), + (30, Color(0xFFFFC800)), + (50, Color(0xFF00C800)), + (100, Color(0xFF00C8C8)), + (150, Color(0xFF0000C8)), + ]; + + /// The colour for [depthKm], linearly interpolated between stops and + /// clamped to the end colours outside the ramp's range. + static Color of(double depthKm) { + if (depthKm <= _stops.first.$1) return _stops.first.$2; + if (depthKm >= _stops.last.$1) return _stops.last.$2; + for (var i = 0; i < _stops.length - 1; i++) { + final (lo, loColor) = _stops[i]; + final (hi, hiColor) = _stops[i + 1]; + if (depthKm >= lo && depthKm < hi) { + return Color.lerp(loColor, hiColor, (depthKm - lo) / (hi - lo))!; + } + } + return _stops.first.$2; + } +} diff --git a/lib/shared/widgets/intensity_badge.dart b/lib/shared/widgets/intensity_badge.dart new file mode 100644 index 000000000..f2028f819 --- /dev/null +++ b/lib/shared/widgets/intensity_badge.dart @@ -0,0 +1,54 @@ +import 'package:dpip/app/theme/app_radius.dart'; +import 'package:flutter/material.dart'; + +/// A filled square badge showing a felt-intensity label (e.g. `5⁻`) over its +/// palette [color] — the report list row and report detail header share this +/// so the two never drift in style. +class IntensityBadge extends StatelessWidget { + const IntensityBadge({ + super.key, + required this.label, + required this.color, + this.size = 48, + }); + + /// Text drawn on the badge (see [Intensity.displayForReport]). + final String label; + + /// Palette colour for the badge fill (see `IntensityColors.discrete`). + final Color color; + + /// Edge length of the square badge. + final double size; + + @override + Widget build(BuildContext context) { + final onBadge = + ThemeData.estimateBrightnessForColor(color) == Brightness.dark + ? Colors.white + : Colors.black87; + final titleLarge = Theme.of(context).textTheme.titleLarge; + // Scale proportionally from the reference 48px badge size so larger/ + // smaller badges (e.g. the detail header) keep the same visual weight. + final fontSize = (titleLarge?.fontSize ?? 22) * (size / 48); + return DecoratedBox( + decoration: BoxDecoration(color: color, borderRadius: AppRadius.small), + child: SizedBox( + width: size, + height: size, + child: Center( + child: Text( + label, + style: titleLarge?.copyWith( + color: onBadge, + fontWeight: FontWeight.w900, + height: 1, + fontSize: fontSize, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 061643e39..e4d8beb15 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,3 +68,22 @@ flutter: - assets/location.json.gz - assets/map/town_boundaries.bin.gz - assets/travel_time.json.gz + - assets/map/icons/cross.png + - assets/map/icons/intensity-1.png + - assets/map/icons/intensity-1-dark.png + - assets/map/icons/intensity-2.png + - assets/map/icons/intensity-2-dark.png + - assets/map/icons/intensity-3.png + - assets/map/icons/intensity-3-dark.png + - assets/map/icons/intensity-4.png + - assets/map/icons/intensity-4-dark.png + - assets/map/icons/intensity-5.png + - assets/map/icons/intensity-5-dark.png + - assets/map/icons/intensity-6.png + - assets/map/icons/intensity-6-dark.png + - assets/map/icons/intensity-7.png + - assets/map/icons/intensity-7-dark.png + - assets/map/icons/intensity-8.png + - assets/map/icons/intensity-8-dark.png + - assets/map/icons/intensity-9.png + - assets/map/icons/intensity-9-dark.png diff --git a/test/features/earthquake/earthquake_report_test.dart b/test/features/earthquake/earthquake_report_test.dart new file mode 100644 index 000000000..3d2471b46 --- /dev/null +++ b/test/features/earthquake/earthquake_report_test.dart @@ -0,0 +1,61 @@ +import 'package:dpip/features/earthquake/domain/earthquake_report.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('EarthquakeReport', () { + final json = { + 'id': '115053-2026-0731-005836', + 'lat': 23.1, + 'lon': 121.36, + 'depth': 20.2, + 'loc': '臺東縣政府北北東方 44.1 公里 (位於臺東縣成功鎮)', + 'mag': 4.7, + 'time': 1785430716000, + 'trem': 0, + 'list': { + '臺東縣': { + 'int': 4, + 'town': { + '成功鎮': {'lon': 121.36, 'lat': 23.1, 'int': 4}, + '長濱鄉': {'lon': 121.4, 'lat': 23.3, 'int': 3}, + }, + }, + }, + }; + + test('fromJson round-trip', () { + final report = EarthquakeReport.fromJson(json); + expect(report.id, '115053-2026-0731-005836'); + expect(report.magnitude, 4.7); + expect(report.isLocalFelt, isFalse); + expect(report.hasNumber, isTrue); + expect(report.number, '115053'); + expect(report.shortLocation, '臺東縣成功鎮'); + expect(report.maxIntensity, 4); + expect(report.list['臺東縣']?.town['成功鎮']?.intensity, 4); + expect(EarthquakeReport.fromJson(report.toJson()), report); + }); + + test('…000 serial is local-felt, not a numbered report', () { + final report = EarthquakeReport.fromJson({ + ...json, + 'id': '115000-2026-0731-005836', + }); + expect(report.isLocalFelt, isTrue); + expect(report.hasNumber, isFalse); + expect(report.number, isNull); + }); + + test('reportUrl and reportImageUrl are derived from id/time/magnitude', () { + final report = EarthquakeReport.fromJson(json); + expect( + report.reportUrl.toString(), + 'https://scweb.cwa.gov.tw/zh-tw/earthquake/details/2026073100583647053', + ); + expect( + report.reportImageUrl.toString(), + 'https://scweb.cwa.gov.tw/webdata/OLDEQ/202607/2026073100583647053_H.png', + ); + }); + }); +}