appsok/lib/src/features/app_shell.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

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;
}