appsok/lib/src/features/app_shell.dart
toki ce859e1cc0 feat: jenkins credential integration and UI updates
- Add jenkins credential web login entry implementation
- Update roadmap for jenkins credential phase
- Archive usb-install milestone
- Update app shell and devices page for jenkins integration
- Add jenkins client service and update models
- Update tests for changed services
2026-06-13 09:44:43 +09:00

388 lines
10 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import '../models/adb_device.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';
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,
});
final JenkinsJobLoader? jobLoader;
final JenkinsBuildLoader? buildLoader;
final ArtifactDownloader? artifactDownloader;
final ArtifactStager? artifactStager;
final ArtifactCleaner? artifactCleaner;
final AdbInstaller? installer;
final AdbDeviceLoader? deviceLoader;
final AdbDiagnosticsLoader? adbDiagnosticsLoader;
@override
State<AppSokShell> createState() => _AppSokShellState();
}
class _AppSokShellState extends State<AppSokShell> {
int _selectedIndex = 0;
PendingInstall? _pendingInstall;
// Install lifecycle state — owned by shell so cleanup survives DevicesPage dispose.
bool _isInstalling = false;
AdbDevice? _lastInstallDevice;
AdbInstallResult? _lastInstallResult;
@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,
);
_lastInstallResult = 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;
_lastInstallDevice = device;
_lastInstallResult = 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;
_lastInstallResult = result;
});
}
void _handlePendingInstallCancelled() {
final pending = _pendingInstall;
if (pending == null) return;
setState(() => _pendingInstall = null);
_cleanupPendingInstall(pending);
}
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,
),
),
_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,
lastInstallDevice: _lastInstallDevice,
lastInstallResult: _lastInstallResult,
),
),
const _ShellPage(
label: '콘솔',
icon: Icons.terminal_outlined,
selectedIcon: Icons.terminal,
child: ConsolePage(),
),
_ShellPage(
label: '설정',
icon: Icons.tune_outlined,
selectedIcon: Icons.tune,
child: SettingsPage(adbDiagnosticsLoader: widget.adbDiagnosticsLoader),
),
];
@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;
}