다운로드 알림: 준비가 끝나면 닫힘, 화면을 오간 뒤에도 '목록 보기' 동작 (다운로드 목록은 열린 화면으로 돌아가기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
RyuJongJin 2026-10-04 13:02:50 +09:00
parent d89bd8a245
commit 14b2489938
7 changed files with 150 additions and 21 deletions

View File

@ -177,8 +177,10 @@ Future<void> main(List<String> args) async {
hotkey: controller.settings.showHotkey,
minimizeToTray: () => controller.settings.minimizeToTray,
onCloseRequested: confirmQuit,
onDownloadsRequested: () => navigatorKey.currentState
?.push(MaterialPageRoute<void>(builder: (_) => DownloadsPage(d: downloads))),
onDownloadsRequested: () {
final nav = navigatorKey.currentState;
if (nav != null) unawaited(DownloadsPage.open(nav, downloads));
},
);
// 제목 표시줄에 버전 · 지난번 창 위치에서 열기 · 동영상 목록 불러오기 (창끼리 공유)
await windowManager.setTitle(appTitle);

View File

@ -109,7 +109,7 @@ class AppNavButtons extends StatelessWidget {
onDownloadsPage ? '다운로드 목록 (지금 여기)' : '다운로드 목록',
downloads == null || onDownloadsPage
? null
: () => Navigator.push(context, MaterialPageRoute<void>(builder: (_) => DownloadsPage(d: downloads))),
: () => DownloadsPage.open(Navigator.of(context), downloads),
here: onDownloadsPage,
),
]),

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
@ -14,6 +15,7 @@ import '../core/download_detect.dart' show CookieRecord, toNetscapeCookies;
import '../core/web_address.dart';
import '../platform/windows/cef_runtime.dart';
import '../platform/windows/com_guard.dart';
import '../services/downloader.dart' show DownloadState;
import 'app_actions.dart';
import 'bookmark_ui.dart';
import 'downloads_page.dart';
@ -199,18 +201,34 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
await _nav?.exportCookies();
} catch (_) {}
if (!mounted) return;
final t = widget.downloads!.addPage(_url);
final d = widget.downloads!;
final t = d.addPage(_url);
final m = ScaffoldMessenger.of(context);
m.showSnackBar(SnackBar(
content: Text(t == null ? '이미 받는 중이거나 받을 수 없는 주소입니다.' : '다운로드 추가: ${_title.isEmpty ? _url : _title}'),
action: t == null
? null
: SnackBarAction(
label: '목록 보기',
onPressed: () => Navigator.push(
context, MaterialPageRoute<void>(builder: (_) => DownloadsPage(d: widget.downloads!))),
),
if (t == null) {
m.showSnackBar(const SnackBar(content: Text('이미 받는 중이거나 받을 수 없는 주소입니다.')));
return;
}
// 알림은 앱 전체에 떠 있으므로 이 브라우저 화면이 아니라 앱의 Navigator 로 연다
// (화면을 오가 이 브라우저 화면이 닫혀도 "목록 보기" 가 된다)
final nav = Navigator.of(context);
final bar = m.showSnackBar(SnackBar(
content: Text('다운로드 추가: ${_title.isEmpty ? _url : _title}'),
duration: const Duration(minutes: 10),
action: SnackBarAction(label: '목록 보기', onPressed: () => DownloadsPage.open(nav, d)),
));
// 받기 준비가 끝나면 (진행률이 나오거나 · 끝 · 실패 · 취소 · 목록에서 지움) 알림을 닫는다
void check() {
final ready = !d.tasks.contains(t) ||
t.progress != null ||
(t.state != DownloadState.queued && t.state != DownloadState.downloading);
if (ready) {
d.removeListener(check);
bar.close();
}
}
d.addListener(check);
unawaited(bar.closed.then((_) => d.removeListener(check)));
}
void _openExternal(String url) =>

View File

@ -15,13 +15,43 @@ class DownloadsPage extends StatefulWidget {
@override
State<DownloadsPage> createState() => _DownloadsPageState();
/// 다운로드 목록 화면의 경로 이름 (이미 열려 있으면 그 화면으로 돌아가기 위해)
static const routeName = 'downloads';
/// 열려 있는 다운로드 목록 화면 수
static int _open = 0;
/// 다운로드 목록으로: 이미 열려 있으면 그 화면으로 돌아가고, 없으면 새로 연다.
/// 위쪽 [다운로드 목록] 버튼 · 브라우저의 "목록 보기" · 작업 현황 등 어디서 열어도 같다.
static Future<void> open(NavigatorState nav, DownloadManager d) async {
if (_open > 0) {
var found = false;
nav.popUntil((r) {
if (r.settings.name == routeName) found = true;
return found || r.isFirst;
});
if (found) return;
}
await nav.push(MaterialPageRoute<void>(
settings: const RouteSettings(name: routeName),
builder: (_) => DownloadsPage(d: d),
));
}
}
class _DownloadsPageState extends State<DownloadsPage> {
final _url = TextEditingController();
@override
void initState() {
super.initState();
DownloadsPage._open++;
}
@override
void dispose() {
DownloadsPage._open--;
_url.dispose();
super.dispose();
}

View File

@ -288,8 +288,7 @@ class _DownloadBox extends StatelessWidget {
final p = d.overallProgress;
return InkWell(
borderRadius: BorderRadius.circular(6),
onTap: () => Navigator.push(
context, MaterialPageRoute<void>(builder: (_) => DownloadsPage(d: d))),
onTap: () => DownloadsPage.open(Navigator.of(context), d),
child: Container(
width: 150,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),

View File

@ -48,12 +48,7 @@ class WorkPanel extends StatelessWidget {
_title(
'다운로드 ${downloads!.tasks.length}개',
action: TextButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute<void>(
builder: (_) => DownloadsPage(d: downloads!),
),
),
onPressed: () => DownloadsPage.open(Navigator.of(context), downloads!),
child: const Text(
'전체 목록',
style: TextStyle(fontSize: 12),

View File

@ -14,6 +14,7 @@ import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/downloader.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/browser_page.dart';
import 'package:jj_mkvmaker/ui/downloads_page.dart';
import 'package:path/path.dart' as p;
class _Backend implements DownloadBackend {
@ -182,6 +183,90 @@ void main() {
});
});
testWidgets('다운로드 알림: 브라우저 화면이 닫혀도 "목록 보기" 가 되고, 준비가 끝나면 사라진다', (tester) async {
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final dir = Directory.systemTemp.createTempSync('jj_dlbar_');
addTearDown(() => dir.deleteSync(recursive: true));
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final bm = BookmarksController(p.join(dir.path, 'bookmarks.json'));
final d = DownloadManager(
backends: [_Backend(DownloadKind.video)], settings: () => c.settings, readClipboard: () async => null);
final navKey = GlobalKey<NavigatorState>();
late BrowserHost host;
Future<void> settle() async {
for (var i = 0; i < 10; i++) {
await tester.pump(const Duration(milliseconds: 100));
}
}
await tester.pumpWidget(MaterialApp(navigatorKey: navKey, home: const Scaffold(body: Text('MKV 화면'))));
navKey.currentState!.push(MaterialPageRoute<void>(
builder: (_) => BrowserPage(
c: c,
bookmarks: bm,
downloads: d,
initialUrl: 'https://www.youtube.com/watch?v=abc',
viewBuilder: (h, url) {
host = h;
h.attach(_Nav());
return const ColoredBox(color: Colors.white);
},
),
));
await settle();
host.onUrl('https://www.youtube.com/watch?v=abc');
await tester.pump();
await tester.tap(find.text('다운로드'));
await settle();
expect(find.text('목록 보기'), findsOneWidget);
final t = d.tasks.single;
// MKV 화면으로 (브라우저 화면이 닫힘) → 알림의 "목록 보기" 를 눌러도 다운로드 목록이 열린다
navKey.currentState!.popUntil((r) => r.isFirst);
await settle();
expect(find.byType(BrowserPage), findsNothing);
await tester.tap(find.text('목록 보기'));
await settle();
expect(find.byType(DownloadsPage), findsOneWidget);
expect(find.text('목록 보기'), findsNothing); // 누르면 알림은 닫힘
// 다시 열어도 (위쪽 버튼 등) 목록 화면이 겹쳐 쌓이지 않는다
await DownloadsPage.open(navKey.currentState!, d);
await settle();
expect(find.byType(DownloadsPage, skipOffstage: false), findsOneWidget);
expect(t.state, DownloadState.downloading);
// 다른 동영상: 받기 준비가 끝나면 (진행률이 나오면) 알림이 저절로 사라진다
navKey.currentState!.popUntil((r) => r.isFirst);
navKey.currentState!.push(MaterialPageRoute<void>(
builder: (_) => BrowserPage(
c: c,
bookmarks: bm,
downloads: d,
initialUrl: 'https://www.youtube.com/watch?v=def',
viewBuilder: (h, url) {
host = h;
h.attach(_Nav());
return const ColoredBox(color: Colors.white);
},
),
));
await settle();
host.onUrl('https://www.youtube.com/watch?v=def');
await tester.pump();
await tester.tap(find.text('다운로드'));
await settle();
expect(find.text('목록 보기'), findsOneWidget);
await tester.pump(const Duration(seconds: 30));
expect(find.text('목록 보기'), findsOneWidget, reason: '준비 중에는 그대로');
d.tasks.first.progress = 0.1;
d.refresh();
await settle();
expect(find.text('목록 보기'), findsNothing);
d.dispose();
});
testWidgets('브라우저: ☆ 추가 · 표시줄 · 다운로드 버튼 · 관리 패널 (삭제 · 실행 취소)', (tester) async {
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;