appsok/lib/src/features/settings/settings_page.dart
2026-06-13 14:11:41 +09:00

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',
),
),
),
],
),
);
}
}