- 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
750 lines
23 KiB
Dart
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',
|
|
),
|
|
];
|