import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import '../core/app_manager.dart'; import '../core/file_ops.dart' show formatSize; import '../core/vfs.dart' show vDisplay; import '../l10n/tr.dart'; import 'theme.dart'; import 'jj_title_bar.dart'; /// X-plore 비교 5-2 앱 관리자 (사용자 결정 10/21, Android): 실행할 수 있는 앱 목록 - 이름 · 판 · 크기 · 설치일, /// [APK 백업] (Download/JJ_MKVMaker/apk) · [앱 정보] (시스템 화면) · [지우기] (시스템 지우기 창) Future openAppManager(BuildContext context, {AppManagerBackend? backend}) => Navigator.of(context) .push(MaterialPageRoute(builder: (_) => AppManagerPage(backend: backend ?? AndroidAppManager()))); class AppManagerPage extends StatefulWidget { final AppManagerBackend backend; const AppManagerPage({super.key, required this.backend}); @override State createState() => _AppManagerPageState(); } class _AppManagerPageState extends State { List? _apps; String? _error; String _sort = 'name'; String _filter = ''; final _icons = >{}; @override void initState() { super.initState(); unawaited(_load()); } Future _load() async { try { final l = await widget.backend.list(); if (mounted) setState(() => _apps = l); } catch (e) { if (mounted) setState(() => _error = trf('앱 목록을 읽지 못했습니다: {0}', [e])); } } List get _shown { final f = _filter.toLowerCase(); final l = [for (final a in _apps ?? const []) if (f.isEmpty || a.label.toLowerCase().contains(f) || a.package.contains(f)) a]; switch (_sort) { case 'size': l.sort((a, b) => b.size.compareTo(a.size)); case 'installed': l.sort((a, b) => b.installed.compareTo(a.installed)); default: l.sort((a, b) => a.label.toLowerCase().compareTo(b.label.toLowerCase())); } return l; } void _snack(String t) => ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(t))); String _day(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; Future _backup(InstalledApp a) async { try { final out = await backupApk(a, await widget.backend.backupDir()); _snack(a.splits > 0 ? trf('APK 를 저장했습니다: {0} - 나눠진 앱 (조각 {1}개) 이라 이 파일 하나로는 설치되지 않을 수 있습니다', [vDisplay(out), a.splits]) : trf('APK 를 저장했습니다: {0}', [vDisplay(out)])); } catch (e) { _snack(trf('APK 를 저장하지 못했습니다: {0}', [e])); } } Future _menu(InstalledApp a) async { final w = await showModalBottomSheet( context: context, builder: (ctx) => SafeArea( child: Column(mainAxisSize: MainAxisSize.min, children: [ ListTile(title: Text(a.label), subtitle: Text(a.package)), ListTile(leading: const Icon(Icons.save_alt), title: Text(tr('APK 백업')), onTap: () => Navigator.pop(ctx, 'backup')), ListTile(leading: const Icon(Icons.info_outline), title: Text(tr('앱 정보 (시스템 화면)')), onTap: () => Navigator.pop(ctx, 'details')), if (!a.system) ListTile( leading: Icon(Icons.delete_outline, color: JjColors.danger), title: Text(tr('지우기 (시스템 창에서 확인)')), onTap: () => Navigator.pop(ctx, 'uninstall')), ]), ), ); switch (w) { case 'backup': await _backup(a); case 'details': await widget.backend.details(a.package); case 'uninstall': await widget.backend.uninstall(a.package); } } @override Widget build(BuildContext context) { final apps = _apps; return Scaffold( appBar: jjTitleBar( context, title: tr('앱 관리자'), actions: [IconButton(tooltip: tr('새로 고침'), icon: const Icon(Icons.refresh), onPressed: _load)], ), body: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: Text(tr('보이는 앱만 나옵니다 (모든 앱 목록 권한은 쓰지 않음)'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row(children: [ Expanded( child: TextField( decoration: InputDecoration(isDense: true, prefixIcon: const Icon(Icons.search, size: 18), hintText: tr('이름으로 찾기')), onChanged: (v) => setState(() => _filter = v.trim()), ), ), const SizedBox(width: 8), DropdownButton( value: _sort, items: [ DropdownMenuItem(value: 'name', child: Text(tr('이름'))), DropdownMenuItem(value: 'size', child: Text(tr('크기'))), DropdownMenuItem(value: 'installed', child: Text(tr('설치일'))), ], onChanged: (v) => setState(() => _sort = v!), ), ]), ), if (_error != null) Padding(padding: const EdgeInsets.all(16), child: Text(_error!, style: TextStyle(color: JjColors.danger))), if (apps == null && _error == null) const Expanded(child: Center(child: CircularProgressIndicator())), if (apps != null) Expanded( child: ListView.builder( itemCount: _shown.length, itemBuilder: (_, i) { final a = _shown[i]; return ListTile( leading: SizedBox( width: 40, height: 40, child: FutureBuilder( future: _icons[a.package] ??= widget.backend.icon(a.package).catchError((_) => null), builder: (_, s) => s.data == null ? const Icon(Icons.android) : Image.memory(s.data!, width: 40, height: 40), ), ), title: Text(a.label, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Text([if (a.version.isNotEmpty) a.version, formatSize(a.size), trf('설치 {0}', [_day(a.installed)])].join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis), trailing: IconButton(tooltip: tr('더 보기'), icon: const Icon(Icons.more_vert), onPressed: () => _menu(a)), onTap: () => _menu(a), ); }, ), ), ]), ); } }