From daac19a514a527325fa94de8713dc28781bdbcde Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:42:44 +0900 Subject: [PATCH] =?UTF-8?q?157=20=C2=B7=20162=20=EB=81=9D=EB=82=9C=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EC=95=8C=EB=A6=BC=EC=9D=80=20=EB=94=B0?= =?UTF-8?q?=EB=A1=9C=20"=EB=81=9D=EB=82=9C=20=EC=9E=91=EC=97=85"=20?= =?UTF-8?q?=EC=B1=84=EB=84=90=EB=A1=9C=20=C2=B7=20[=EC=97=B4=EA=B8=B0]=20?= =?UTF-8?q?=EB=8A=94=20Android=20=EC=97=90=EC=84=9C=20=EC=9D=B4=20?= =?UTF-8?q?=EC=95=B1=EC=9D=98=20=ED=83=90=EC=83=89=EA=B8=B0=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 157: 끝 알림이 진행 알림 채널 (조용히, 낮은 중요도) 에 올라가 알림 창의 조용한 칸에 묻혔다 - 새 채널 "끝난 작업" (보통 중요도, 소리 없음, 설정에서 따로 끌 수 있음) - 162: ExplorerPage.openAt - 이 앱의 탐색기를 그 폴더로 (이미 열려 있으면 그 화면에서, 처음 열면 첫 창으로). AI 해상도 끝 알림 · 작업 현황의 [열기] 가 Android 에서 이것을 씀 (Windows 는 Windows 탐색기). 폴더 메뉴의 "파일 관리자에서 보기" 로 다른 파일 앱도 그대로 열 수 있다 - 시험: openAt 으로 열기 · 열린 채로 다른 폴더 Co-Authored-By: Claude Opus 5.5 --- .../com/jj/jj_mkvmaker/KeepAliveService.kt | 16 +++++++++--- lib/ui/ai_upscale_ui.dart | 13 +++++++++- lib/ui/explorer_page.dart | 25 +++++++++++++++++++ lib/ui/work_panel.dart | 7 +++--- test/explorer_conflict_test.dart | 21 ++++++++++++++++ 5 files changed, 75 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/KeepAliveService.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/KeepAliveService.kt index 499498a..97ab858 100644 --- a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/KeepAliveService.kt +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/KeepAliveService.kt @@ -83,10 +83,20 @@ class KeepAliveService : Service() { const val STOPPED_ID = 2 const val DONE_ID = 3 - /// 157: 끝난 작업 알림 (예: "4장 저장: 폴더"). 누르면 작업 현황으로 + const val DONE_CHANNEL_ID = "done" + + /// 157: 끝난 작업 알림 (예: "4장 저장: 폴더"). 누르면 작업 현황으로. + /// 진행 알림 채널 (조용히, 낮은 중요도) 과 따로 두어 알림 창의 "조용한 알림" 칸에 묻히지 않게 (보통 중요도, 소리 없음) fun notifyDone(context: Context, title: String, text: String) { val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notification(context, "", -1, "") // 알림 채널을 만들어 둔다 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(DONE_CHANNEL_ID) == null) { + nm.createNotificationChannel( + NotificationChannel(DONE_CHANNEL_ID, "끝난 작업", NotificationManager.IMPORTANCE_DEFAULT).apply { + description = "AI 해상도 올리기 등 오래 걸린 작업이 끝났을 때" + setSound(null, null) + } + ) + } val open = PendingIntent.getActivity( context, 3, Intent(context, MainActivity::class.java) @@ -95,7 +105,7 @@ class KeepAliveService : Service() { PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Notification.Builder(context, CHANNEL_ID) + Notification.Builder(context, DONE_CHANNEL_ID) } else { @Suppress("DEPRECATION") Notification.Builder(context) } diff --git a/lib/ui/ai_upscale_ui.dart b/lib/ui/ai_upscale_ui.dart index f4a2c58..f29a126 100644 --- a/lib/ui/ai_upscale_ui.dart +++ b/lib/ui/ai_upscale_ui.dart @@ -15,6 +15,7 @@ import '../l10n/tr.dart'; import '../services/image_ai.dart' show ImageAiException; import 'ai_image_page.dart' show aiSaveDir; import 'confirm.dart'; +import 'explorer_page.dart' show ExplorerPage; import 'theme.dart'; UpscaleOptions upscaleOptionsOf(AppController c, {String? outDir}) { @@ -265,6 +266,15 @@ Future upscaleOne(BuildContext context, AppController c, {required Stri } } +/// 157 · 162: 저장한 폴더 열기 - Android 는 이 앱의 파일 탐색기로 (삼성 파일 앱 대신), Windows 는 탐색기로 +Future openFolderInApp(NavigatorState nav, AppController c, String dir) async { + if (Platform.isAndroid) { + await ExplorerPage.openAt(nav, c: c, dir: dir); + } else { + await c.services.shell.revealFile(dir); + } +} + /// 121: 폴더 · ZIP 의 모든 장을 올려 저장 (백그라운드 - 작업 알림 · 작업 현황에 남은 장 · 예상 시간) Future upscaleAll(BuildContext context, AppController c, ReaderSource src) async { final n = src.length; @@ -294,6 +304,7 @@ Future upscaleAll(BuildContext context, AppController c, ReaderSource src) ); if (!ok || !context.mounted) return; final messenger = ScaffoldMessenger.maybeOf(context); + final nav = Navigator.of(context); final failed = []; String? lastDir; final done = await AiJobs.instance.runTask(tr('AI 해상도 올리기'), n, (i, progress) async { @@ -326,7 +337,7 @@ Future upscaleAll(BuildContext context, AppController c, ReaderSource src) messenger?.showSnackBar(SnackBar( duration: const Duration(seconds: 10), content: Text(text), - action: dir == null ? null : SnackBarAction(label: tr('열기'), onPressed: () => c.services.shell.revealFile(dir)), + action: dir == null ? null : SnackBarAction(label: tr('열기'), onPressed: () => openFolderInApp(nav, c, dir)), )); } diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 8cc4406..f9427c6 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -73,6 +73,15 @@ class ExplorerPage extends StatefulWidget { /// Rsync 화면으로 (이미 열려 있으면 그 화면으로) static Future openRsync(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: true); + /// 162: 이 앱의 파일 탐색기로 그 폴더를 연다 (이미 열려 있으면 그 화면에서 그 폴더로). 끝난 작업의 [열기] 등 + static Future openAt(NavigatorState nav, {required AppController c, required String dir}) { + goToRequest.value = dir; + return _show(nav, c, rsync: false); + } + + /// 열려 있는 (또는 곧 열릴) 탐색기가 갈 폴더 (가면 비운다) + static final goToRequest = ValueNotifier(null); + static Future _show(NavigatorState nav, AppController c, {required bool rsync}) async { final name = rsync ? rsyncRouteName : routeName; if ((rsync ? _openRsync : _open) > 0) { @@ -264,6 +273,7 @@ class _ExplorerPageState extends State { ExplorerPage._openRsync--; } else { ExplorerPage._open--; + ExplorerPage.goToRequest.removeListener(_onGoToRequest); } for (final pane in _panes) { pane.dispose(); @@ -288,6 +298,12 @@ class _ExplorerPageState extends State { final wants = [ for (var i = 0; i < 2; i++) reach[i] ? saved[i] : _local[0].$1, ]; + // 162: "이 폴더를 탐색기로" 로 열렸으면 첫 창은 그 폴더 (지난 폴더 대신) + final asked = widget.rsync ? null : ExplorerPage.goToRequest.value; + if (asked != null) { + ExplorerPage.goToRequest.value = null; + wants[0] = asked; + } for (var i = 0; i < 2; i++) { _panes[i] ..root = _volumeOf(wants[i]) @@ -297,6 +313,8 @@ class _ExplorerPageState extends State { } if (!mounted) return; setState(() => _ready = true); + // (이미 열린 화면에 들어온 요청은 그 화면에서) + if (!widget.rsync) ExplorerPage.goToRequest.addListener(_onGoToRequest); // 52: 다른 화면에 다녀와도 아직 도는 복사 · 이동의 진행 막대를 다시 보이고, 끝나면 그 폴더들을 새로 읽는다 final center = CopyCenter.peekOf(c); if (center != null) { @@ -1594,6 +1612,13 @@ class _ExplorerPageState extends State { /// (Rsync 화면의 ⇄ 는 둘이 함께) List _jobs = const []; + void _onGoToRequest() { + final dir = ExplorerPage.goToRequest.value; + if (dir == null || !_ready || !mounted) return; + ExplorerPage.goToRequest.value = null; + unawaited(_goTo(_pane, dir, fresh: true).then((_) => _reveal(_pane, dir))); + } + /// 52: 이 화면을 떠난 사이에 시작된 (또는 앞의 화면이 시작한) 작업을 다시 보이고, 끝나면 새로 읽고 막대를 내린다 void _adoptJob(TransferJob job) { if (_jobs.contains(job)) return; diff --git a/lib/ui/work_panel.dart b/lib/ui/work_panel.dart index bab6910..b4991f6 100644 --- a/lib/ui/work_panel.dart +++ b/lib/ui/work_panel.dart @@ -5,6 +5,7 @@ import '../app/ai_local.dart'; import '../app/download_manager.dart'; import '../core/models.dart'; import '../services/downloader.dart'; +import 'ai_upscale_ui.dart' show openFolderInApp; import 'app_actions.dart'; import 'downloads_page.dart'; import 'theme.dart'; @@ -108,7 +109,7 @@ class WorkPanel extends StatelessWidget { child: ListView( padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), children: [ - ..._jobs(), + ..._jobs(context), _title(trf('편집 목록 {0}개', [c.videos.length])), if (c.videos.isEmpty) _Dim(tr('아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).')), @@ -184,7 +185,7 @@ class WorkPanel extends StatelessWidget { ), ); - List _jobs() => [ + List _jobs(BuildContext context) => [ _title(tr('작업')), // 123: AI 그림 (남은 장 · 예상 시간) if (AiJobs.instance.statusLine case final ai?) ...[ @@ -196,7 +197,7 @@ class WorkPanel extends StatelessWidget { Row(children: [ Expanded(child: Text('✓ ${AiJobs.instance.lastDone}', style: const TextStyle(fontSize: 12))), if (AiJobs.instance.lastDoneDir case final dir?) - TextButton(onPressed: () => c.services.shell.revealFile(dir), child: Text(tr('열기'))), + TextButton(onPressed: () => openFolderInApp(Navigator.of(context), c, dir), child: Text(tr('열기'))), ]), if (!c.busy && !AiJobs.instance.busy) _Dim(tr('쉬는 중')) diff --git a/test/explorer_conflict_test.dart b/test/explorer_conflict_test.dart index c7f3db1..4a18c8a 100644 --- a/test/explorer_conflict_test.dart +++ b/test/explorer_conflict_test.dart @@ -351,6 +351,27 @@ void main() { expect(find.text('더 보기'), findsNothing); }); + testWidgets('157 · 162: 끝난 작업의 [열기] - 이 앱의 탐색기로 그 폴더를 연다 (이미 열려 있으면 그 화면에서 그 폴더로)', (tester) async { + final out = Directory(p.join(tmp.path, 'out_AI'))..createSync(); + File(p.join(out.path, 'p1_x2.png')).writeAsStringSync('x'); + tester.view.physicalSize = const Size(1600, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final navKey = GlobalKey(); + await tester.runAsync(() => tester.pumpWidget(MaterialApp(navigatorKey: navKey, home: const Text('앞 화면')))); + unawaited(ExplorerPage.openAt(navKey.currentState!, c: c, dir: out.path)); + // 제목 (지금 경로) 이 그 폴더 - 목록은 저장 장치 맨 위부터라 그 파일 줄이 화면 밖일 수 있다 + await settle(tester, () => find.text(out.path).evaluate().isNotEmpty); + expect(find.text(out.path), findsOneWidget); + expect(ExplorerPage.goToRequest.value, isNull, reason: '간 뒤에는 비움'); + // 이미 열린 채로 다른 폴더를 요청하면 새 화면을 쌓지 않고 그 화면에서 + // (실제 비동기 구역에서: 그때 시작한 폴더 읽기가 시험의 가짜 시간에 묶이지 않게) + await tester.runAsync(() async => unawaited(ExplorerPage.openAt(navKey.currentState!, c: c, dir: right))); + await settle(tester, () => find.text(right).evaluate().isNotEmpty); + expect(find.byType(ExplorerPage), findsOneWidget); + expect(find.text(right), findsOneWidget); + }); + testWidgets('146 · 37-2: 없는 경로를 넣으면 창을 닫지 않고 칸 아래에 "폴더가 없거나 열 수 없습니다" · 고친 경로로 간다', (tester) async { tester.view.physicalSize = const Size(1600, 900); tester.view.devicePixelRatio = 1;