diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 47aa09d..bf5fc7a 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -499,6 +499,7 @@ "{0} · 그림 · 동영상 {1}개": "{0} · {1} pictures / videos", "{0} · 링크 {1}개 제외": "{0} · {1} links excluded", "{0} · 파일 {1}개": "{0} · {1} files", + "{0} × {1} 픽셀": "{0} × {1} px", "{0} → {1} 번역 {2}%": "{0} → {1} translation {2}%", "{0} 는 이미 끝났습니다": "{0} has already finished", "{0} 라이선스": "{0} license", @@ -1526,6 +1527,7 @@ "사용할 수 있는 자막 사이트가 없습니다.": "No subtitle site is available.", "사진": "Photos", "사진 슬라이드쇼": "Photo slideshow", + "사진 정보 (Exif)": "Photo info (Exif)", "사진에는 '사진' 모델이 더 낫지만, 이 기기 (그래픽카드 없음) 에서는 한 장에 약 20분 걸립니다.": "The 'Photos' model is better for photos, but on this device (no graphics card) it takes about 20 minutes per image.", "사진용": "Photos", "삭제": "Delete", @@ -2022,6 +2024,9 @@ "위아래 ⇅": "Rows ⇅", "위쪽 이동 버튼과 좌우로 밀기의 순서입니다. ▲ ▼ 로 바꿉니다.": "Order of the top navigation buttons and of swiping. Change it with ▲ ▼.", "위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.": "Order of the top navigation buttons and of swiping. Drag a row to change it.", + "위치": "Location", + "위치 보기": "Show location", + "위치는 이 기기에서만 보이며 지도 · 인터넷으로 보내지 않습니다": "The location is shown only on this device and is never sent to a map or the internet", "유형": "Type", "음성": "Audio", "음성 {0}개": "{0} audio", @@ -2041,6 +2046,7 @@ "이 WebDAV 서버는 로그인이 필요합니다": "This WebDAV server requires sign-in", "이 {0}의 설정 파일에 저장됩니다": "Saved in this {0}'s settings file", "이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)": "Saved in this {0}'s secure storage (not in the settings file)", + "이 그림에는 Exif 정보가 없습니다 (JPEG 사진만 읽음)": "This image has no Exif info (only JPEG photos are read)", "이 그림을 바탕으로 (그림 → 그림)": "Use this as the base (image → image)", "이 기기 (SD1.5 + LCM)": "This device (SD1.5 + LCM)", "이 기기 ({0})": "This device ({0})", @@ -2513,6 +2519,7 @@ "집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "If it uses a self-signed certificate (like a home NAS), turn on \"Don't verify certificate\" in [Edit server settings].", "짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "Shorter means faster syncing but more battery use.", "쪽 범위가 맞지 않습니다": "The page range is invalid", + "찍은 날": "Taken", "차례 기다림": "Waiting in queue", "창": "Panes", "창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "Icon of the window, taskbar, tray and the top-left button in the app (the program file and desktop shortcut icons stay the same)", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 48bec88..32b4107 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -499,6 +499,7 @@ "{0} · 그림 · 동영상 {1}개": "{0} · 画像・動画 {1} 個", "{0} · 링크 {1}개 제외": "{0} · リンク {1} 件を除く", "{0} · 파일 {1}개": "{0} · ファイル {1} 件", + "{0} × {1} 픽셀": "{0} × {1} ピクセル", "{0} → {1} 번역 {2}%": "{0} → {1} 翻訳 {2}%", "{0} 는 이미 끝났습니다": "{0} はすでに終わりました", "{0} 라이선스": "{0} ライセンス", @@ -1526,6 +1527,7 @@ "사용할 수 있는 자막 사이트가 없습니다.": "使用できる字幕サイトがありません。", "사진": "写真", "사진 슬라이드쇼": "写真スライドショー", + "사진 정보 (Exif)": "写真情報 (Exif)", "사진에는 '사진' 모델이 더 낫지만, 이 기기 (그래픽카드 없음) 에서는 한 장에 약 20분 걸립니다.": "写真には「写真」モデルが適していますが、この端末 (グラフィックカードなし) では 1 枚に約 20 分かかります。", "사진용": "写真用", "삭제": "削除", @@ -2022,6 +2024,9 @@ "위아래 ⇅": "上下 ⇅", "위쪽 이동 버튼과 좌우로 밀기의 순서입니다. ▲ ▼ 로 바꿉니다.": "上部の移動ボタンと左右スワイプの順番です。▲ ▼ で変更します。", "위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.": "上部の移動ボタンと左右スワイプの順番です。行をドラッグして変更します。", + "위치": "位置", + "위치 보기": "位置を表示", + "위치는 이 기기에서만 보이며 지도 · 인터넷으로 보내지 않습니다": "位置はこの端末でのみ表示され、地図やインターネットには送信しません", "유형": "種類", "음성": "音声", "음성 {0}개": "音声 {0}個", @@ -2041,6 +2046,7 @@ "이 WebDAV 서버는 로그인이 필요합니다": "この WebDAV サーバーはログインが必要です", "이 {0}의 설정 파일에 저장됩니다": "この{0}の設定ファイルに保存されます", "이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)": "この {0} の安全な保存領域に保存されます (設定ファイルには書きません)", + "이 그림에는 Exif 정보가 없습니다 (JPEG 사진만 읽음)": "この画像には Exif 情報がありません (JPEG 写真のみ読み込み)", "이 그림을 바탕으로 (그림 → 그림)": "この画像を元に (画像 → 画像)", "이 기기 (SD1.5 + LCM)": "この端末 (SD1.5 + LCM)", "이 기기 ({0})": "この端末 ({0})", @@ -2513,6 +2519,7 @@ "집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "自宅 NAS のように自己署名証明書なら、[サーバー設定を修正] で「証明書を検証しない」をオンにしてください。", "짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "短いほど早く同期しますが、バッテリーをより使います。", "쪽 범위가 맞지 않습니다": "ページ範囲が正しくありません", + "찍은 날": "撮影日", "차례 기다림": "順番待ち", "창": "ペイン", "창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "ウィンドウ・タスクバー・トレイとアプリ内左上のボタンのアイコンです(実行ファイル・デスクトップのショートカットのアイコンはそのまま)", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 47c32f6..159cd9d 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -499,6 +499,7 @@ "{0} · 그림 · 동영상 {1}개": "{0} · {1} 个图片 / 视频", "{0} · 링크 {1}개 제외": "{0} · 已排除 {1} 个链接", "{0} · 파일 {1}개": "{0} · {1} 个文件", + "{0} × {1} 픽셀": "{0} × {1} 像素", "{0} → {1} 번역 {2}%": "{0} → {1} 翻译 {2}%", "{0} 는 이미 끝났습니다": "{0} 已经完成", "{0} 라이선스": "{0} 许可证", @@ -1526,6 +1527,7 @@ "사용할 수 있는 자막 사이트가 없습니다.": "没有可用的字幕网站。", "사진": "照片", "사진 슬라이드쇼": "照片幻灯片", + "사진 정보 (Exif)": "照片信息 (Exif)", "사진에는 '사진' 모델이 더 낫지만, 이 기기 (그래픽카드 없음) 에서는 한 장에 약 20분 걸립니다.": "照片更适合使用“照片”模型,但在本设备 (无显卡) 上每张约需 20 分钟。", "사진용": "照片", "삭제": "删除", @@ -2022,6 +2024,9 @@ "위아래 ⇅": "上下 ⇅", "위쪽 이동 버튼과 좌우로 밀기의 순서입니다. ▲ ▼ 로 바꿉니다.": "顶部导航按钮和左右滑动的顺序。用 ▲ ▼ 更改。", "위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.": "顶部导航按钮和左右滑动的顺序。拖动行即可更改。", + "위치": "位置", + "위치 보기": "显示位置", + "위치는 이 기기에서만 보이며 지도 · 인터넷으로 보내지 않습니다": "位置仅在本设备上显示,不会发送到地图或互联网", "유형": "类型", "음성": "音频", "음성 {0}개": "音频 {0} 个", @@ -2041,6 +2046,7 @@ "이 WebDAV 서버는 로그인이 필요합니다": "此 WebDAV 服务器需要登录", "이 {0}의 설정 파일에 저장됩니다": "保存在此{0}的设置文件中", "이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)": "保存在此 {0} 的安全存储中 (不写入设置文件)", + "이 그림에는 Exif 정보가 없습니다 (JPEG 사진만 읽음)": "此图片没有 Exif 信息 (仅读取 JPEG 照片)", "이 그림을 바탕으로 (그림 → 그림)": "以此图片为基础 (图片 → 图片)", "이 기기 (SD1.5 + LCM)": "本设备 (SD1.5 + LCM)", "이 기기 ({0})": "本设备 ({0})", @@ -2513,6 +2519,7 @@ "집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "如果是自签名证书 (如家用 NAS),请在 [修改服务器设置] 中开启“不验证证书”。", "짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "间隔越短同步越快,但更耗电。", "쪽 범위가 맞지 않습니다": "页面范围无效", + "찍은 날": "拍摄日期", "차례 기다림": "排队等待", "창": "窗格", "창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "窗口、任务栏、托盘以及应用内左上角按钮的图标(程序文件和桌面快捷方式的图标不变)", diff --git a/lib/core/photo_info.dart b/lib/core/photo_info.dart new file mode 100644 index 0000000..e7b29b2 --- /dev/null +++ b/lib/core/photo_info.dart @@ -0,0 +1,70 @@ +import 'dart:typed_data'; + +import 'package:image/image.dart' as img; + +/// X-plore 맞추기 (관리자 지시 10/20): 보기 화면의 사진 정보 (Exif) - 찍은 날 · 기기 · 크기 · 위치. +/// image 패키지 (MIT - 앱이 이미 쓰는 것, 라이선스 화면에 있음) 로 읽는다. 새 의존 없음. 위치는 보기 화면이 누를 때만 보이고, +/// 지도 · 인터넷으로 보내지 않는다 (이 파일은 숫자만 읽는다). +class PhotoInfo { + /// 그림 크기 (픽셀, 모르면 null) + final int? width, height; + + /// 찍은 날 ("2024-05-01 12:30:00", Exif 의 DateTimeOriginal · 없으면 DateTime) + final String? taken; + + /// 기기 ("Canon EOS 5D" - Make · Model) + final String? device; + + /// 위치 (도, 남 · 서는 음수) + final double? latitude, longitude; + + const PhotoInfo({this.width, this.height, this.taken, this.device, this.latitude, this.longitude}); + + bool get hasLocation => latitude != null && longitude != null; + + /// Exif 에서 읽은 것이 있는지 (크기는 Exif 가 없어도 읽음) + bool get hasExif => taken != null || device != null || hasLocation; + + /// 위치 글 ("37.500000, 127.000000") + String get locationText => hasLocation ? '${latitude!.toStringAsFixed(6)}, ${longitude!.toStringAsFixed(6)}' : ''; +} + +/// 그림 파일의 바이트에서 사진 정보를 읽는다 (Exif 는 JPEG 만 - 다른 형식은 크기만). 읽지 못하는 것은 null +PhotoInfo readPhotoInfo(Uint8List bytes) { + int? w, h; + try { + final info = img.findDecoderForData(bytes)?.startDecode(bytes); + if (info != null && info.width > 0 && info.height > 0) { + w = info.width; + h = info.height; + } + } catch (_) {} + String? taken, device; + double? lat, lon; + try { + final e = img.decodeJpgExif(bytes); + if (e != null) { + String? s(img.IfdValue? v) { + final t = v?.toString().replaceAll('\u0000', '').trim(); + return t == null || t.isEmpty ? null : t; + } + + final when = s(e.exifIfd['DateTimeOriginal']) ?? s(e.imageIfd['DateTime']); + // "2024:05:01 12:30:00" → "2024-05-01 12:30:00" + taken = when?.replaceFirstMapped(RegExp(r'^(\d{4}):(\d{2}):(\d{2})'), (m) => '${m[1]}-${m[2]}-${m[3]}'); + final make = s(e.imageIfd['Make']), model = s(e.imageIfd['Model']); + device = model == null ? make : (make == null || model.toLowerCase().startsWith(make.toLowerCase()) ? model : '$make $model'); + // GPS: 1 위도 N/S · 2 위도 (도 · 분 · 초) · 3 경도 E/W · 4 경도 + lat = _coord(e.gpsIfd[2], s(e.gpsIfd[1])); + lon = _coord(e.gpsIfd[4], s(e.gpsIfd[3])); + } + } catch (_) {} + return PhotoInfo(width: w, height: h, taken: taken, device: device, latitude: lat, longitude: lon); +} + +double? _coord(img.IfdValue? v, String? ref) { + if (v == null || v.length < 3) return null; + final d = v.toDouble(0) + v.toDouble(1) / 60 + v.toDouble(2) / 3600; + if (d.isNaN || d.isInfinite) return null; + return ref == 'S' || ref == 'W' ? -d : d; +} diff --git a/lib/ui/reader_page.dart b/lib/ui/reader_page.dart index 16a4118..7d0d24e 100644 --- a/lib/ui/reader_page.dart +++ b/lib/ui/reader_page.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:io' show Platform; +import 'dart:io' show File, Platform; import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -9,6 +9,8 @@ import 'package:wakelock_plus/wakelock_plus.dart'; import '../app/app_controller.dart'; import '../core/reader_sources.dart'; import '../core/hwp_source.dart' show HwpPdfException, HwpSource; +import '../core/file_ops.dart' show formatSize; +import '../core/photo_info.dart'; import '../core/vfs.dart'; import '../l10n/tr.dart'; import 'ai_image_page.dart'; @@ -676,6 +678,8 @@ class _ReaderPageState extends State { one(_Act(const Icon(Icons.delete_outline), tr('이 페이지 지우기'), _deletePage)), one(_Act(const Icon(Icons.add_photo_alternate_outlined), tr('그림을 페이지로 넣기 (이 페이지 뒤)'), _insertImages)), ], + // X-plore 맞추기 (관리자 지시 10/20): 사진 정보 (Exif) - 폴더의 그림만 + if (src is ImageFilesSource) one(_Act(const Icon(Icons.info_outline), tr('사진 정보 (Exif)'), _showPhotoInfo)), // X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 켜고 끄기 (그림 원본만) if (_pictures) one(_Act(const Icon(Icons.view_carousel_outlined), s.readerGallery ? tr('아래 갤러리 줄 숨기기') : tr('아래 갤러리 줄 보이기'), @@ -837,6 +841,82 @@ class _ReaderPageState extends State { /// 쪽 번호: 통째면 "12", 반쪽이면 "12-1" · "12-2" String _label(ReaderSlot s) => s.part < 0 ? '${s.page + 1}' : '${s.page + 1}-${s.part + 1}'; + /// X-plore 맞추기 (관리자 지시 10/20): 지금 그림의 사진 정보 (찍은 날 · 기기 · 크기 · 위치). 위치는 [위치 보기] 를 누를 때만 + /// 보이고, 지도 · 인터넷으로 보내지 않는다 (링크 없음) + Future _showPhotoInfo() async { + final s = src; + if (s is! ImageFilesSource || _v.isEmpty) return; + final path = s.paths[_cur.page]; + PhotoInfo? info; + int? size; + Object? error; + try { + final local = await vLocalCopy(path, s.tempDir); + final f = File(local); + size = await f.length(); + final bytes = await f.readAsBytes(); + info = readPhotoInfo(bytes); + } catch (e) { + error = e; + } + if (!mounted) return; + var showLocation = false; + await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) { + final i = info; + Widget row(String k, Widget v) => Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SizedBox(width: 90, child: Text(k, style: const TextStyle(color: JjColors.textDim))), + Expanded(child: v), + ]), + ); + return AlertDialog( + scrollable: true, + title: Text(tr('사진 정보 (Exif)')), + content: SizedBox( + width: 420, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + row(tr('파일'), SelectableText(vBasename(path))), + if (i == null) + Text(trf('읽을 수 없습니다: {0}', [error])) + else ...[ + row(tr('크기'), Text([ + if (i.width != null) trf('{0} × {1} 픽셀', [i.width!, i.height!]), + if (size != null) formatSize(size), + ].join(' · '))), + row(tr('찍은 날'), Text(i.taken ?? '-')), + row(tr('기기'), Text(i.device ?? '-')), + row( + tr('위치'), + !i.hasLocation + ? const Text('-') + : showLocation + ? SelectableText(i.locationText) + : Align( + alignment: Alignment.centerLeft, + child: TextButton(onPressed: () => set(() => showLocation = true), child: Text(tr('위치 보기'))))), + if (i.hasLocation) + Text(tr('위치는 이 기기에서만 보이며 지도 · 인터넷으로 보내지 않습니다'), + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + if (!i.hasExif) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text(tr('이 그림에는 Exif 정보가 없습니다 (JPEG 사진만 읽음)'), + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + ], + ]), + ), + actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(tr('닫기')))], + ); + }, + ), + ); + } + Future _toggleGallery() async { await c.updateSettings((x) => x.readerGallery = !x.readerGallery); if (!mounted) return; diff --git a/test/photo_info_test.dart b/test/photo_info_test.dart new file mode 100644 index 0000000..822c96f --- /dev/null +++ b/test/photo_info_test.dart @@ -0,0 +1,87 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/core/photo_info.dart'; +import 'package:jj_mkvmaker/core/reader_sources.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/reader_page.dart'; +import 'package:path/path.dart' as p; + +/// 도 · 분 · 초 (image 패키지가 Rational 을 내보내지 않아 IfdValueRational 하나씩 만들어 잇는다) +img.IfdValueRational _dms(int d, int m, int s) => + img.IfdValueRational(d, 1)..value.addAll([...img.IfdValueRational(m, 1).value, ...img.IfdValueRational(s, 1).value]); + +/// Exif 가 든 JPEG (찍은 날 · 기기 · 위치) +Uint8List _jpeg({bool gps = true, bool south = false}) { + final im = img.Image(width: 30, height: 20); + im.exif.imageIfd['Make'] = 'Canon'; + im.exif.imageIfd['Model'] = 'Canon EOS 5D'; + im.exif.exifIfd['DateTimeOriginal'] = '2024:05:01 12:30:00'; + if (gps) { + im.exif.gpsIfd[1] = img.IfdValueAscii(south ? 'S' : 'N'); + im.exif.gpsIfd[2] = _dms(37, 30, 0); + im.exif.gpsIfd[3] = img.IfdValueAscii('E'); + im.exif.gpsIfd[4] = _dms(127, 0, 36); + } + return Uint8List.fromList(img.encodeJpg(im)); +} + +/// X-plore 맞추기 (관리자 지시 10/20): 보기 화면 사진 정보 (Exif) - 찍은 날 · 기기 · 크기 · 위치 (누를 때만, 보내지 않음) +void main() { + test('Exif 읽기: 찍은 날 (2024-05-01) · 기기 (Make 가 Model 앞에 있으면 한 번만) · 크기 · 위치 (도 · 분 · 초 → 도, 남은 음수)', () { + final i = readPhotoInfo(_jpeg()); + expect(i.width, 30); + expect(i.height, 20); + expect(i.taken, '2024-05-01 12:30:00'); + expect(i.device, 'Canon EOS 5D'); + expect(i.latitude, closeTo(37.5, 1e-6)); + expect(i.longitude, closeTo(127.01, 1e-6)); + expect(i.locationText, '37.500000, 127.010000'); + expect(i.hasExif, isTrue); + expect(readPhotoInfo(_jpeg(south: true)).latitude, closeTo(-37.5, 1e-6)); + expect(readPhotoInfo(_jpeg(gps: false)).hasLocation, isFalse); + }); + + test('Exif 가 없는 그림 (PNG) 은 크기만, 깨진 바이트는 아무것도 없이 (예외 없음)', () { + final png = readPhotoInfo(Uint8List.fromList(img.encodePng(img.Image(width: 7, height: 9)))); + expect((png.width, png.height), (7, 9)); + expect(png.hasExif, isFalse); + final bad = readPhotoInfo(Uint8List.fromList([1, 2, 3])); + expect(bad.width, isNull); + expect(bad.hasExif, isFalse); + }); + + testWidgets('보기 화면 [사진 정보 (Exif)]: 찍은 날 · 기기 · 크기, 위치는 [위치 보기] 를 눌러야 보임 (링크 없음)', (tester) async { + tester.view.physicalSize = const Size(1400, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final tmp = Directory.systemTemp.createTempSync('jj_photoinfo_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final f = File(p.join(tmp.path, 'trip.jpg'))..writeAsBytesSync(_jpeg()); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + await tester.pumpWidget(MaterialApp(home: ReaderPage(c: c, source: ImageFilesSource([f.path], tempDir: tmp.path, title: 'x')))); + await tester.pump(); + await tester.tap(find.byTooltip('사진 정보 (Exif)')); + for (var k = 0; k < 10; k++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); + await tester.pump(); + } + expect(find.text('2024-05-01 12:30:00'), findsOneWidget); + expect(find.text('Canon EOS 5D'), findsOneWidget); + expect(find.textContaining('30 × 20 픽셀'), findsOneWidget); + expect(find.textContaining('37.5'), findsNothing, reason: '위치는 누를 때만'); + await tester.tap(find.text('위치 보기')); + await tester.pump(); + expect(find.text('37.500000, 127.010000'), findsOneWidget); + expect(find.textContaining('http'), findsNothing, reason: '지도 · 인터넷 링크 없음'); + await tester.tap(find.text('닫기')); + await tester.pumpAndSettle(); + await tester.pumpWidget(const SizedBox()); + }); +}