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 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 createState() => _AppSokShellState(); } class _AppSokShellState extends State { 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 _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 _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; }