514 lines
15 KiB
Dart
514 lines
15 KiB
Dart
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<JenkinsWebLoginResult?> 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<SettingsPage> createState() => _SettingsPageState();
|
|
}
|
|
|
|
class _SettingsPageState extends State<SettingsPage> {
|
|
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<void> _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<void> _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<JenkinsWebLoginResult?> _defaultLauncher(String baseUrl) {
|
|
return showDialog<JenkinsWebLoginResult>(
|
|
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<String>(
|
|
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',
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|