appsok/lib/src/features/devices/devices_page.dart
toki c1eb3a912c feat: install session history and device page improvements
- Add install_attempt_result model with session tracking
- Update devices page with install history UI
- Extend app_shell with session management
- Update workflow integration phase roadmap
- Add widget tests for device features
2026-06-16 21:13:16 +09:00

750 lines
23 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../models/adb_device.dart';
import '../../models/install_attempt_result.dart';
import '../../models/pending_install.dart';
typedef PendingInstallRunner =
Future<void> Function(AdbDevice device, PendingInstall pending);
typedef AdbDeviceLoader = Future<List<AdbDevice>> Function();
class DevicesPage extends StatefulWidget {
const DevicesPage({
super.key,
this.pendingInstall,
this.onPendingInstallCancelled,
this.installer,
this.deviceLoader,
this.isInstalling = false,
this.lastInstallAttempt,
this.onInstallAttemptCleared,
this.onLogcatRequested,
});
final PendingInstall? pendingInstall;
final VoidCallback? onPendingInstallCancelled;
final PendingInstallRunner? installer;
final AdbDeviceLoader? deviceLoader;
final bool isInstalling;
final InstallAttemptResult? lastInstallAttempt;
final VoidCallback? onInstallAttemptCleared;
final ValueChanged<AdbDevice>? onLogcatRequested;
@override
State<DevicesPage> createState() => _DevicesPageState();
}
class _DevicesPageState extends State<DevicesPage> {
static const _filterAll = 'all';
static const _filterReady = 'ready';
static const _filterAttention = 'attention';
Set<String> _selectedFilter = const {_filterAll};
List<AdbDevice>? _loadedDevices;
bool _isLoading = false;
Object? _loadError;
@override
void initState() {
super.initState();
if (widget.deviceLoader != null) {
unawaited(_refreshDevices());
}
}
@override
void didUpdateWidget(DevicesPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.deviceLoader != oldWidget.deviceLoader) {
_loadedDevices = null;
_loadError = null;
if (widget.deviceLoader != null) {
unawaited(_refreshDevices());
}
}
}
Future<void> _refreshDevices() async {
final loader = widget.deviceLoader;
if (loader == null) {
setState(() {
_loadedDevices = _sampleDevices;
_loadError = null;
});
return;
}
setState(() {
_isLoading = true;
_loadError = null;
});
try {
final devices = await loader();
if (!mounted) return;
setState(() {
_loadedDevices = devices;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_loadError = e;
_isLoading = false;
});
}
}
List<AdbDevice> get _devices => widget.deviceLoader == null
? _sampleDevices
: (_loadedDevices ?? const []);
List<AdbDevice> get _filteredDevices {
final selected = _selectedFilter.single;
return switch (selected) {
_filterReady => _devices.where((device) => device.isReady).toList(),
_filterAttention => _devices.where((device) => !device.isReady).toList(),
_ => _devices,
};
}
@override
Widget build(BuildContext context) {
final devices = _filteredDevices;
return Padding(
padding: const EdgeInsets.fromLTRB(28, 0, 28, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.pendingInstall != null) ...[
_PendingInstallBanner(
pendingInstall: widget.pendingInstall!,
onCancel: widget.isInstalling
? null
: widget.onPendingInstallCancelled,
),
const SizedBox(height: 12),
],
if (widget.lastInstallAttempt case final attempt?) ...[
_InstallResultBanner(
attempt: attempt,
onClear: widget.onInstallAttemptCleared,
),
const SizedBox(height: 12),
],
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SegmentedButton<String>(
segments: const [
ButtonSegment(
value: _filterAll,
label: Text('전체'),
icon: Icon(Icons.devices),
),
ButtonSegment(
value: _filterReady,
label: Text('준비됨'),
icon: Icon(Icons.check_circle_outline),
),
ButtonSegment(
value: _filterAttention,
label: Text('대기'),
icon: Icon(Icons.warning_amber_outlined),
),
],
selected: _selectedFilter,
onSelectionChanged: (selection) {
setState(() => _selectedFilter = selection);
},
),
OutlinedButton.icon(
key: const ValueKey('device-refresh-button'),
onPressed: _isLoading ? null : _refreshDevices,
icon: const Icon(Icons.refresh),
label: const Text('새로고침'),
),
],
),
if (_isLoading) ...[
const SizedBox(height: 12),
const LinearProgressIndicator(),
],
if (_loadError != null) ...[
const SizedBox(height: 12),
_DeviceLoadError(onRetry: _refreshDevices),
],
const SizedBox(height: 18),
Expanded(
child: devices.isEmpty && _isLoading
? const Center(child: CircularProgressIndicator())
: devices.isEmpty
? const _EmptyDevicesView()
: GridView.builder(
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 360,
mainAxisExtent: 196,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
),
itemCount: devices.length,
itemBuilder: (context, index) {
return _DeviceTile(
device: devices[index],
pendingInstall: widget.pendingInstall,
isInstalling: widget.isInstalling,
installer: widget.installer,
onLogcatRequested: widget.onLogcatRequested,
);
},
),
),
],
),
);
}
}
class _DeviceLoadError extends StatelessWidget {
const _DeviceLoadError({required this.onRetry});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
key: const ValueKey('device-load-error'),
color: colorScheme.errorContainer.withValues(alpha: 0.34),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: colorScheme.error.withValues(alpha: 0.32)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 10, 10),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 10),
Expanded(
child: Text(
'device 목록을 불러오지 못했습니다',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: colorScheme.error,
fontWeight: FontWeight.w700,
),
),
),
IconButton(
tooltip: '다시 시도',
onPressed: onRetry,
icon: const Icon(Icons.refresh),
),
],
),
),
);
}
}
class _EmptyDevicesView extends StatelessWidget {
const _EmptyDevicesView();
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.usb_off_outlined, size: 40, color: colorScheme.secondary),
const SizedBox(height: 8),
Text(
'연결된 device 없음',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
class _DeviceTile extends StatelessWidget {
const _DeviceTile({
required this.device,
this.pendingInstall,
required this.isInstalling,
this.installer,
this.onLogcatRequested,
});
final AdbDevice device;
final PendingInstall? pendingInstall;
final bool isInstalling;
final PendingInstallRunner? installer;
final ValueChanged<AdbDevice>? onLogcatRequested;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final stateInfo = _DeviceStateInfo.from(device, colorScheme);
final metadataLabel = _metadataLabel(device);
final canInstall =
pendingInstall != null &&
installer != null &&
device.isReady &&
!isInstalling;
return Material(
key: ValueKey('device-tile-${device.serial}'),
color: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.phone_android, color: stateInfo.color),
const SizedBox(width: 8),
Expanded(
child: Text(
device.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
const SizedBox(height: 12),
Text(
device.serial,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
if (metadataLabel.isNotEmpty)
Text(
metadataLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
const Spacer(),
Text(
key: ValueKey('device-state-message-${device.serial}'),
stateInfo.message,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Row(
children: [
Flexible(child: _DeviceStateChip(info: stateInfo)),
const SizedBox(width: 8),
_CompactIconButton(
tooltip: 'logcat',
icon: Icons.terminal,
onPressed: device.isReady && onLogcatRequested != null
? () => onLogcatRequested!(device)
: null,
),
const SizedBox(width: 4),
_CompactIconButton(
tooltip: '설치',
icon: isInstalling
? Icons.hourglass_top
: Icons.install_mobile,
onPressed: canInstall
? () => installer!(device, pendingInstall!)
: null,
),
],
),
],
),
),
);
}
String _metadataLabel(AdbDevice device) {
final metadata = [
if (device.product != null && device.product!.isNotEmpty)
'product:${device.product}',
if (device.device != null && device.device!.isNotEmpty)
'device:${device.device}',
];
return metadata.join(' · ');
}
}
class _CompactIconButton extends StatelessWidget {
const _CompactIconButton({
required this.tooltip,
required this.icon,
this.onPressed,
});
final String tooltip;
final IconData icon;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return IconButton(
tooltip: tooltip,
constraints: const BoxConstraints.tightFor(width: 36, height: 36),
padding: EdgeInsets.zero,
onPressed: onPressed,
icon: Icon(icon, size: 20),
);
}
}
class _DeviceStateChip extends StatelessWidget {
const _DeviceStateChip({required this.info});
final _DeviceStateInfo info;
@override
Widget build(BuildContext context) {
return Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 10),
alignment: Alignment.center,
decoration: BoxDecoration(
color: info.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(info.icon, size: 14, color: info.color),
const SizedBox(width: 5),
Flexible(
child: Text(
key: info.key,
info.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: info.color, fontWeight: FontWeight.w800),
),
),
],
),
);
}
}
class _DeviceStateInfo {
const _DeviceStateInfo({
required this.label,
required this.message,
required this.icon,
required this.color,
required this.key,
});
final String label;
final String message;
final IconData icon;
final Color color;
final Key key;
static _DeviceStateInfo from(AdbDevice device, ColorScheme colorScheme) {
if (device.isReady) {
return _DeviceStateInfo(
label: 'ready',
message: '설치 가능',
icon: Icons.check_circle_outline,
color: colorScheme.primary,
key: ValueKey('device-state-${device.serial}'),
);
}
if (device.needsAuthorization) {
return _DeviceStateInfo(
label: '승인 필요',
message: '폰에서 USB debugging 승인 필요',
icon: Icons.verified_user_outlined,
color: colorScheme.error,
key: ValueKey('device-state-${device.serial}'),
);
}
if (device.isOffline) {
return _DeviceStateInfo(
label: 'offline',
message: 'ADB 연결 대기',
icon: Icons.hourglass_empty,
color: colorScheme.secondary,
key: ValueKey('device-state-${device.serial}'),
);
}
return _DeviceStateInfo(
label: device.state,
message: '상태 확인 필요',
icon: Icons.help_outline,
color: colorScheme.onSurfaceVariant,
key: ValueKey('device-state-${device.serial}'),
);
}
}
class _PendingInstallBanner extends StatelessWidget {
const _PendingInstallBanner({required this.pendingInstall, this.onCancel});
final PendingInstall pendingInstall;
final VoidCallback? onCancel;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final sizeLabel = pendingInstall.sizeBytes < 1024 * 1024
? '${(pendingInstall.sizeBytes / 1024).toStringAsFixed(1)} KB'
: '${(pendingInstall.sizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB';
return Material(
key: const ValueKey('pending-install-banner'),
color: colorScheme.primaryContainer.withValues(alpha: 0.42),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: colorScheme.primary.withValues(alpha: 0.34)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
Icon(Icons.install_mobile, color: colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
key: const ValueKey('pending-install-filename'),
pendingInstall.fileName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'#${pendingInstall.buildNumber} · ${pendingInstall.jobName} · $sizeLabel',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
key: const ValueKey('pending-install-path'),
pendingInstall.apkPath,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (onCancel != null) ...[
const SizedBox(width: 8),
IconButton(
key: const ValueKey('pending-install-cancel-button'),
tooltip: '설치 대기 취소',
onPressed: onCancel,
icon: const Icon(Icons.close),
),
],
],
),
),
);
}
}
class _InstallResultBanner extends StatelessWidget {
const _InstallResultBanner({required this.attempt, this.onClear});
final InstallAttemptResult attempt;
final VoidCallback? onClear;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isSuccess = attempt.succeeded;
final color = isSuccess ? colorScheme.primary : colorScheme.error;
final output = attempt.maskedOutputSummary;
final metadataKey = ValueKey(
'install-result-attempt:${attempt.pendingInstall.jobName}:'
'${attempt.pendingInstall.buildNumber}:${attempt.pendingInstall.fileName}',
);
return Material(
key: const ValueKey('install-result-banner'),
color: color.withValues(alpha: 0.08),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: color.withValues(alpha: 0.34)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
isSuccess ? Icons.check_circle_outline : Icons.error_outline,
color: color,
),
const SizedBox(width: 12),
Expanded(
child: Column(
key: metadataKey,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
key: const ValueKey('current-install-result-title'),
'현재 설치 결과',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
key: const ValueKey('install-result-label'),
isSuccess
? '${attempt.device.displayName}에 설치 성공'
: '${attempt.device.displayName}에 설치 실패 (exit ${attempt.result.exitCode})',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
color: color,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
attempt.artifactLabel,
key: const ValueKey('install-result-metadata'),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (output.isNotEmpty)
Text(
key: const ValueKey('install-result-output'),
output,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
fontFamily: 'monospace',
),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
key: const ValueKey('install-report-copy-button'),
tooltip: '리포트 복사',
constraints: const BoxConstraints.tightFor(
width: 36,
height: 36,
),
padding: EdgeInsets.zero,
onPressed: () => _copyReport(context),
icon: const Icon(Icons.content_copy, size: 20),
),
IconButton(
key: const ValueKey('install-result-clear-button'),
tooltip: '현재 결과 초기화',
constraints: const BoxConstraints.tightFor(
width: 36,
height: 36,
),
padding: EdgeInsets.zero,
onPressed: onClear,
icon: const Icon(Icons.close, size: 20),
),
],
),
],
),
),
);
}
Future<void> _copyReport(BuildContext context) async {
await Clipboard.setData(ClipboardData(text: attempt.maskedReport));
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('리포트 복사됨')));
}
}
const _sampleDevices = [
AdbDevice(
serial: 'R5CT90A1B2C',
state: 'device',
model: 'SM_S918N',
product: 'dm3q',
),
AdbDevice(
serial: 'emulator-5554',
state: 'device',
model: 'sdk_gphone64_arm64',
product: 'emu64a',
),
AdbDevice(
serial: 'ZX1G22K7Q9',
state: 'unauthorized',
model: 'Pixel_8',
product: 'shiba',
),
];