WIP X-plore 맞추기 (관리자 지시 10/20): 보기 화면 사진 정보 (Exif) - 위 막대 [사진 정보 (Exif)] (폴더의 그림만): 파일 · 크기 (픽셀 · 바이트) · 찍은 날 · 기기 · 위치 - 위치는 [위치 보기] 를 눌러야 보이고 지도 · 인터넷으로 보내지 않음 (링크 없음) · Exif 없으면 안내 · image 패키지 (MIT, 이미 씀) 로 읽어 새 의존 없음 · 시험 (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xplore_view
RyuJongJin 2026-10-20 21:35:34 +09:00
parent ed24463192
commit 1f0c094656
6 changed files with 259 additions and 1 deletions

View File

@ -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)",

View File

@ -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 のように自己署名証明書なら、[サーバー設定を修正] で「証明書を検証しない」をオンにしてください。",
"짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "短いほど早く同期しますが、バッテリーをより使います。",
"쪽 범위가 맞지 않습니다": "ページ範囲が正しくありません",
"찍은 날": "撮影日",
"차례 기다림": "順番待ち",
"창": "ペイン",
"창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "ウィンドウ・タスクバー・トレイとアプリ内左上のボタンのアイコンです(実行ファイル・デスクトップのショートカットのアイコンはそのまま)",

View File

@ -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),请在 [修改服务器设置] 中开启“不验证证书”。",
"짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "间隔越短同步越快,但更耗电。",
"쪽 범위가 맞지 않습니다": "页面范围无效",
"찍은 날": "拍摄日期",
"차례 기다림": "排队等待",
"창": "窗格",
"창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "窗口、任务栏、托盘以及应用内左上角按钮的图标(程序文件和桌面快捷方式的图标不变)",

70
lib/core/photo_info.dart Normal file
View File

@ -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;
}

View File

@ -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<ReaderPage> {
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<ReaderPage> {
/// 쪽 번호: 통째면 "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<void> _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<void>(
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<void> _toggleGallery() async {
await c.updateSettings((x) => x.readerGallery = !x.readerGallery);
if (!mounted) return;

87
test/photo_info_test.dart Normal file
View File

@ -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<void>.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());
});
}