- 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
441 lines
12 KiB
Dart
441 lines
12 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../models/adb_device.dart';
|
|
import '../models/install_attempt_result.dart';
|
|
import '../models/jenkins_build.dart';
|
|
import '../models/pending_install.dart';
|
|
import '../services/adb_service.dart';
|
|
import '../services/artifact_staging_service.dart';
|
|
import 'builds/builds_page.dart';
|
|
import 'console/console_page.dart';
|
|
import 'devices/devices_page.dart';
|
|
import 'settings/settings_page.dart';
|
|
|
|
export 'builds/builds_page.dart' show JenkinsConnectionStatus;
|
|
export 'settings/settings_page.dart'
|
|
show
|
|
JenkinsSessionClearer,
|
|
JenkinsWebLoginLauncher,
|
|
JenkinsWebLoginResult,
|
|
JenkinsTokenSaver;
|
|
|
|
typedef AdbInstaller =
|
|
Future<AdbInstallResult> Function(AdbDevice device, PendingInstall pending);
|
|
|
|
class AppSokShell extends StatefulWidget {
|
|
const AppSokShell({
|
|
super.key,
|
|
this.jobLoader,
|
|
this.buildLoader,
|
|
this.artifactDownloader,
|
|
this.artifactStager,
|
|
this.artifactCleaner,
|
|
this.installer,
|
|
this.deviceLoader,
|
|
this.adbDiagnosticsLoader,
|
|
this.webLoginLauncher,
|
|
this.onWebLoginCompleted,
|
|
this.onSessionSaved,
|
|
this.tokenSaver,
|
|
this.sessionClearer,
|
|
this.connectionStatus,
|
|
this.onRetryConnection,
|
|
this.logcatStarter,
|
|
});
|
|
|
|
final JenkinsJobLoader? jobLoader;
|
|
final JenkinsBuildLoader? buildLoader;
|
|
final ArtifactDownloader? artifactDownloader;
|
|
final ArtifactStager? artifactStager;
|
|
final ArtifactCleaner? artifactCleaner;
|
|
final AdbInstaller? installer;
|
|
final AdbDeviceLoader? deviceLoader;
|
|
final AdbDiagnosticsLoader? adbDiagnosticsLoader;
|
|
final JenkinsWebLoginLauncher? webLoginLauncher;
|
|
final void Function(JenkinsWebLoginResult result)? onWebLoginCompleted;
|
|
final VoidCallback? onSessionSaved;
|
|
final JenkinsTokenSaver? tokenSaver;
|
|
final JenkinsSessionClearer? sessionClearer;
|
|
final JenkinsConnectionStatus? connectionStatus;
|
|
final VoidCallback? onRetryConnection;
|
|
final AdbLogcatSessionStarter? logcatStarter;
|
|
|
|
@override
|
|
State<AppSokShell> createState() => _AppSokShellState();
|
|
}
|
|
|
|
class _AppSokShellState extends State<AppSokShell> {
|
|
int _selectedIndex = 0;
|
|
PendingInstall? _pendingInstall;
|
|
AdbDevice? _selectedConsoleDevice;
|
|
|
|
void _handleLogcatRequested(AdbDevice device) {
|
|
setState(() {
|
|
_selectedConsoleDevice = device;
|
|
_selectedIndex = 2;
|
|
});
|
|
}
|
|
|
|
// Install lifecycle state — owned by shell so cleanup survives DevicesPage dispose.
|
|
bool _isInstalling = false;
|
|
InstallAttemptResult? _lastInstallAttempt;
|
|
|
|
@override
|
|
void dispose() {
|
|
final pending = _pendingInstall;
|
|
if (pending != null) {
|
|
_cleanupPendingInstall(pending);
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
void _handleInstallRequested(
|
|
JenkinsBuild build,
|
|
BuildArtifact artifact,
|
|
StagedApk? staged,
|
|
) {
|
|
if (staged == null) return;
|
|
final previous = _pendingInstall;
|
|
setState(() {
|
|
_pendingInstall = PendingInstall(
|
|
apkPath: staged.path,
|
|
fileName: artifact.fileName,
|
|
buildNumber: build.number,
|
|
jobName: build.jobName,
|
|
sizeBytes: staged.sizeBytes,
|
|
);
|
|
_lastInstallAttempt = null;
|
|
_selectedIndex = 1;
|
|
});
|
|
if (previous != null && previous.apkPath != staged.path) {
|
|
_cleanupPendingInstall(previous);
|
|
}
|
|
}
|
|
|
|
Future<void> _handleDeviceInstallRequested(
|
|
AdbDevice device,
|
|
PendingInstall pending,
|
|
) async {
|
|
final installer = widget.installer;
|
|
if (installer == null || _isInstalling) return;
|
|
|
|
setState(() {
|
|
_isInstalling = true;
|
|
_lastInstallAttempt = null;
|
|
});
|
|
|
|
// Capture pending path before any state mutation.
|
|
final pathToClean = pending.apkPath;
|
|
|
|
AdbInstallResult result;
|
|
try {
|
|
result = await installer(device, pending);
|
|
} catch (e) {
|
|
result = AdbInstallResult(exitCode: -1, stdout: '', stderr: e.toString());
|
|
}
|
|
|
|
// Cleanup must happen regardless of whether the shell is still mounted.
|
|
_cleanupPath(pathToClean);
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_pendingInstall = null;
|
|
_isInstalling = false;
|
|
_lastInstallAttempt = InstallAttemptResult(
|
|
pendingInstall: pending,
|
|
device: device,
|
|
result: result,
|
|
);
|
|
});
|
|
}
|
|
|
|
void _handlePendingInstallCancelled() {
|
|
final pending = _pendingInstall;
|
|
if (pending == null) return;
|
|
setState(() => _pendingInstall = null);
|
|
_cleanupPendingInstall(pending);
|
|
}
|
|
|
|
void _handleInstallAttemptCleared() {
|
|
if (_lastInstallAttempt == null) return;
|
|
setState(() => _lastInstallAttempt = null);
|
|
}
|
|
|
|
void _cleanupPendingInstall(PendingInstall pending) {
|
|
_cleanupPath(pending.apkPath);
|
|
}
|
|
|
|
void _cleanupPath(String path) {
|
|
final cleaner = widget.artifactCleaner;
|
|
if (cleaner != null) {
|
|
unawaited(_runCleanup(cleaner, path));
|
|
}
|
|
}
|
|
|
|
Future<void> _runCleanup(ArtifactCleaner cleaner, String path) async {
|
|
try {
|
|
await cleaner(path);
|
|
} catch (_) {
|
|
// Cleanup is best-effort; app teardown and navigation should continue.
|
|
}
|
|
}
|
|
|
|
List<_ShellPage> get _pages => [
|
|
_ShellPage(
|
|
label: '빌드',
|
|
icon: Icons.inventory_2_outlined,
|
|
selectedIcon: Icons.inventory_2,
|
|
child: BuildsPage(
|
|
jobLoader: widget.jobLoader,
|
|
buildLoader: widget.buildLoader,
|
|
artifactDownloader: widget.artifactDownloader,
|
|
artifactStager: widget.artifactStager,
|
|
artifactCleaner: widget.artifactCleaner,
|
|
onInstallRequested: _handleInstallRequested,
|
|
connectionStatus: widget.connectionStatus,
|
|
onRetryConnection: widget.onRetryConnection,
|
|
),
|
|
),
|
|
_ShellPage(
|
|
label: '디바이스',
|
|
icon: Icons.usb_outlined,
|
|
selectedIcon: Icons.usb,
|
|
child: DevicesPage(
|
|
pendingInstall: _pendingInstall,
|
|
onPendingInstallCancelled: _isInstalling
|
|
? null
|
|
: _handlePendingInstallCancelled,
|
|
installer: widget.installer == null
|
|
? null
|
|
: _handleDeviceInstallRequested,
|
|
deviceLoader: widget.deviceLoader,
|
|
isInstalling: _isInstalling,
|
|
lastInstallAttempt: _lastInstallAttempt,
|
|
onInstallAttemptCleared: _handleInstallAttemptCleared,
|
|
onLogcatRequested: _handleLogcatRequested,
|
|
),
|
|
),
|
|
_ShellPage(
|
|
label: '콘솔',
|
|
icon: Icons.terminal_outlined,
|
|
selectedIcon: Icons.terminal,
|
|
child: ConsolePage(
|
|
device: _selectedConsoleDevice,
|
|
logcatStarter: widget.logcatStarter,
|
|
),
|
|
),
|
|
_ShellPage(
|
|
label: '설정',
|
|
icon: Icons.tune_outlined,
|
|
selectedIcon: Icons.tune,
|
|
child: SettingsPage(
|
|
adbDiagnosticsLoader: widget.adbDiagnosticsLoader,
|
|
webLoginLauncher: widget.webLoginLauncher,
|
|
onWebLoginCompleted: widget.onWebLoginCompleted,
|
|
onSessionSaved: widget.onSessionSaved,
|
|
tokenSaver: widget.tokenSaver,
|
|
sessionClearer: widget.sessionClearer,
|
|
),
|
|
),
|
|
];
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final page = _pages[_selectedIndex];
|
|
|
|
return Scaffold(
|
|
body: SafeArea(
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 196,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
const _BrandHeader(),
|
|
Expanded(
|
|
child: NavigationRail(
|
|
extended: true,
|
|
minExtendedWidth: 196,
|
|
selectedIndex: _selectedIndex,
|
|
onDestinationSelected: (index) {
|
|
setState(() => _selectedIndex = index);
|
|
},
|
|
destinations: _pages
|
|
.map(
|
|
(item) => NavigationRailDestination(
|
|
icon: Icon(item.icon),
|
|
selectedIcon: Icon(item.selectedIcon),
|
|
label: Text(item.label),
|
|
),
|
|
)
|
|
.toList(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const VerticalDivider(width: 1),
|
|
Expanded(
|
|
child: Column(
|
|
children: [
|
|
_TopBar(title: page.label),
|
|
Expanded(child: page.child),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BrandHeader extends StatelessWidget {
|
|
const _BrandHeader();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 22, 16, 18),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 42,
|
|
height: 42,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.primary,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: const Icon(Icons.bolt, color: Colors.white, size: 24),
|
|
),
|
|
const SizedBox(width: 12),
|
|
const Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'AppSok',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900),
|
|
),
|
|
Text(
|
|
'Jenkins to USB',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TopBar extends StatelessWidget {
|
|
const _TopBar({required this.title});
|
|
|
|
final String title;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
height: 72,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 28),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
),
|
|
const _StatusPill(
|
|
key: ValueKey('status-jenkins'),
|
|
icon: Icons.key_outlined,
|
|
label: 'Jenkins 대기',
|
|
tooltip: 'Jenkins 상태 연결 대기',
|
|
),
|
|
const SizedBox(width: 8),
|
|
const _StatusPill(
|
|
key: ValueKey('status-adb'),
|
|
icon: Icons.usb_outlined,
|
|
label: 'ADB 대기',
|
|
tooltip: 'ADB 상태 연결 대기',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatusPill extends StatelessWidget {
|
|
const _StatusPill({
|
|
super.key,
|
|
required this.icon,
|
|
required this.label,
|
|
required this.tooltip,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final String tooltip;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Container(
|
|
height: 34,
|
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
border: Border.all(color: colorScheme.outlineVariant),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Tooltip(
|
|
message: tooltip,
|
|
child: Icon(icon, size: 16, color: colorScheme.onSurfaceVariant),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: colorScheme.onSurfaceVariant,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ShellPage {
|
|
const _ShellPage({
|
|
required this.label,
|
|
required this.icon,
|
|
required this.selectedIcon,
|
|
required this.child,
|
|
});
|
|
|
|
final String label;
|
|
final IconData icon;
|
|
final IconData selectedIcon;
|
|
final Widget child;
|
|
}
|