import 'dart:io'; import 'dart:math' as math; import 'package:flutter/material.dart'; import '../app/app_controller.dart'; import '../app/net_guard.dart'; import '../core/app_update.dart'; import '../services/updater.dart' show Updater; import 'theme.dart'; import 'update/update_install.dart'; import '../l10n/tr.dart'; // 약속 (업데이트 나누기): 이 파일은 새 버전 확인 ([checkForUpdate] · [UpdateCheckButton]) 과 버전 고르기를 맡는다. // 버전 고르기 ([chooseVersion] · [VersionPickerDialog]) 도 여기, 설치 · 되돌리기 · 공용 단계는 update/update_install.dart (부르는 곳이 그대로 쓰게 아래에서 내보냄) export 'update/update_install.dart' show gbText, installRelease, rollbackModelRoots, withProgressDialog; /// 새 버전 확인 → 알림 → 받기 · 검증 → 종료 후 설치 · 다시 시작 (하루 한 번 자동 확인 · 최신 버전만) /// /// [manual]: "지금 확인" (최신이어도 알려 주고, 건너뛴 버전도 보여 줌) /// [onResult]: 164 - 직접 확인할 때 결과를 알림 대신 버튼 옆에 (최신 · 새 버전 · 확인 못 함) Future checkForUpdate(BuildContext context, AppController c, {bool manual = false, void Function(String result)? onResult}) async { final up = c.services.updater; if (up == null) return; final s = c.settings; final String current; try { current = await up.currentVersion(); } catch (_) { return; } if (!manual && (!s.run.autoCheckUpdates || !updateCheckDue(s.run.lastUpdateCheck, DateTime.now(), checkedBy: s.run.lastUpdateCheckVersion, current: current))) { return; } void snack(String t) { if (onResult != null) return onResult(t); if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t))); } final ReleaseInfo? r; try { r = await up.latest(); } catch (e) { c.note(trf('새 버전을 확인할 수 없습니다: {0}', [e])); // 247: DNS 강제 프록시에서 실패했으면 까닭을 사용자 글로 (원문은 위 작업 기록에) if (manual) { snack(NetGuard.instance.explain(e, tr('새 버전을 확인하지 못했습니다')) ?? trf('새 버전을 확인할 수 없습니다: {0}', [e])); } return; } await c.updateSettings((x) => x ..run.lastUpdateCheck = DateTime.now().toIso8601String() ..run.lastUpdateCheckVersion = current); // 작업 기록에 남긴다 (업데이트가 안 될 때 원인을 찾을 수 있게) c.note(trf('새 버전 확인: 지금 v{0} · 최신 v{1}', [current, r?.version ?? '-'])); if (r == null || !r.isNewerThan(current)) { if (manual) snack(trf('최신 버전입니다 (v{0})', [current])); return; } if (!manual && s.run.skippedVersion == r.version) return; onResult?.call(trf('새 버전 v{0} 이 있습니다', [r.version])); if (!context.mounted) return; final choice = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(trf('새 버전 v{0}', [r!.version])), content: SizedBox( width: 560, height: 380, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(trf('지금 쓰는 버전: v{0} → 새 버전: v{1}', [current, r.version])), if (r.zipSize > 0) Text(trf('받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다', [(r.zipSize / 1e6).round()]), style: TextStyle(fontSize: 12, color: JjColors.textDim)), const SizedBox(height: 12), Text(tr('바뀐 내용'), style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 4), Expanded(child: UpdateNotes(r.notes)), ]), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, 'skip'), child: Text(tr('이 버전 건너뛰기'))), TextButton(onPressed: () => Navigator.pop(ctx, 'versions'), child: Text(tr('다른 버전 고르기'))), TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('나중에'))), FilledButton.icon( onPressed: () => Navigator.pop(ctx, 'update'), icon: const Icon(Icons.system_update_alt, size: 18), label: Text(tr('업데이트')), ), ], ), ); if (!context.mounted) return; switch (choice) { case 'skip': await c.updateSettings((x) => x.run.skippedVersion = r!.version); case 'versions': await chooseVersion(context, c); case 'update': await installRelease(context, c, r, current); } } /// 164: [최신 버전 확인] 과 그 결과 (버튼 옆에 - 최신입니다 · 새 버전이 있습니다 · 확인할 수 없습니다) class UpdateCheckButton extends StatefulWidget { final AppController c; const UpdateCheckButton({super.key, required this.c}); @override State createState() => _UpdateCheckButtonState(); } class _UpdateCheckButtonState extends State { bool _busy = false; String? _result; Future _check() async { setState(() { _busy = true; _result = null; }); try { await checkForUpdate(context, widget.c, manual: true, onResult: (r) { if (mounted) setState(() => _result = r); }); } finally { if (mounted) setState(() => _busy = false); } } @override Widget build(BuildContext context) => Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ OutlinedButton(onPressed: _busy ? null : _check, child: Text(tr('최신 버전 확인'))), if (_busy) const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)), if (_result != null) Text(_result!, style: TextStyle(fontSize: 12, color: JjColors.textDim)), ]); } /// 약속 (업데이트 나누기): 버전 고르기. /// - [chooseVersion]: 올려 둔 모든 버전을 보여 주고 고른 것을 [installRelease] 로 (닫으면 아무것도 하지 않음) /// - [initialVersionPick]: 처음 고를 버전 (순수 함수) /// - [VersionPickerDialog]: 고르기 창 → 고른 [ReleaseInfo], 닫으면 null. 목록을 읽지 못하면 창 안에 그 글 /// - [UpdateNotes] · [UpdateBadge]: 바뀐 내용 칸 · 작은 표 (지금 · 최신 · 시험판) /// 환경 설정 > [업데이트]: GitHub 에 올려 둔 모든 버전을 보여 주고 골라 설치한다. /// 최신으로 올리기뿐 아니라 예전 (정상이던) 버전으로 되돌리기 · 같은 버전 다시 설치도 된다. Future chooseVersion(BuildContext context, AppController c) async { final up = c.services.updater; if (up == null) return; final String current; try { current = await up.currentVersion(); } catch (_) { return; } if (!context.mounted) return; final picked = await showDialog( context: context, builder: (_) => VersionPickerDialog(up: up, current: current), ); if (picked == null || !context.mounted) return; await installRelease(context, c, picked, current); } /// 169: 버전 고르기 창에서 처음 고를 버전: 이 기기용 파일이 있는 것 중 가장 새것, 없으면 지금 버전 (목록은 새것이 앞) ReleaseInfo initialVersionPick(List all, String current) => all.firstWhere((r) => r.zipUrl != null, orElse: () => all.firstWhere((r) => r.version == current, orElse: () => all.first)); /// 버전 고르기 창 class VersionPickerDialog extends StatefulWidget { final Updater up; final String current; const VersionPickerDialog({super.key, required this.up, required this.current}); @override State createState() => _VersionPickerDialogState(); } class _VersionPickerDialogState extends State { late final Future> _list = widget.up.releases(); ReleaseInfo? _sel; @override void initState() { super.initState(); // 169: 처음엔 최신 버전 (이 기기용 파일이 있는 것 중 가장 새것) 을 골라 둔다 - 이 창을 여는 사람은 대개 업데이트하려는 것. // 이 기기용 파일이 하나도 없으면 지금 쓰는 버전. (목록을 다 읽은 뒤 버튼 글도 맞게) _list.then((all) { if (!mounted || _sel != null || all.isEmpty) return; setState(() => _sel = initialVersionPick(all, widget.current)); }, onError: (_) {}); } String _date(DateTime? d) { if (d == null) return ''; final l = d.toLocal(); String two(int x) => x.toString().padLeft(2, '0'); return '${l.year}-${two(l.month)}-${two(l.day)} ${two(l.hour)}:${two(l.minute)}'; } /// 고른 버전으로 무엇을 하게 되는지 (버튼 글) String _action(ReleaseInfo r) { final d = compareVersions(r.version, widget.current); return d > 0 ? tr('이 버전으로 업데이트') : d < 0 ? tr('이 버전으로 되돌리기') : tr('다시 설치'); } @override Widget build(BuildContext context) => AlertDialog( title: Text(tr('버전 고르기')), content: SizedBox( width: 720, height: 480, child: FutureBuilder>( future: _list, builder: (context, snap) { if (snap.hasError) return Center(child: Text(trf('버전 목록을 읽을 수 없습니다: {0}', [snap.error]))); if (!snap.hasData) return const Center(child: CircularProgressIndicator()); final all = snap.data!; if (all.isEmpty) return Center(child: Text(tr('올려 둔 버전이 없습니다.'))); final newest = all.first.version; final list = ListView(children: [ Padding( padding: const EdgeInsets.fromLTRB(4, 0, 4, 8), child: Text(trf('지금 쓰는 버전: v{0}', [widget.current]), style: TextStyle(fontSize: 12, color: JjColors.textDim)), ), for (final r in all) ListTile( dense: true, selected: _sel?.version == r.version, selectedTileColor: JjColors.accent.withValues(alpha: 0.15), title: Row(children: [ Text('v${r.version}', style: const TextStyle(fontWeight: FontWeight.w600)), if (r.version == widget.current) UpdateBadge(tr('지금'), JjColors.accent), if (r.version == newest) UpdateBadge(tr('최신'), JjColors.positive), if (r.prerelease) UpdateBadge(tr('시험판'), JjColors.warning), ]), subtitle: Text(r.zipUrl == null ? '${_date(r.published)} · ${tr('이 기기용 파일 없음')}' : _date(r.published), style: const TextStyle(fontSize: 11)), enabled: r.zipUrl != null, onTap: () => setState(() => _sel = r), ), ]); final detail = _sel == null ? const SizedBox() : Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('v${_sel!.version}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), if (compareVersions(_sel!.version, widget.current) < 0) Padding( padding: const EdgeInsets.only(top: 6), child: Text( [ tr('예전 버전으로 되돌립니다. 지금 버전의 설정 (동영상 목록 · 즐겨찾기 포함) 은 보관해 두었다가, ' '나중에 이 버전으로 다시 오면 되살릴 수 있습니다.'), // P0: Android 는 앱을 지운 뒤 설치한다 - 무엇이 사라지는지는 다음 창에서 if (Platform.isAndroid) tr('Android 에서는 앱을 지운 뒤 예전 버전을 설치합니다. 받은 AI 모델 · 비밀번호 · 브라우저 로그인 등은 ' '사라집니다 (남는 것과 사라지는 것은 다음 창에서 보여 줍니다).'), ].join('\n'), style: TextStyle(fontSize: 12, color: JjColors.warning)), ), const SizedBox(height: 8), Text(tr('바뀐 내용'), style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 4), Expanded(child: UpdateNotes(_sel!.notes)), ]); // 168: 좁은 화면 (폰) 에서는 목록 위 · 고른 버전의 바뀐 내용 아래 (나란히 두면 바뀐 내용 칸이 너무 좁았다) return LayoutBuilder( builder: (context, box) => box.maxWidth < 560 ? Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(height: math.min(200, box.maxHeight * 0.4), child: list), const Divider(height: 16), Expanded(child: detail), ]) : Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(width: 300, child: list), const VerticalDivider(width: 16), Expanded(child: detail), ]), ); }, ), ), actions: [ TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('닫기'))), FilledButton.icon( onPressed: _sel == null || _sel!.zipUrl == null ? null : () => Navigator.pop(context, _sel), icon: const Icon(Icons.system_update_alt, size: 18), label: Text(_sel == null ? tr('업데이트') : _action(_sel!)), ), ], ); } class UpdateBadge extends StatelessWidget { final String text; final Color color; const UpdateBadge(this.text, this.color, {super.key}); @override Widget build(BuildContext context) => Container( margin: const EdgeInsets.only(left: 6), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration(border: Border.all(color: color), borderRadius: BorderRadius.circular(8)), child: Text(text, style: TextStyle(fontSize: 10, color: color)), ); } class UpdateNotes extends StatelessWidget { final String notes; const UpdateNotes(this.notes, {super.key}); @override Widget build(BuildContext context) => Container( width: double.infinity, padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: JjColors.bg, borderRadius: BorderRadius.circular(6)), child: SingleChildScrollView( child: SelectableText(notes.isEmpty ? tr('(설명 없음)') : notes, style: const TextStyle(fontSize: 12, height: 1.5)), ), ); }