import 'package:flutter/material.dart'; import '../../services/adb_service.dart'; import 'jenkins_web_login_page.dart'; class JenkinsWebLoginResult { const JenkinsWebLoginResult({required this.username, this.displayName}); final String username; final String? displayName; } typedef JenkinsWebLoginLauncher = Future Function(String baseUrl); class SettingsPage extends StatefulWidget { const SettingsPage({ super.key, this.adbDiagnosticsLoader, this.webLoginLauncher, this.onWebLoginCompleted, }); final AdbDiagnosticsLoader? adbDiagnosticsLoader; final JenkinsWebLoginLauncher? webLoginLauncher; final void Function(JenkinsWebLoginResult result)? onWebLoginCompleted; @override State createState() => _SettingsPageState(); } class _SettingsPageState extends State { AdbRuntimeDiagnostics? _diagnostics; bool _loading = false; Object? _loadError; final _baseUrlController = TextEditingController(); bool _webLoginLoading = false; Object? _webLoginError; JenkinsWebLoginResult? _lastLoginResult; @override void initState() { super.initState(); _baseUrlController.addListener(_onBaseUrlChanged); } void _onBaseUrlChanged() => setState(() {}); @override void dispose() { _baseUrlController.removeListener(_onBaseUrlChanged); _baseUrlController.dispose(); super.dispose(); } bool get _isValidJenkinsUrl { final text = _baseUrlController.text.trim(); if (text.isEmpty) return false; try { final uri = Uri.parse(text); return (uri.scheme == 'http' || uri.scheme == 'https') && uri.host.isNotEmpty; } catch (_) { return false; } } Future _loadDiagnostics() async { final loader = widget.adbDiagnosticsLoader; if (loader == null) return; setState(() { _loading = true; _loadError = null; }); try { final diag = await loader(); if (!mounted) return; setState(() { _diagnostics = diag; _loading = false; }); } catch (e) { if (!mounted) return; setState(() { _loadError = e; _loading = false; }); } } Future _handleWebLogin() async { if (!_isValidJenkinsUrl || _webLoginLoading) return; final baseUrl = _baseUrlController.text.trim(); final launcher = widget.webLoginLauncher ?? _defaultLauncher; setState(() { _webLoginLoading = true; _webLoginError = null; }); try { final result = await launcher(baseUrl); if (!mounted) return; if (result != null) { widget.onWebLoginCompleted?.call(result); setState(() { _lastLoginResult = result; _webLoginLoading = false; }); } else { setState(() => _webLoginLoading = false); } } catch (e) { if (!mounted) return; setState(() { _webLoginError = e; _webLoginLoading = false; }); } } Future _defaultLauncher(String baseUrl) { return showDialog( context: context, barrierDismissible: false, builder: (_) => Dialog.fullscreen(child: JenkinsWebLoginPage(baseUrl: baseUrl)), ); } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(28, 0, 28, 28), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Material( color: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), side: BorderSide(color: colorScheme.outlineVariant), ), child: Padding( padding: const EdgeInsets.all(18), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Jenkins', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 14), Row( children: [ Expanded( child: ListenableBuilder( listenable: _baseUrlController, builder: (context, _) => TextField( controller: _baseUrlController, decoration: InputDecoration( labelText: 'Base URL', prefixIcon: const Icon(Icons.dns_outlined), errorText: _baseUrlController.text.isNotEmpty && !_isValidJenkinsUrl ? '유효한 http/https URL을 입력하세요' : null, ), ), ), ), const SizedBox(width: 12), Expanded( child: TextField( decoration: const InputDecoration( labelText: '기본 Job', prefixIcon: Icon(Icons.account_tree_outlined), ), ), ), ], ), const SizedBox(height: 14), SegmentedButton( segments: const [ ButtonSegment( value: 'manual', label: Text('Token'), icon: Icon(Icons.key), ), ButtonSegment( value: 'webview', label: Text('Web Login'), icon: Icon(Icons.web_asset), ), ], selected: const {'webview'}, onSelectionChanged: (_) {}, ), const SizedBox(height: 14), _WebLoginSection( isValidUrl: _isValidJenkinsUrl, isLoading: _webLoginLoading, error: _webLoginError, lastResult: _lastLoginResult, onLogin: _handleWebLogin, ), ], ), ), ), const SizedBox(height: 14), Material( color: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), side: BorderSide(color: colorScheme.outlineVariant), ), child: Padding( padding: const EdgeInsets.all(18), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('ADB', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 14), Row( children: [ Expanded( child: TextField( decoration: const InputDecoration( labelText: 'adb path', prefixIcon: Icon(Icons.terminal), ), ), ), const SizedBox(width: 12), OutlinedButton.icon( onPressed: () {}, icon: const Icon(Icons.search), label: const Text('찾기'), ), ], ), const SizedBox(height: 8), SwitchListTile( contentPadding: EdgeInsets.zero, value: true, onChanged: (_) {}, title: const Text('설치 전 동일 패키지 업데이트'), secondary: const Icon(Icons.update), ), if (widget.adbDiagnosticsLoader != null) ...[ const Divider(height: 24), _AdbDiagnosticsSection( diagnostics: _diagnostics, loading: _loading, loadError: _loadError, onRefresh: _loadDiagnostics, ), ], ], ), ), ), ], ), ); } } class _WebLoginSection extends StatelessWidget { const _WebLoginSection({ required this.isValidUrl, required this.isLoading, required this.error, required this.lastResult, required this.onLogin, }); final bool isValidUrl; final bool isLoading; final Object? error; final JenkinsWebLoginResult? lastResult; final VoidCallback onLogin; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ElevatedButton.icon( key: const ValueKey('jenkins-web-login-button'), onPressed: isValidUrl && !isLoading ? onLogin : null, icon: isLoading ? SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: colorScheme.onPrimary, ), ) : const Icon(Icons.open_in_browser), label: const Text('Web Login'), ), if (error != null) ...[ const SizedBox(height: 8), Text( '로그인 오류: $error', style: textTheme.bodySmall?.copyWith(color: colorScheme.error), ), ], if (lastResult != null) ...[ const SizedBox(height: 8), Text( key: const ValueKey('web-login-result'), lastResult!.displayName != null ? '로그인 완료: ${lastResult!.displayName}' : '로그인 완료: ${lastResult!.username}', style: textTheme.bodySmall?.copyWith( color: colorScheme.primary, fontWeight: FontWeight.w600, ), ), ], ], ); } } class _AdbDiagnosticsSection extends StatelessWidget { const _AdbDiagnosticsSection({ required this.diagnostics, required this.loading, required this.loadError, required this.onRefresh, }); final AdbRuntimeDiagnostics? diagnostics; final bool loading; final Object? loadError; final VoidCallback onRefresh; @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; final colorScheme = Theme.of(context).colorScheme; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( 'ADB 진단', style: textTheme.labelMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), ), const SizedBox(width: 8), if (loading) Icon( key: const ValueKey('adb-diag-loading'), Icons.hourglass_empty, size: 14, color: colorScheme.onSurfaceVariant, ) else InkWell( onTap: onRefresh, child: Icon( Icons.refresh, size: 16, color: colorScheme.onSurfaceVariant, ), ), ], ), const SizedBox(height: 8), if (loadError != null) Text( '진단 로드 실패', style: textTheme.bodySmall?.copyWith(color: colorScheme.error), ) else if (diagnostics == null && !loading) Text( '진단 정보 없음', style: textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ) else if (diagnostics != null) _DiagRows(diagnostics: diagnostics!), ], ); } } class _DiagRows extends StatelessWidget { const _DiagRows({required this.diagnostics}); final AdbRuntimeDiagnostics diagnostics; @override Widget build(BuildContext context) { final rt = diagnostics.runtime; final sha = diagnostics.sha256; final shortSha = sha != null && sha.length >= 12 ? sha.substring(0, 12) : sha; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _DiagRow( key: const ValueKey('diag-source'), label: '소스', value: _sourceLabel(rt.source, rt.fallbackUsed), ), _DiagRow( key: const ValueKey('diag-path'), label: '경로', value: rt.executablePath, ), _DiagRow( key: const ValueKey('diag-port'), label: '서버 포트', value: rt.serverPort.toString(), ), if (diagnostics.version != null) _DiagRow( key: const ValueKey('diag-version'), label: '버전', value: diagnostics.version!.split('\n').first, ), if (diagnostics.versionError != null) _DiagRow( key: const ValueKey('diag-version-error'), label: '버전 오류', value: diagnostics.versionError!, isError: true, ), if (shortSha != null) _DiagRow( key: const ValueKey('diag-hash'), label: 'SHA-256', value: shortSha, ), ], ); } String _sourceLabel(AdbExecutableSource source, bool fallback) { return switch (source) { AdbExecutableSource.bundled => 'bundled', AdbExecutableSource.custom => 'custom', AdbExecutableSource.system => fallback ? 'system (fallback)' : 'system', }; } } class _DiagRow extends StatelessWidget { const _DiagRow({ super.key, required this.label, required this.value, this.isError = false, }); final String label; final String value; final bool isError; @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; final colorScheme = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 72, child: Text( label, style: textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ), Expanded( child: Text( value, maxLines: 1, overflow: TextOverflow.ellipsis, style: textTheme.bodySmall?.copyWith( color: isError ? colorScheme.error : null, fontFamily: 'monospace', ), ), ), ], ), ); } }