Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
<!-- Onboarding "省電白名單": request Doze exemption so background reporting
keeps running on aggressive battery managers (safety-app use). -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<!-- Saving the report image to the gallery (gal): Android 10+ writes
through MediaStore and needs no permission at all, so this is capped at
the API levels that still use the legacy external-storage write —
minSdk is 26, so those levels really are reachable. -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"/>

<!-- Bluetooth (meshtastic_flutter / flutter_blue_plus): connect to LoRa
mesh radios over BLE. The maxSdkVersion=30 pair is the legacy API;
Expand Down
2 changes: 2 additions & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@
<string>DPIP 需要在背景持續存取你的位置,即使關閉 App 也能為你所在地推送地震、天氣等災害警報。</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>DPIP 需要你的位置,以提供你所在地的地震、天氣等災害警報。</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>DPIP 需要存取相簿,把你選擇的地震報告圖儲存到相簿。</string>
<key>NSUserActivityTypes</key>
<array>
<string>WeatherWidgetConfigurationIntent</string>
Expand Down
55 changes: 55 additions & 0 deletions lib/core/platform/photo_library.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import 'dart:typed_data';

import 'package:dpip/core/logging/log.dart';
import 'package:gal/gal.dart';

/// How a save-to-photo-library attempt ended — the only three shapes the UI
/// can say something different about.
enum PhotoSaveOutcome {
/// The image is in the system photo library.
saved,

/// The user or the OS refused photo-library access.
permissionDenied,

/// Anything else: no space, bytes the platform cannot decode, a native
/// failure.
failed,
}

/// Writes image bytes into the system photo library (相簿).
///
/// `gal` is wrapped here rather than called from the report page so the page
/// has no plugin dependency: this file owns the permission dance (ask once,
/// report a refusal as [PhotoSaveOutcome.permissionDenied] instead of an
/// exception) and the [GalException] → [PhotoSaveOutcome] mapping, and a test
/// can stub the plugin's `gal` method channel.
abstract final class PhotoLibrary {
const PhotoLibrary._();

/// Saves [bytes] as an image named [name].
///
/// [name] carries no extension: the native side derives one from the bytes
/// themselves (Android guesses the format, iOS hands the name to Photos).
static Future<PhotoSaveOutcome> saveImage(
Uint8List bytes, {
required String name,
}) async {
try {
// `putImageBytes` asks for access on its own, but it throws away the
// answer and lets the native call fail with ACCESS_DENIED instead. Asking
// first turns a refusal into the outcome the UI can explain, and when the
// permission is already granted nothing is shown to the user.
if (!await Gal.hasAccess() && !await Gal.requestAccess()) {
return PhotoSaveOutcome.permissionDenied;
}
await Gal.putImageBytes(bytes, name: name);
return PhotoSaveOutcome.saved;
} on GalException catch (error) {
Log.error('photo library save failed', error, error.stackTrace);
return error.type == GalExceptionType.accessDenied
? PhotoSaveOutcome.permissionDenied
: PhotoSaveOutcome.failed;
}
}
}
89 changes: 71 additions & 18 deletions lib/features/earthquake/presentation/pages/report_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import 'package:dpip/core/geo/town_directory.dart';
import 'package:dpip/core/logging/log.dart';
import 'package:dpip/core/models/lat_lng.dart' as geo;
import 'package:dpip/core/network/api_client.dart';
import 'package:dpip/core/platform/photo_library.dart';
import 'package:dpip/core/realtime/app_time.dart';
import 'package:dpip/core/settings/home_area.dart';
import 'package:dpip/core/settings/region_store.dart';
Expand Down Expand Up @@ -1685,6 +1686,36 @@ class _ReportImageCardState extends State<_ReportImageCard> {
/// image itself sizes to its own natural proportions once loaded.
static const double _placeholderHeight = 200;

/// A save is in flight — the button stays visible but disabled so a second
/// tap cannot start a second write to the gallery.
bool _saving = false;

/// The section is CWA's picture: save those exact bytes, never a screenshot
/// of the card, so what lands in the gallery is the official report at full
/// resolution rather than the on-screen, width-scaled copy.
Future<void> _save(BuildContext context, Uint8List bytes) async {
if (_saving) return;
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.maybeOf(context);
setState(() => _saving = true);
final outcome = await PhotoLibrary.saveImage(
bytes,
name: 'dpip-report-${widget.report.id}',
);
if (!mounted) return;
setState(() => _saving = false);
messenger?.showSnackBar(
SnackBar(
content: Text(switch (outcome) {
PhotoSaveOutcome.saved => l10n.reportDetailImageSaved,
PhotoSaveOutcome.permissionDenied =>
l10n.reportDetailImageSavePermission,
PhotoSaveOutcome.failed => l10n.reportDetailImageSaveFailed,
}),
),
);
}

@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
Expand Down Expand Up @@ -1720,26 +1751,48 @@ class _ReportImageCardState extends State<_ReportImageCard> {
),
);
}
return ClipRRect(
borderRadius: AppRadius.medium,
child: Image.memory(
snapshot.data!,
// 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,
errorBuilder: (context, error, stackTrace) => Container(
height: _placeholderHeight,
width: double.infinity,
color: colors.surfaceContainer,
alignment: Alignment.center,
child: Text(
l10n.reportDetailImageUnavailable,
style: TextStyle(color: colors.onSurfaceVariant),
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClipRRect(
borderRadius: AppRadius.medium,
child: Image.memory(
snapshot.data!,
// 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,
errorBuilder: (context, error, stackTrace) => Container(
height: _placeholderHeight,
width: double.infinity,
color: colors.surfaceContainer,
alignment: Alignment.center,
child: Text(
l10n.reportDetailImageUnavailable,
style: TextStyle(color: colors.onSurfaceVariant),
),
),
),
),
),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: _saving
? null
: () => _save(context, snapshot.data!),
icon: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download_outlined),
label: Text(l10n.reportDetailImageSave),
),
),
],
);
},
);
Expand Down
16 changes: 16 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,18 @@
"@reportDetailImageUnavailable": {
"description": "Shown in place of the report image when it fails to load"
},
"@reportDetailImageSave": {
"description": "Button under the CWA report image that saves it to the photo library"
},
"@reportDetailImageSaved": {
"description": "Snackbar confirming the CWA report image was saved to the photo library"
},
"@reportDetailImageSavePermission": {
"description": "Snackbar shown when photo-library access is denied, so the report image cannot be saved"
},
"@reportDetailImageSaveFailed": {
"description": "Snackbar shown when saving the report image failed"
},
"meshtasticFirmware": "Firmware",
"@mapLayerMeshtastic": {
"description": "Map layer name: mesh nodes"
Expand Down Expand Up @@ -1638,6 +1650,10 @@
"description": "Rainfall accumulation since local midnight (API now)"
},
"reportDetailImageUnavailable": "Report image not available",
"reportDetailImageSave": "Save image",
"reportDetailImageSaved": "Saved to your photo library",
"reportDetailImageSavePermission": "Photo library access is off. Turn it on in system settings.",
"reportDetailImageSaveFailed": "Couldn't save the image. Try again.",
"@weatherRankingMergeTo": {
"description": "Label before township/county merge chips on ranking"
},
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_fil.arb
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,10 @@
"reportFilterOrderAsc": "Pataas",
"reportFilterApply": "I-apply",
"reportDetailImageUnavailable": "Wala pang available na larawan ng ulat",
"reportDetailImageSave": "I-save ang larawan",
"reportDetailImageSaved": "Na-save sa photo library",
"reportDetailImageSavePermission": "Naka-off ang access sa photo library. I-on ito sa system settings.",
"reportDetailImageSaveFailed": "Hindi na-save ang larawan. Subukan muli.",
"@weatherModeSand": {
"description": "Label for the weatherModeSand option in the experimental backdrop settings."
},
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_id.arb
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,10 @@
"reportFilterOrderAsc": "Menaik",
"reportFilterApply": "Terapkan",
"reportDetailImageUnavailable": "Gambar laporan belum tersedia",
"reportDetailImageSave": "Simpan gambar",
"reportDetailImageSaved": "Tersimpan ke galeri foto",
"reportDetailImageSavePermission": "Akses galeri foto dimatikan. Aktifkan di pengaturan sistem.",
"reportDetailImageSaveFailed": "Gagal menyimpan gambar. Coba lagi.",
"@weatherModeSand": {
"description": "Label for the weatherModeSand option in the experimental backdrop settings."
},
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_ja.arb
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,10 @@
"reportFilterOrderAsc": "昇順",
"reportFilterApply": "適用",
"reportDetailImageUnavailable": "レポート画像はまだありません",
"reportDetailImageSave": "画像を保存",
"reportDetailImageSaved": "写真ライブラリに保存しました",
"reportDetailImageSavePermission": "写真ライブラリへのアクセスが許可されていません。システム設定から許可してください。",
"reportDetailImageSaveFailed": "画像を保存できませんでした。もう一度お試しください。",
"@weatherModeSand": {
"description": "Label for the weatherModeSand option in the experimental backdrop settings."
},
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_ko.arb
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,10 @@
"reportFilterOrderAsc": "오름차순",
"reportFilterApply": "적용",
"reportDetailImageUnavailable": "보고서 이미지가 아직 없습니다",
"reportDetailImageSave": "이미지 저장",
"reportDetailImageSaved": "사진 보관함에 저장했습니다",
"reportDetailImageSavePermission": "사진 보관함 접근 권한이 없습니다. 시스템 설정에서 허용해 주세요.",
"reportDetailImageSaveFailed": "이미지를 저장하지 못했습니다. 다시 시도해 주세요.",
"@weatherModeSand": {
"description": "Label for the weatherModeSand option in the experimental backdrop settings."
},
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_th.arb
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,10 @@
"reportFilterOrderAsc": "น้อย→มาก",
"reportFilterApply": "ใช้",
"reportDetailImageUnavailable": "ยังไม่มีภาพรายงาน",
"reportDetailImageSave": "บันทึกรูปภาพ",
"reportDetailImageSaved": "บันทึกลงอัลบั้มภาพแล้ว",
"reportDetailImageSavePermission": "ไม่ได้รับสิทธิ์เข้าถึงอัลบั้มภาพ โปรดเปิดในตั้งค่าระบบ",
"reportDetailImageSaveFailed": "บันทึกรูปภาพไม่สำเร็จ โปรดลองอีกครั้ง",
"@weatherModeSand": {
"description": "Label for the weatherModeSand option in the experimental backdrop settings."
},
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_vi.arb
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,10 @@
"reportFilterOrderAsc": "Tăng dần",
"reportFilterApply": "Áp dụng",
"reportDetailImageUnavailable": "Hình ảnh báo cáo chưa có sẵn",
"reportDetailImageSave": "Lưu ảnh",
"reportDetailImageSaved": "Đã lưu vào thư viện ảnh",
"reportDetailImageSavePermission": "Chưa được cấp quyền truy cập thư viện ảnh. Hãy bật trong cài đặt hệ thống.",
"reportDetailImageSaveFailed": "Không lưu được ảnh. Vui lòng thử lại.",
"@weatherModeSand": {
"description": "Label for the weatherModeSand option in the experimental backdrop settings."
},
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_yue.arb
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,10 @@
"reportFilterOrderAsc": "升序",
"reportFilterApply": "套用",
"reportDetailImageUnavailable": "報告圖尚未提供",
"reportDetailImageSave": "儲存圖片",
"reportDetailImageSaved": "已儲存到相簿",
"reportDetailImageSavePermission": "冇相簿權限,請去系統設定開啟",
"reportDetailImageSaveFailed": "儲存失敗,請遲啲再試",
"@weatherModeSand": {
"description": "Label for the weatherModeSand option in the experimental backdrop settings."
},
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_zh.arb
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,10 @@
"reportFilterOrderAsc": "升序",
"reportFilterApply": "套用",
"reportDetailImageUnavailable": "報告圖尚未提供",
"reportDetailImageSave": "儲存圖片",
"reportDetailImageSaved": "已儲存到相簿",
"reportDetailImageSavePermission": "沒有相簿權限,請到系統設定開啟",
"reportDetailImageSaveFailed": "儲存失敗,請稍後再試",
"@weatherModeSand": {
"description": "Label for the weatherModeSand option in the experimental backdrop settings."
},
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_zh_Hans.arb
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,10 @@
"reportFilterOrderAsc": "升序",
"reportFilterApply": "应用",
"reportDetailImageUnavailable": "报告图尚未提供",
"reportDetailImageSave": "保存图片",
"reportDetailImageSaved": "已保存到相册",
"reportDetailImageSavePermission": "没有相册权限,请在系统设置中开启",
"reportDetailImageSaveFailed": "保存失败,请稍后再试",
"@weatherModeSand": {
"description": "Label for the weatherModeSand option in the experimental backdrop settings."
},
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_zh_Hant_HK.arb
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,10 @@
"reportFilterOrderAsc": "升序",
"reportFilterApply": "套用",
"reportDetailImageUnavailable": "報告圖尚未提供",
"reportDetailImageSave": "儲存圖片",
"reportDetailImageSaved": "已儲存到相簿",
"reportDetailImageSavePermission": "沒有相簿權限,請到系統設定開啟",
"reportDetailImageSaveFailed": "儲存失敗,請稍後再試",
"@weatherModeSand": {
"description": "Label for the weatherModeSand option in the experimental backdrop settings."
},
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_zh_TW.arb
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,10 @@
"reportFilterOrderAsc": "升序",
"reportFilterApply": "套用",
"reportDetailImageUnavailable": "報告圖尚未提供",
"reportDetailImageSave": "儲存圖片",
"reportDetailImageSaved": "已儲存到相簿",
"reportDetailImageSavePermission": "沒有相簿權限,請到系統設定開啟",
"reportDetailImageSaveFailed": "儲存失敗,請稍後再試",
"@weatherModeSand": {
"description": "Label for the weatherModeSand option in the experimental backdrop settings."
},
Expand Down
24 changes: 24 additions & 0 deletions lib/l10n/gen/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2382,6 +2382,30 @@ abstract class AppLocalizations {
/// **'Report image not available'**
String get reportDetailImageUnavailable;

/// Button under the CWA report image that saves it to the photo library
///
/// In en, this message translates to:
/// **'Save image'**
String get reportDetailImageSave;

/// Snackbar confirming the CWA report image was saved to the photo library
///
/// In en, this message translates to:
/// **'Saved to your photo library'**
String get reportDetailImageSaved;

/// Snackbar shown when photo-library access is denied, so the report image cannot be saved
///
/// In en, this message translates to:
/// **'Photo library access is off. Turn it on in system settings.'**
String get reportDetailImageSavePermission;

/// Snackbar shown when saving the report image failed
///
/// In en, this message translates to:
/// **'Couldn\'t save the image. Try again.'**
String get reportDetailImageSaveFailed;

/// Button that opens the RTS/EEW replay starting from this report's origin time
///
/// In en, this message translates to:
Expand Down
14 changes: 14 additions & 0 deletions lib/l10n/gen/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1254,6 +1254,20 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get reportDetailImageUnavailable => 'Report image not available';

@override
String get reportDetailImageSave => 'Save image';

@override
String get reportDetailImageSaved => 'Saved to your photo library';

@override
String get reportDetailImageSavePermission =>
'Photo library access is off. Turn it on in system settings.';

@override
String get reportDetailImageSaveFailed =>
'Couldn\'t save the image. Try again.';

@override
String get reportDetailReplay => 'Replay';

Expand Down
Loading
Loading