157 · 162 끝난 작업 알림은 따로 "끝난 작업" 채널로 · [열기] 는 Android 에서 이 앱의 탐색기로

- 157: 끝 알림이 진행 알림 채널 (조용히, 낮은 중요도) 에 올라가 알림 창의 조용한 칸에 묻혔다 -
  새 채널 "끝난 작업" (보통 중요도, 소리 없음, 설정에서 따로 끌 수 있음)
- 162: ExplorerPage.openAt - 이 앱의 탐색기를 그 폴더로 (이미 열려 있으면 그 화면에서, 처음 열면 첫 창으로).
  AI 해상도 끝 알림 · 작업 현황의 [열기] 가 Android 에서 이것을 씀 (Windows 는 Windows 탐색기).
  폴더 메뉴의 "파일 관리자에서 보기" 로 다른 파일 앱도 그대로 열 수 있다
- 시험: openAt 으로 열기 · 열린 채로 다른 폴더

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음4
RyuJongJin 2026-10-09 22:42:44 +09:00
parent cc05fcad3c
commit daac19a514
5 changed files with 75 additions and 7 deletions

View File

@ -83,10 +83,20 @@ class KeepAliveService : Service() {
const val STOPPED_ID = 2 const val STOPPED_ID = 2
const val DONE_ID = 3 const val DONE_ID = 3
/// 157: 끝난 작업 알림 (예: "4장 저장: 폴더"). 누르면 작업 현황으로 const val DONE_CHANNEL_ID = "done"
/// 157: 끝난 작업 알림 (예: "4장 저장: 폴더"). 누르면 작업 현황으로.
/// 진행 알림 채널 (조용히, 낮은 중요도) 과 따로 두어 알림 창의 "조용한 알림" 칸에 묻히지 않게 (보통 중요도, 소리 없음)
fun notifyDone(context: Context, title: String, text: String) { fun notifyDone(context: Context, title: String, text: String) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 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( val open = PendingIntent.getActivity(
context, 3, context, 3,
Intent(context, MainActivity::class.java) Intent(context, MainActivity::class.java)
@ -95,7 +105,7 @@ class KeepAliveService : Service() {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
) )
val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(context, CHANNEL_ID) Notification.Builder(context, DONE_CHANNEL_ID)
} else { } else {
@Suppress("DEPRECATION") Notification.Builder(context) @Suppress("DEPRECATION") Notification.Builder(context)
} }

View File

@ -15,6 +15,7 @@ import '../l10n/tr.dart';
import '../services/image_ai.dart' show ImageAiException; import '../services/image_ai.dart' show ImageAiException;
import 'ai_image_page.dart' show aiSaveDir; import 'ai_image_page.dart' show aiSaveDir;
import 'confirm.dart'; import 'confirm.dart';
import 'explorer_page.dart' show ExplorerPage;
import 'theme.dart'; import 'theme.dart';
UpscaleOptions upscaleOptionsOf(AppController c, {String? outDir}) { UpscaleOptions upscaleOptionsOf(AppController c, {String? outDir}) {
@ -265,6 +266,15 @@ Future<String?> upscaleOne(BuildContext context, AppController c, {required Stri
} }
} }
/// 157 · 162: 저장한 폴더 열기 - Android 는 이 앱의 파일 탐색기로 (삼성 파일 앱 대신), Windows 는 탐색기로
Future<void> 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 의 모든 장을 올려 저장 (백그라운드 - 작업 알림 · 작업 현황에 남은 장 · 예상 시간) /// 121: 폴더 · ZIP 의 모든 장을 올려 저장 (백그라운드 - 작업 알림 · 작업 현황에 남은 장 · 예상 시간)
Future<void> upscaleAll(BuildContext context, AppController c, ReaderSource src) async { Future<void> upscaleAll(BuildContext context, AppController c, ReaderSource src) async {
final n = src.length; final n = src.length;
@ -294,6 +304,7 @@ Future<void> upscaleAll(BuildContext context, AppController c, ReaderSource src)
); );
if (!ok || !context.mounted) return; if (!ok || !context.mounted) return;
final messenger = ScaffoldMessenger.maybeOf(context); final messenger = ScaffoldMessenger.maybeOf(context);
final nav = Navigator.of(context);
final failed = <String>[]; final failed = <String>[];
String? lastDir; String? lastDir;
final done = await AiJobs.instance.runTask(tr('AI 해상도 올리기'), n, (i, progress) async { final done = await AiJobs.instance.runTask(tr('AI 해상도 올리기'), n, (i, progress) async {
@ -326,7 +337,7 @@ Future<void> upscaleAll(BuildContext context, AppController c, ReaderSource src)
messenger?.showSnackBar(SnackBar( messenger?.showSnackBar(SnackBar(
duration: const Duration(seconds: 10), duration: const Duration(seconds: 10),
content: Text(text), 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)),
)); ));
} }

View File

@ -73,6 +73,15 @@ class ExplorerPage extends StatefulWidget {
/// Rsync 화면으로 (이미 열려 있으면 그 화면으로) /// Rsync 화면으로 (이미 열려 있으면 그 화면으로)
static Future<void> openRsync(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: true); static Future<void> openRsync(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: true);
/// 162: 이 앱의 파일 탐색기로 그 폴더를 연다 (이미 열려 있으면 그 화면에서 그 폴더로). 끝난 작업의 [열기] 등
static Future<void> openAt(NavigatorState nav, {required AppController c, required String dir}) {
goToRequest.value = dir;
return _show(nav, c, rsync: false);
}
/// 열려 있는 (또는 곧 열릴) 탐색기가 갈 폴더 (가면 비운다)
static final goToRequest = ValueNotifier<String?>(null);
static Future<void> _show(NavigatorState nav, AppController c, {required bool rsync}) async { static Future<void> _show(NavigatorState nav, AppController c, {required bool rsync}) async {
final name = rsync ? rsyncRouteName : routeName; final name = rsync ? rsyncRouteName : routeName;
if ((rsync ? _openRsync : _open) > 0) { if ((rsync ? _openRsync : _open) > 0) {
@ -264,6 +273,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
ExplorerPage._openRsync--; ExplorerPage._openRsync--;
} else { } else {
ExplorerPage._open--; ExplorerPage._open--;
ExplorerPage.goToRequest.removeListener(_onGoToRequest);
} }
for (final pane in _panes) { for (final pane in _panes) {
pane.dispose(); pane.dispose();
@ -288,6 +298,12 @@ class _ExplorerPageState extends State<ExplorerPage> {
final wants = [ final wants = [
for (var i = 0; i < 2; i++) reach[i] ? saved[i] : _local[0].$1, 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++) { for (var i = 0; i < 2; i++) {
_panes[i] _panes[i]
..root = _volumeOf(wants[i]) ..root = _volumeOf(wants[i])
@ -297,6 +313,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
} }
if (!mounted) return; if (!mounted) return;
setState(() => _ready = true); setState(() => _ready = true);
// (이미 열린 화면에 들어온 요청은 그 화면에서)
if (!widget.rsync) ExplorerPage.goToRequest.addListener(_onGoToRequest);
// 52: 다른 화면에 다녀와도 아직 도는 복사 · 이동의 진행 막대를 다시 보이고, 끝나면 그 폴더들을 새로 읽는다 // 52: 다른 화면에 다녀와도 아직 도는 복사 · 이동의 진행 막대를 다시 보이고, 끝나면 그 폴더들을 새로 읽는다
final center = CopyCenter.peekOf(c); final center = CopyCenter.peekOf(c);
if (center != null) { if (center != null) {
@ -1594,6 +1612,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// (Rsync 화면의 ⇄ 는 둘이 함께) /// (Rsync 화면의 ⇄ 는 둘이 함께)
List<TransferJob> _jobs = const []; List<TransferJob> _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: 이 화면을 떠난 사이에 시작된 (또는 앞의 화면이 시작한) 작업을 다시 보이고, 끝나면 새로 읽고 막대를 내린다 /// 52: 이 화면을 떠난 사이에 시작된 (또는 앞의 화면이 시작한) 작업을 다시 보이고, 끝나면 새로 읽고 막대를 내린다
void _adoptJob(TransferJob job) { void _adoptJob(TransferJob job) {
if (_jobs.contains(job)) return; if (_jobs.contains(job)) return;

View File

@ -5,6 +5,7 @@ import '../app/ai_local.dart';
import '../app/download_manager.dart'; import '../app/download_manager.dart';
import '../core/models.dart'; import '../core/models.dart';
import '../services/downloader.dart'; import '../services/downloader.dart';
import 'ai_upscale_ui.dart' show openFolderInApp;
import 'app_actions.dart'; import 'app_actions.dart';
import 'downloads_page.dart'; import 'downloads_page.dart';
import 'theme.dart'; import 'theme.dart';
@ -108,7 +109,7 @@ class WorkPanel extends StatelessWidget {
child: ListView( child: ListView(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
children: [ children: [
..._jobs(), ..._jobs(context),
_title(trf('편집 목록 {0}개', [c.videos.length])), _title(trf('편집 목록 {0}개', [c.videos.length])),
if (c.videos.isEmpty) if (c.videos.isEmpty)
_Dim(tr('아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).')), _Dim(tr('아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).')),
@ -184,7 +185,7 @@ class WorkPanel extends StatelessWidget {
), ),
); );
List<Widget> _jobs() => [ List<Widget> _jobs(BuildContext context) => [
_title(tr('작업')), _title(tr('작업')),
// 123: AI 그림 (남은 장 · 예상 시간) // 123: AI 그림 (남은 장 · 예상 시간)
if (AiJobs.instance.statusLine case final ai?) ...[ if (AiJobs.instance.statusLine case final ai?) ...[
@ -196,7 +197,7 @@ class WorkPanel extends StatelessWidget {
Row(children: [ Row(children: [
Expanded(child: Text('✓ ${AiJobs.instance.lastDone}', style: const TextStyle(fontSize: 12))), Expanded(child: Text('✓ ${AiJobs.instance.lastDone}', style: const TextStyle(fontSize: 12))),
if (AiJobs.instance.lastDoneDir case final dir?) 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) if (!c.busy && !AiJobs.instance.busy)
_Dim(tr('쉬는 중')) _Dim(tr('쉬는 중'))

View File

@ -351,6 +351,27 @@ void main() {
expect(find.text('더 보기'), findsNothing); 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<NavigatorState>();
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 { testWidgets('146 · 37-2: 없는 경로를 넣으면 창을 닫지 않고 칸 아래에 "폴더가 없거나 열 수 없습니다" · 고친 경로로 간다', (tester) async {
tester.view.physicalSize = const Size(1600, 900); tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;