diff --git a/native/assets/docker.svg b/native/assets/docker.svg new file mode 100644 index 0000000..ceaa6b8 --- /dev/null +++ b/native/assets/docker.svg @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/native/lib/features/docker/docker_container.dart b/native/lib/features/docker/docker_container.dart new file mode 100644 index 0000000..9557ae1 --- /dev/null +++ b/native/lib/features/docker/docker_container.dart @@ -0,0 +1,57 @@ +class DockerContainer { + const DockerContainer({ + required this.id, + required this.name, + required this.image, + required this.status, + required this.ports, + required this.createdAt, + required this.uptime, + }); + + final String id; + final String name; + final String image; + final String status; + final List ports; + final String createdAt; + final String uptime; + + factory DockerContainer.fromJson(Map json) { + final rawPorts = json['ports']; + return DockerContainer( + id: (json['id'] ?? '').toString(), + name: (json['name'] ?? '').toString(), + image: (json['image'] ?? '').toString(), + status: (json['status'] ?? '').toString(), + ports: rawPorts is List + ? rawPorts.map((port) => port.toString()).toList(growable: false) + : const [], + createdAt: (json['createdAt'] ?? '').toString(), + uptime: (json['uptime'] ?? '').toString(), + ); + } + + String get shortId => id.length <= 12 ? id : id.substring(0, 12); + bool get isRunning => status == 'running'; +} + +class DockerOperationResult { + const DockerOperationResult({required this.success, required this.message}); + + final bool success; + final String message; + + factory DockerOperationResult.fromJson(dynamic json) { + if (json is Map) { + return DockerOperationResult( + success: json['success'] == true, + message: (json['message'] ?? '').toString(), + ); + } + return const DockerOperationResult( + success: false, + message: 'Unknown error', + ); + } +} diff --git a/native/lib/features/docker/docker_icon.dart b/native/lib/features/docker/docker_icon.dart new file mode 100644 index 0000000..a6a1a43 --- /dev/null +++ b/native/lib/features/docker/docker_icon.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class DockerIcon extends StatelessWidget { + const DockerIcon({super.key, this.size, this.visualScale = 0.82}); + + final double? size; + final double visualScale; + + @override + Widget build(BuildContext context) { + final iconTheme = IconTheme.of(context); + final iconSize = size ?? iconTheme.size ?? 24; + final imageSize = iconSize * visualScale; + return SizedBox.square( + dimension: iconSize, + child: Center( + child: SvgPicture.asset( + 'assets/docker.svg', + width: imageSize, + height: imageSize, + fit: BoxFit.contain, + colorFilter: ColorFilter.mode( + iconTheme.color ?? Theme.of(context).colorScheme.onSurface, + BlendMode.srcIn, + ), + ), + ), + ); + } +} diff --git a/native/lib/features/docker/docker_session_manager.dart b/native/lib/features/docker/docker_session_manager.dart new file mode 100644 index 0000000..58fe8ae --- /dev/null +++ b/native/lib/features/docker/docker_session_manager.dart @@ -0,0 +1,218 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show ChangeNotifier; + +import '../../core/api/cookie_store.dart'; +import '../auth/auth_session.dart'; +import '../servers/server_model.dart'; +import 'docker_container.dart'; +import 'docker_socket_client.dart'; + +enum DockerConnectionStatus { connecting, connected, disconnected, error } + +class DockerSessionState { + DockerSessionState({ + required this.server, + required this.client, + this.status = DockerConnectionStatus.connecting, + this.containers = const [], + this.logs = '', + this.errorMessage, + this.loading = true, + this.refreshing = false, + }); + + final ServerModel server; + final DockerSocketClient client; + DockerConnectionStatus status; + List containers; + String logs; + String? errorMessage; + bool loading; + bool refreshing; + final Set operatingIds = {}; +} + +class DockerSessionManager extends ChangeNotifier { + final Map _sessions = {}; + final Map>> _subscriptions = {}; + String? _activeHostId; + + List get sessions => + _sessions.values.toList(growable: false); + + DockerSessionState? get activeSession { + final id = _activeHostId; + if (id == null) return null; + return _sessions[id]; + } + + String? get activeHostId => _activeHostId; + + bool isConnected(String hostId) => + _sessions[hostId]?.status == DockerConnectionStatus.connected; + + Future connect({ + required ServerModel server, + required AuthSession authSession, + required SessionCookieStore cookieStore, + }) async { + final existing = _sessions[server.id]; + if (existing != null) { + _activeHostId = server.id; + if (existing.status == DockerConnectionStatus.disconnected || + existing.status == DockerConnectionStatus.error) { + existing.status = DockerConnectionStatus.connecting; + existing.loading = true; + existing.errorMessage = null; + notifyListeners(); + await existing.client.connect(); + } else { + notifyListeners(); + } + return; + } + + final client = DockerSocketClient( + authSession: authSession, + cookieStore: cookieStore, + hostId: server.id, + ); + final session = DockerSessionState(server: server, client: client); + _sessions[server.id] = session; + _activeHostId = server.id; + _subscriptions[server.id] = [ + client.connectedStream.listen((connected) { + if (connected) { + session.status = DockerConnectionStatus.connected; + } else { + if (session.status == DockerConnectionStatus.connecting) return; + session.status = DockerConnectionStatus.disconnected; + session.loading = false; + } + notifyListeners(); + }), + client.containersStream.listen((containers) { + session.containers = containers; + session.loading = false; + session.refreshing = false; + session.operatingIds.clear(); + session.errorMessage = null; + session.status = DockerConnectionStatus.connected; + notifyListeners(); + }), + client.logsStream.listen((logs) { + session.logs = logs; + notifyListeners(); + }), + client.operationStream.listen((result) { + if (!result.success) { + session.operatingIds.clear(); + session.errorMessage = result.message; + } + session.loading = false; + notifyListeners(); + if (result.success) client.refresh(); + }), + client.errorStream.listen((message) { + session.status = DockerConnectionStatus.error; + session.loading = false; + session.refreshing = false; + session.errorMessage = message; + notifyListeners(); + }), + ]; + notifyListeners(); + await client.connect(); + } + + void activate(String hostId) { + if (!_sessions.containsKey(hostId)) return; + _activeHostId = hostId; + notifyListeners(); + } + + void refreshActive() { + final session = activeSession; + if (session == null) return; + session.refreshing = true; + session.errorMessage = null; + notifyListeners(); + session.client.refresh(); + } + + void reconnectActive() { + final session = activeSession; + if (session == null) return; + session.status = DockerConnectionStatus.connecting; + session.loading = true; + session.errorMessage = null; + notifyListeners(); + unawaited(session.client.connect()); + } + + void getLogs(DockerContainer container) { + final session = activeSession; + if (session == null) return; + session.logs = ''; + notifyListeners(); + session.client.getLogs(container.id); + } + + void refreshLogs(DockerContainer container) { + activeSession?.client.getLogs(container.id); + } + + void start(DockerContainer container) => _operate(container, (client) { + client.start(container.id); + }); + + void stop(DockerContainer container) => _operate(container, (client) { + client.stop(container.id); + }); + + void restart(DockerContainer container) => _operate(container, (client) { + client.restart(container.id); + }); + + void delete(DockerContainer container) => _operate(container, (client) { + client.delete(container.id); + }); + + void _operate( + DockerContainer container, + void Function(DockerSocketClient client) send, + ) { + final session = activeSession; + if (session == null) return; + session.operatingIds.add(container.id); + session.errorMessage = null; + notifyListeners(); + send(session.client); + } + + void disconnectActive() { + final id = _activeHostId; + if (id == null) return; + _disconnect(id); + _activeHostId = _sessions.keys.isEmpty ? null : _sessions.keys.first; + notifyListeners(); + } + + void _disconnect(String hostId) { + final session = _sessions.remove(hostId); + if (session == null) return; + for (final sub in _subscriptions.remove(hostId) ?? const []) { + sub.cancel(); + } + session.client.disconnect(); + } + + @override + void dispose() { + for (final hostId in _sessions.keys.toList(growable: false)) { + _disconnect(hostId); + } + super.dispose(); + } +} diff --git a/native/lib/features/docker/docker_socket_client.dart b/native/lib/features/docker/docker_socket_client.dart new file mode 100644 index 0000000..dd1f152 --- /dev/null +++ b/native/lib/features/docker/docker_socket_client.dart @@ -0,0 +1,205 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; +import 'package:socket_io_client/socket_io_client.dart' as sio; + +import '../../core/api/cookie_store.dart'; +import '../auth/auth_session.dart'; +import 'docker_container.dart'; + +class DockerSocketClient { + DockerSocketClient({ + required AuthSession authSession, + required SessionCookieStore cookieStore, + required this.hostId, + }) : _authSession = authSession, + _cookieStore = cookieStore; + + final AuthSession _authSession; + final SessionCookieStore _cookieStore; + final String hostId; + + final StreamController> _containersController = + StreamController>.broadcast(); + final StreamController _logsController = + StreamController.broadcast(); + final StreamController _operationController = + StreamController.broadcast(); + final StreamController _errorController = + StreamController.broadcast(); + final StreamController _connectedController = + StreamController.broadcast(); + + sio.Socket? _socket; + List _serverCandidates = const []; + int _candidateIndex = 0; + bool _disposed = false; + + Stream> get containersStream => + _containersController.stream; + Stream get logsStream => _logsController.stream; + Stream get operationStream => + _operationController.stream; + Stream get errorStream => _errorController.stream; + Stream get connectedStream => _connectedController.stream; + + bool get connected => _socket?.connected ?? false; + + Future connect() async { + if (_disposed) return; + disconnect(closeStreams: false); + final cookie = await _cookieStore.readCookieHeader(); + _serverCandidates = _buildServerCandidates(_authSession.serverAddress); + _candidateIndex = 0; + if (cookie == null || cookie.isEmpty) { + _emitError('No Cookie'); + return; + } + + _connectCandidate(cookie); + } + + void _connectCandidate(String cookie) { + if (_disposed || _candidateIndex >= _serverCandidates.length) return; + final serverAddress = _serverCandidates[_candidateIndex]; + + final socket = sio.io( + serverAddress, + sio.OptionBuilder() + .setTransports(['websocket']) + .setPath('/docker/') + .setAuth({'token': _authSession.token}) + .setExtraHeaders({'Cookie': cookie, 'Origin': _authSession.serverAddress}) + .disableAutoConnect() + .disableReconnection() + .build(), + ); + + socket.onConnect((_) { + _connectedController.add(true); + socket.emit('ws_docker', {'hostId': hostId}); + }); + + socket.onConnectError((error) { + final message = _stringifyError(error); + socket.dispose(); + if (_tryNextCandidate(cookie)) return; + _emitError(message); + }); + + socket.onError((error) { + _emitError(_stringifyError(error)); + }); + + socket.onDisconnect((_) { + if (!_disposed) _connectedController.add(false); + }); + + socket.on('docker_containers_data', (data) { + if (data is! List) return; + _containersController.add( + data + .whereType() + .map( + (item) => DockerContainer.fromJson( + item.map((key, value) => MapEntry(key.toString(), value)), + ), + ) + .toList(growable: false), + ); + }); + + socket.on('docker_containers_logs', (data) { + _logsController.add(data?.toString() ?? ''); + }); + + socket.on('docker_operation_result', (data) { + _operationController.add(DockerOperationResult.fromJson(data)); + }); + + socket.on('docker_connect_fail', (_) { + _emitError('docker_connect_fail'); + }); + + socket.on('docker_not_plus', (_) { + _emitError('docker_not_plus'); + }); + + _socket = socket; + socket.connect(); + } + + bool _tryNextCandidate(String cookie) { + final hasNext = _candidateIndex + 1 < _serverCandidates.length; + if (!hasNext) return false; + _candidateIndex++; + _connectCandidate(cookie); + return true; + } + + void refresh() { + _socket?.emit('docker_get_containers_data'); + } + + void getLogs(String containerId, {int tail = 2000}) { + _socket?.emit('docker_get_containers_logs', { + 'containerId': containerId, + 'tail': tail, + }); + } + + void start(String containerId) => + _socket?.emit('docker_start_container', {'containerId': containerId}); + + void stop(String containerId) => + _socket?.emit('docker_stop_container', {'containerId': containerId}); + + void restart(String containerId) => + _socket?.emit('docker_restart_container', {'containerId': containerId}); + + void delete(String containerId) => + _socket?.emit('docker_delete_container', {'containerId': containerId}); + + void _emitError(String message) { + if (!_disposed) _errorController.add(message); + } + + String _stringifyError(Object? error) { + if (error == null) return 'Connection error'; + if (error is Map) { + final message = error['message'] ?? error['error'] ?? error['data']; + return message?.toString() ?? error.toString(); + } + return error.toString(); + } + + List _buildServerCandidates(String serverAddress) { + if (!kDebugMode) return [serverAddress]; + final parsed = Uri.tryParse(serverAddress); + if (parsed == null || parsed.host.isEmpty) return [serverAddress]; + final candidates = []; + if (parsed.scheme == 'http' && parsed.port != 8082) { + candidates.add(parsed.replace(port: 8082).toString()); + } + if (parsed.scheme == 'https' && parsed.port != 8092) { + candidates.add(parsed.replace(port: 8092).toString()); + } + candidates.add(serverAddress); + return LinkedHashSet.from(candidates).toList(growable: false); + } + + void disconnect({bool closeStreams = true}) { + final socket = _socket; + _socket = null; + socket?.dispose(); + if (closeStreams) { + _disposed = true; + _containersController.close(); + _logsController.close(); + _operationController.close(); + _errorController.close(); + _connectedController.close(); + } + } +} diff --git a/native/lib/features/docker/docker_tab.dart b/native/lib/features/docker/docker_tab.dart new file mode 100644 index 0000000..8eb8e97 --- /dev/null +++ b/native/lib/features/docker/docker_tab.dart @@ -0,0 +1,1818 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../core/api/api_result.dart'; +import '../../core/ui/app_color_theme.dart'; +import '../../core/ui/refresh_feedback.dart'; +import '../../l10n/app_localizations.dart'; +import '../../state/auth_notifier.dart'; +import '../../state/docker_providers.dart'; +import '../../state/host_list_notifier.dart'; +import '../../state/plus_info_notifier.dart'; +import '../../state/storage_providers.dart'; +import '../servers/server_model.dart'; +import '../shell/tab_header.dart'; +import 'docker_container.dart'; +import 'docker_icon.dart'; +import 'docker_session_manager.dart'; + +class DockerTab extends StatelessWidget { + const DockerTab({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: context.colors.canvas, + body: const DockerPanel(showHeader: true), + ); + } +} + +class DockerPanel extends ConsumerStatefulWidget { + const DockerPanel({ + super.key, + this.showHeader = false, + this.initialHostId, + this.lockToHost = false, + this.allowDisconnect = true, + }); + + final bool showHeader; + final String? initialHostId; + final bool lockToHost; + final bool allowDisconnect; + + @override + ConsumerState createState() => _DockerPanelState(); +} + +class _DockerPanelState extends ConsumerState { + bool _connecting = false; + String? _autoConnectAttemptedHostId; + + Future _refreshHosts() => runRefreshWithFeedback( + context, + () => ref.read(hostListProvider.notifier).refresh(throwOnError: true), + ); + + Future _openServerPicker() async { + final manager = ref.read(dockerSessionManagerProvider); + final selected = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + barrierColor: Colors.black.withValues(alpha: 0.34), + builder: (_) => _DockerServerPickerSheet( + connectedServerIds: manager.sessions + .where( + (session) => session.status == DockerConnectionStatus.connected, + ) + .map((session) => session.server.id) + .toSet(), + activeServerId: manager.activeHostId, + onRefresh: _refreshHosts, + ), + ); + if (selected == null || !mounted) return; + await _connectOrActivate(selected); + } + + Future _connectOrActivate(ServerModel server) async { + final l = AppLocalizations.of(context); + final manager = ref.read(dockerSessionManagerProvider); + if (manager.isConnected(server.id)) { + manager.activate(server.id); + return; + } + if (server.isWindows) { + _showSnack(l.tr('servers.windowsUnsupported')); + return; + } + if (!server.canConnect) { + _showSnack(l.tr('servers.notConfigured')); + return; + } + if (!ref.read(isPlusActiveProvider)) { + _showSnack(l.tr('docker.plusRequired')); + return; + } + final authSession = ref.read(authProvider).session; + if (authSession == null) return; + + setState(() => _connecting = true); + try { + await manager.connect( + server: server, + authSession: authSession, + cookieStore: ref.read(cookieStoreProvider), + ); + } catch (error) { + if (!mounted) return; + if (error is UnauthorizedFailure) { + await ref.read(authProvider.notifier).signOut(); + return; + } + _showSnack(error.toString()); + } finally { + if (mounted) setState(() => _connecting = false); + } + } + + void _showSnack(String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context); + final hostsAsync = ref.watch(hostListProvider); + final manager = ref.watch(dockerSessionManagerProvider); + final isPlusActive = ref.watch(isPlusActiveProvider); + + return AnimatedBuilder( + animation: manager, + builder: (context, _) { + final session = manager.activeSession; + final showSelector = + !widget.lockToHost && + !_connecting && + session != null && + session.status != DockerConnectionStatus.connecting; + return Column( + children: [ + if (widget.showHeader) + TabHeader( + title: l.tr('tabs.docker'), + actions: showSelector + ? [ + _DockerHeaderSelector( + session: session, + onTap: _openServerPicker, + onDisconnect: widget.allowDisconnect + ? () => manager.disconnectActive() + : null, + ), + ] + : const [], + ), + Expanded( + child: RefreshIndicator( + color: context.colors.primary, + backgroundColor: context.colors.card, + onRefresh: () async { + if (manager.activeSession == null) { + await _refreshHosts(); + } else { + manager.reconnectActive(); + } + }, + child: hostsAsync.when( + loading: () => Center( + child: CircularProgressIndicator( + color: context.colors.primary, + ), + ), + error: (error, _) { + if (error is UnauthorizedFailure) { + return const SizedBox.shrink(); + } + return _DockerMessageList( + message: error.toString(), + action: TextButton( + onPressed: _refreshHosts, + child: Text(l.tr('common.retry')), + ), + ); + }, + data: (servers) { + _autoConnectInitialServer(servers); + if (!isPlusActive) { + return _DockerMessageList( + message: l.tr('docker.plusRequired'), + icon: Icons.workspace_premium_outlined, + ); + } + if (_connecting || + session?.status == DockerConnectionStatus.connecting) { + return _DockerConnectingView( + server: session?.server, + onCancel: session != null + ? () => manager.disconnectActive() + : null, + ); + } + if (session == null) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 72, 16, 110), + children: [ + SizedBox( + height: MediaQuery.sizeOf(context).height * 0.58, + child: Center( + child: _DockerEmptyCard( + onChooseServer: _openServerPicker, + ), + ), + ), + ], + ); + } + return _DockerContainerList( + session: session, + manager: manager, + onRefresh: manager.reconnectActive, + onOpenPort: _openPort, + onShowLogs: _showLogs, + ); + }, + ), + ), + ), + ], + ); + }, + ); + } + + void _autoConnectInitialServer(List servers) { + final hostId = widget.initialHostId; + if (hostId == null || _autoConnectAttemptedHostId == hostId) return; + final server = servers.where((item) => item.id == hostId).firstOrNull; + if (server == null) return; + _autoConnectAttemptedHostId = hostId; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _connectOrActivate(server); + }); + } + + Future _openPort(ServerModel server, String port) async { + final match = RegExp(r':(\d+)->').firstMatch(port); + if (match == null) { + _showSnack(AppLocalizations.of(context).tr('docker.openPortFailed')); + return; + } + final url = Uri.parse('http://${server.host}:${match.group(1)}'); + await launchUrl(url, mode: LaunchMode.externalApplication); + } + + Future _showLogs(DockerContainer container) async { + final manager = ref.read(dockerSessionManagerProvider); + manager.getLogs(container); + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + barrierColor: Colors.black.withValues(alpha: 0.34), + builder: (_) => _DockerLogsSheet(container: container), + ); + } +} + +class _DockerContainerList extends StatefulWidget { + const _DockerContainerList({ + required this.session, + required this.manager, + required this.onRefresh, + required this.onOpenPort, + required this.onShowLogs, + }); + + final DockerSessionState session; + final DockerSessionManager manager; + final VoidCallback onRefresh; + final Future Function(ServerModel server, String port) onOpenPort; + final Future Function(DockerContainer container) onShowLogs; + + @override + State<_DockerContainerList> createState() => _DockerContainerListState(); +} + +class _DockerContainerListState extends State<_DockerContainerList> { + final Set _selected = {}; + Timer? _autoRefreshTimer; + + @override + void initState() { + super.initState(); + _autoRefreshTimer = Timer.periodic( + const Duration(milliseconds: 3500), + (_) { + if (_selected.isNotEmpty) return; + final s = widget.session; + if (s.loading || s.status != DockerConnectionStatus.connected) return; + s.client.refresh(); + }, + ); + } + + @override + void dispose() { + _autoRefreshTimer?.cancel(); + super.dispose(); + } + + bool get _allSelected => + widget.session.containers.isNotEmpty && + _selected.length == widget.session.containers.length; + + bool get _hasSelection => _selected.isNotEmpty; + + void _toggleAll() { + setState(() { + if (_allSelected) { + _selected.clear(); + } else { + _selected + ..clear() + ..addAll(widget.session.containers.map((c) => c.id)); + } + }); + } + + void _toggle(String id) { + setState(() { + if (!_selected.remove(id)) _selected.add(id); + }); + } + + List get _selectedContainers => widget.session.containers + .where((c) => _selected.contains(c.id)) + .toList(growable: false); + + void _batchStart() { + for (final c in _selectedContainers) { + if (!c.isRunning) widget.manager.start(c); + } + } + + void _batchRestart() { + for (final c in _selectedContainers) { + if (c.isRunning) widget.manager.restart(c); + } + } + + void _batchStop() { + for (final c in _selectedContainers) { + if (c.isRunning) widget.manager.stop(c); + } + } + + Future _batchDelete() async { + final count = _selected.length; + final l = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l.trf('docker.batchDeleteTitle', ['$count'])), + content: Text(l.trf('docker.batchDeleteBody', ['$count'])), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l.tr('common.cancel')), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: context.colors.danger, + foregroundColor: Colors.white, + ), + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l.tr('docker.batchDelete')), + ), + ], + ), + ); + if (confirmed != true) return; + for (final c in _selectedContainers) { + widget.manager.delete(c); + } + setState(() => _selected.clear()); + } + + @override + void didUpdateWidget(covariant _DockerContainerList oldWidget) { + super.didUpdateWidget(oldWidget); + final currentIds = widget.session.containers.map((c) => c.id).toSet(); + _selected.removeWhere((id) => !currentIds.contains(id)); + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context); + final session = widget.session; + if (session.status == DockerConnectionStatus.error) { + final message = session.errorMessage == 'docker_not_plus' + ? l.tr('docker.notPlus') + : session.errorMessage == 'docker_connect_fail' + ? l.tr('docker.connectFailed') + : session.errorMessage ?? l.tr('docker.connectFailed'); + return _DockerMessageList( + message: message, + icon: Icons.error_outline_rounded, + action: TextButton( + onPressed: widget.onRefresh, + child: Text(l.tr('common.retry')), + ), + ); + } + if (session.loading) { + return Center( + child: CircularProgressIndicator(color: context.colors.primary), + ); + } + if (session.containers.isEmpty) { + return _DockerMessageList( + message: l.tr('docker.noContainers'), + icon: Icons.inventory_2_outlined, + action: TextButton.icon( + onPressed: widget.onRefresh, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: Text(l.tr('docker.refresh')), + ), + ); + } + return Column( + children: [ + _DockerToolbar( + allSelected: _allSelected, + indeterminate: _hasSelection && !_allSelected, + selectedCount: _selected.length, + refreshing: session.refreshing, + operating: session.operatingIds.isNotEmpty, + onToggleAll: _toggleAll, + onRefresh: widget.onRefresh, + onBatchStart: _hasSelection ? _batchStart : null, + onBatchRestart: _hasSelection ? _batchRestart : null, + onBatchStop: _hasSelection ? _batchStop : null, + onBatchDelete: _hasSelection ? _batchDelete : null, + ), + Expanded( + child: ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 4, 16, 110), + itemBuilder: (context, index) { + final container = session.containers[index]; + return _DockerContainerCard( + server: session.server, + container: container, + operating: session.operatingIds.contains(container.id), + selected: _selected.contains(container.id), + onSelect: () => _toggle(container.id), + onStart: () => widget.manager.start(container), + onStop: () => widget.manager.stop(container), + onRestart: () => widget.manager.restart(container), + onDelete: () => _confirmDelete(context, container), + onLogs: () => widget.onShowLogs(container), + onOpenPort: (port) => + widget.onOpenPort(session.server, port), + ); + }, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemCount: session.containers.length, + ), + ), + ], + ); + } + + Future _confirmDelete( + BuildContext context, + DockerContainer container, + ) async { + final l = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l.tr('docker.deleteTitle')), + content: Text(l.trf('docker.deleteBody', [container.name])), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l.tr('common.cancel')), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: context.colors.danger, + foregroundColor: Colors.white, + ), + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l.tr('docker.delete')), + ), + ], + ), + ); + if (confirmed == true) widget.manager.delete(container); + } +} + +class _DockerToolbar extends StatelessWidget { + const _DockerToolbar({ + required this.allSelected, + required this.indeterminate, + required this.selectedCount, + required this.refreshing, + required this.operating, + required this.onToggleAll, + required this.onRefresh, + required this.onBatchStart, + required this.onBatchRestart, + required this.onBatchStop, + required this.onBatchDelete, + }); + + final bool allSelected; + final bool indeterminate; + final int selectedCount; + final bool refreshing; + final bool operating; + final VoidCallback onToggleAll; + final VoidCallback onRefresh; + final VoidCallback? onBatchStart; + final VoidCallback? onBatchRestart; + final VoidCallback? onBatchStop; + final VoidCallback? onBatchDelete; + + @override + Widget build(BuildContext context) { + final c = context.colors; + final l = AppLocalizations.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 10, 2), + child: Row( + children: [ + SizedBox( + width: 24, + height: 24, + child: Checkbox( + value: allSelected ? true : (indeterminate ? null : false), + tristate: true, + onChanged: (_) => onToggleAll(), + activeColor: c.primary, + side: BorderSide(color: c.strongBorder, width: 1.8), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ), + const SizedBox(width: 6), + GestureDetector( + onTap: onToggleAll, + child: Text( + selectedCount > 0 + ? l.trf('docker.selectedCount', ['$selectedCount']) + : l.tr('docker.selectAll'), + style: TextStyle( + color: selectedCount > 0 ? c.primary : c.muted, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + const Spacer(), + if (selectedCount > 0) ...[ + if (operating) + Padding( + padding: const EdgeInsets.only(right: 8), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: c.primary, + ), + ), + ), + _DockerBatchButton( + icon: Icons.play_arrow_rounded, + color: c.success, + tooltip: l.tr('docker.batchStart'), + onTap: operating ? null : onBatchStart, + ), + const SizedBox(width: 4), + _DockerBatchButton( + icon: Icons.restart_alt_rounded, + color: c.primary, + tooltip: l.tr('docker.batchRestart'), + onTap: operating ? null : onBatchRestart, + ), + const SizedBox(width: 4), + _DockerBatchButton( + icon: Icons.pause_rounded, + color: c.warning, + tooltip: l.tr('docker.batchStop'), + onTap: operating ? null : onBatchStop, + ), + const SizedBox(width: 4), + _DockerBatchButton( + icon: Icons.delete_outline_rounded, + color: c.danger, + tooltip: l.tr('docker.batchDelete'), + onTap: operating ? null : onBatchDelete, + ), + const SizedBox(width: 6), + ], + SizedBox( + width: 34, + height: 34, + child: IconButton( + onPressed: refreshing ? null : onRefresh, + padding: EdgeInsets.zero, + tooltip: l.tr('docker.refresh'), + icon: refreshing + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: c.primary, + ), + ) + : Icon(Icons.refresh_rounded, size: 22, color: c.primary), + ), + ), + ], + ), + ); + } +} + +class _DockerBatchButton extends StatelessWidget { + const _DockerBatchButton({ + required this.icon, + required this.color, + required this.tooltip, + required this.onTap, + }); + + final IconData icon; + final Color color; + final String tooltip; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: Material( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon(icon, size: 18, color: color), + ), + ), + ), + ); + } +} + +class _DockerContainerCard extends StatelessWidget { + const _DockerContainerCard({ + required this.server, + required this.container, + required this.operating, + required this.selected, + required this.onSelect, + required this.onStart, + required this.onStop, + required this.onRestart, + required this.onDelete, + required this.onLogs, + required this.onOpenPort, + }); + + final ServerModel server; + final DockerContainer container; + final bool operating; + final bool selected; + final VoidCallback onSelect; + final VoidCallback onStart; + final VoidCallback onStop; + final VoidCallback onRestart; + final VoidCallback onDelete; + final VoidCallback onLogs; + final ValueChanged onOpenPort; + + @override + Widget build(BuildContext context) { + final c = context.colors; + final l = AppLocalizations.of(context); + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: c.card, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: selected ? c.primary.withValues(alpha: 0.5) : c.border, + ), + boxShadow: [ + if (selected) + BoxShadow( + color: c.primary.withValues(alpha: 0.10), + blurRadius: 16, + offset: const Offset(0, 8), + ) + else + BoxShadow( + color: c.primary.withValues(alpha: 0.05), + blurRadius: 16, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SizedBox( + width: 22, + height: 22, + child: Checkbox( + value: selected, + onChanged: (_) => onSelect(), + activeColor: c.primary, + side: BorderSide(color: c.strongBorder, width: 1.8), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ), + const SizedBox(width: 8), + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: c.banner, + borderRadius: BorderRadius.circular(13), + ), + child: IconTheme( + data: IconThemeData(color: c.primary, size: 24), + child: const DockerIcon(), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + container.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: c.text, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 3), + Text( + container.image, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: c.softMuted, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + _DockerStatusPill(status: container.status), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _DockerInfoChip(label: 'ID', value: container.shortId), + if (container.uptime.isNotEmpty) + _DockerInfoChip(label: 'UP', value: container.uptime), + ], + ), + if (container.ports.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final port in container.ports) + _DockerPortChip( + port: port, + mapped: port.contains('->'), + onTap: port.contains('->') ? () => onOpenPort(port) : null, + ), + ], + ), + ], + const SizedBox(height: 14), + Row( + children: [ + _DockerCircleAction( + tooltip: l.tr('docker.start'), + icon: Icons.play_arrow_rounded, + color: c.success, + enabled: !container.isRunning && !operating, + onTap: onStart, + ), + const SizedBox(width: 10), + _DockerCircleAction( + tooltip: l.tr('docker.restart'), + icon: Icons.restart_alt_rounded, + color: c.primary, + enabled: container.isRunning && !operating, + onTap: onRestart, + ), + const SizedBox(width: 10), + _DockerCircleAction( + tooltip: l.tr('docker.stop'), + icon: Icons.pause_rounded, + color: c.warning, + enabled: container.isRunning && !operating, + onTap: onStop, + ), + const SizedBox(width: 10), + _DockerCircleAction( + tooltip: l.tr('docker.delete'), + icon: Icons.delete_outline_rounded, + color: c.danger, + enabled: !operating, + onTap: onDelete, + ), + const SizedBox(width: 10), + _DockerCircleAction( + tooltip: l.tr('docker.logs'), + icon: Icons.article_outlined, + color: c.muted, + enabled: !operating, + onTap: onLogs, + ), + if (operating) ...[ + const Spacer(), + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: c.primary, + ), + ), + ], + ], + ), + ], + ), + ); + } +} + +class _DockerHeaderSelector extends StatelessWidget { + const _DockerHeaderSelector({ + required this.session, + required this.onTap, + required this.onDisconnect, + }); + + final DockerSessionState session; + final VoidCallback onTap; + final VoidCallback? onDisconnect; + + @override + Widget build(BuildContext context) { + final c = context.colors; + final isError = session.status == DockerConnectionStatus.error; + final dotColor = isError ? c.danger : c.success; + final screenWidth = MediaQuery.sizeOf(context).width; + return ConstrainedBox( + constraints: BoxConstraints( + minWidth: screenWidth / 4, + maxWidth: screenWidth / 2, + ), + child: Material( + color: c.card, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: c.strongBorder), + ), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onTap, + child: SizedBox( + height: 34, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: dotColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + session.server.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: c.text, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + ), + Icon( + Icons.keyboard_arrow_down_rounded, + size: 16, + color: c.muted, + ), + if (onDisconnect != null) + InkResponse( + radius: 14, + onTap: onDisconnect, + child: Icon( + Icons.close_rounded, + size: 14, + color: c.muted, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _DockerEmptyCard extends StatelessWidget { + const _DockerEmptyCard({required this.onChooseServer}); + + final VoidCallback onChooseServer; + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context); + final c = context.colors; + return Container( + width: double.infinity, + constraints: const BoxConstraints(maxWidth: 420, minHeight: 348), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const _DockerOrbitIcon(), + const SizedBox(height: 18), + SizedBox( + height: 24, + child: Text( + l.tr('docker.emptyTitle'), + textAlign: TextAlign.center, + style: TextStyle( + color: c.text, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + height: 40, + child: Text( + l.tr('docker.emptyBody'), + textAlign: TextAlign.center, + style: TextStyle( + color: c.softMuted, + fontSize: 13, + fontWeight: FontWeight.w600, + height: 1.5, + ), + ), + ), + const SizedBox(height: 18), + _DockerPrimaryButton( + icon: const DockerIcon(size: 16), + label: l.tr('docker.chooseServer'), + trailing: Icons.keyboard_arrow_down_rounded, + onTap: onChooseServer, + ), + ], + ), + ); + } +} + +class _DockerOrbitIcon extends StatelessWidget { + const _DockerOrbitIcon(); + + @override + Widget build(BuildContext context) { + final c = context.colors; + return SizedBox( + width: 120, + height: 120, + child: Stack( + children: [ + Positioned( + left: 6, + top: 14, + child: _DockerDot(size: 10, color: c.banner), + ), + Positioned( + right: 12, + top: 8, + child: _DockerDot(size: 8, color: c.accent), + ), + Positioned( + right: 4, + bottom: 12, + child: _DockerDot(size: 12, color: c.banner), + ), + const Positioned( + left: 0, + bottom: 24, + child: _DockerDot(size: 6, color: Color(0xFFEFD9A2)), + ), + Center( + child: Container( + width: 92, + height: 92, + decoration: BoxDecoration( + color: c.banner, + borderRadius: BorderRadius.circular(28), + border: Border.all(color: c.accent.withValues(alpha: 0.4)), + ), + child: IconTheme( + data: IconThemeData(color: c.primary, size: 42), + child: const DockerIcon(), + ), + ), + ), + ], + ), + ); + } +} + +class _DockerDot extends StatelessWidget { + const _DockerDot({required this.size, required this.color}); + + final double size; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ); + } +} + +class _DockerPrimaryButton extends StatelessWidget { + const _DockerPrimaryButton({ + required this.icon, + required this.label, + required this.onTap, + this.trailing, + }); + + final Widget icon; + final String label; + final IconData? trailing; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return Material( + color: c.primary, + borderRadius: BorderRadius.circular(10), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconTheme( + data: IconThemeData(size: 16, color: c.fontOnPrimary), + child: icon, + ), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + color: c.fontOnPrimary, + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + if (trailing != null) ...[ + const SizedBox(width: 8), + Icon(trailing, size: 16, color: c.fontOnPrimary), + ], + ], + ), + ), + ), + ); + } +} + +class _DockerConnectingView extends StatelessWidget { + const _DockerConnectingView({required this.server, this.onCancel}); + + final ServerModel? server; + final VoidCallback? onCancel; + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context); + final c = context.colors; + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 96, 16, 110), + children: [ + Center( + child: Column( + children: [ + CircularProgressIndicator(color: c.primary), + const SizedBox(height: 16), + Text( + l.tr('docker.connecting'), + style: TextStyle(color: c.text, fontWeight: FontWeight.w800), + ), + if (server != null) ...[ + const SizedBox(height: 6), + Text(server!.displayName, style: TextStyle(color: c.softMuted)), + ], + if (onCancel != null) ...[ + const SizedBox(height: 20), + TextButton.icon( + onPressed: onCancel, + icon: const Icon(Icons.close_rounded, size: 18), + label: Text(l.tr('common.cancel')), + ), + ], + ], + ), + ), + ], + ); + } +} + +class _DockerMessageList extends StatelessWidget { + const _DockerMessageList({required this.message, this.action, this.icon}); + + final String message; + final Widget? action; + final IconData? icon; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(24, 96, 24, 110), + children: [ + if (icon != null) ...[ + Icon(icon, size: 44, color: c.softMuted), + const SizedBox(height: 12), + ], + Text( + message, + textAlign: TextAlign.center, + style: TextStyle(color: c.muted, fontWeight: FontWeight.w600), + ), + if (action != null) ...[ + const SizedBox(height: 10), + Center(child: action), + ], + ], + ); + } +} + +class _DockerStatusPill extends StatelessWidget { + const _DockerStatusPill({required this.status}); + + final String status; + + @override + Widget build(BuildContext context) { + final c = context.colors; + final color = switch (status) { + 'running' => c.success, + 'exited' => c.danger, + 'paused' => c.warning, + _ => c.softMuted, + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.36)), + ), + child: Text( + status.isEmpty ? '--' : status, + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.w800, + ), + ), + ); + } +} + +class _DockerInfoChip extends StatelessWidget { + const _DockerInfoChip({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 6), + decoration: BoxDecoration( + color: c.chip, + borderRadius: BorderRadius.circular(9), + border: Border.all(color: c.border), + ), + child: Text( + '$label $value', + style: TextStyle( + color: c.muted, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _DockerPortChip extends StatelessWidget { + const _DockerPortChip({required this.port, required this.mapped, this.onTap}); + + final String port; + final bool mapped; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return Material( + color: mapped ? c.accentSoft : c.chip, + borderRadius: BorderRadius.circular(9), + child: InkWell( + borderRadius: BorderRadius.circular(9), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (mapped) ...[ + Icon(Icons.open_in_new_rounded, size: 12, color: c.primary), + const SizedBox(width: 4), + ], + Text( + port, + style: TextStyle( + color: mapped ? c.primary : c.muted, + fontSize: 11, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _DockerCircleAction extends StatelessWidget { + const _DockerCircleAction({ + required this.tooltip, + required this.icon, + required this.color, + required this.onTap, + this.enabled = true, + }); + + final String tooltip; + final IconData icon; + final Color color; + final VoidCallback onTap; + final bool enabled; + + @override + Widget build(BuildContext context) { + final c = context.colors; + final fg = enabled ? color : c.softMuted; + final border = enabled ? color.withValues(alpha: 0.36) : c.border; + return Tooltip( + message: tooltip, + child: Material( + color: Colors.transparent, + shape: CircleBorder(side: BorderSide(color: border)), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: enabled ? onTap : null, + customBorder: const CircleBorder(), + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(icon, size: 18, color: fg), + ), + ), + ), + ); + } +} + +class _DockerLogsSheet extends ConsumerStatefulWidget { + const _DockerLogsSheet({required this.container}); + + final DockerContainer container; + + @override + ConsumerState<_DockerLogsSheet> createState() => _DockerLogsSheetState(); +} + +class _DockerLogsSheetState extends ConsumerState<_DockerLogsSheet> { + late final DockerSessionManager _manager; + Timer? _autoRefreshTimer; + ScrollController? _scrollController; + bool _initialScrollDone = false; + + @override + void initState() { + super.initState(); + _manager = ref.read(dockerSessionManagerProvider); + _manager.addListener(_onLogsChanged); + _autoRefreshTimer = Timer.periodic(const Duration(seconds: 3), (_) { + if (_manager.activeSession?.status != DockerConnectionStatus.connected) { + return; + } + _manager.refreshLogs(widget.container); + }); + } + + @override + void dispose() { + _manager.removeListener(_onLogsChanged); + _autoRefreshTimer?.cancel(); + super.dispose(); + } + + void _onLogsChanged() { + if (_initialScrollDone) return; + final logs = _manager.activeSession?.logs ?? ''; + if (logs.trim().isEmpty) return; + _initialScrollDone = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + final c = _scrollController; + if (!mounted || c == null || !c.hasClients) return; + c.jumpTo(c.position.maxScrollExtent); + }); + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context); + final c = context.colors; + return SafeArea( + top: false, + child: DraggableScrollableSheet( + initialChildSize: 0.76, + minChildSize: 0.42, + maxChildSize: 0.92, + expand: false, + builder: (context, scrollController) { + _scrollController = scrollController; + return Container( + decoration: BoxDecoration( + color: c.canvas, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20), + ), + border: Border(top: BorderSide(color: c.border)), + ), + child: Column( + children: [ + const SizedBox(height: 8), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: c.strongBorder, + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(18, 14, 12, 10), + child: Row( + children: [ + Expanded( + child: Text( + l.trf('docker.logsTitle', [widget.container.name]), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: c.text, + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + onPressed: () => _manager.getLogs(widget.container), + icon: const Icon(Icons.refresh_rounded), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close_rounded), + ), + ], + ), + ), + Expanded( + child: AnimatedBuilder( + animation: _manager, + builder: (context, _) { + final logs = _manager.activeSession?.logs ?? ''; + return Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB(12, 0, 12, 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.82), + borderRadius: BorderRadius.circular(14), + ), + child: SingleChildScrollView( + controller: scrollController, + child: SelectableText( + logs.trim().isEmpty + ? l.tr('docker.noLogs') + : logs, + style: const TextStyle( + color: Color(0xFFEDE7DB), + fontFamily: 'monospace', + fontSize: 12, + height: 1.45, + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + }, + ), + ); + } +} + +class _DockerServerPickerSheet extends ConsumerStatefulWidget { + const _DockerServerPickerSheet({ + required this.connectedServerIds, + required this.activeServerId, + required this.onRefresh, + }); + + final Set connectedServerIds; + final String? activeServerId; + final Future Function() onRefresh; + + @override + ConsumerState<_DockerServerPickerSheet> createState() => + _DockerServerPickerSheetState(); +} + +class _DockerServerPickerSheetState + extends ConsumerState<_DockerServerPickerSheet> { + final TextEditingController _searchCtrl = TextEditingController(); + String _query = ''; + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final hostsAsync = ref.watch(hostListProvider); + final l = AppLocalizations.of(context); + final c = context.colors; + return SafeArea( + top: false, + child: DraggableScrollableSheet( + initialChildSize: 0.64, + minChildSize: 0.38, + maxChildSize: 0.86, + expand: false, + builder: (context, scrollController) => Container( + decoration: BoxDecoration( + color: c.canvas, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + border: Border(top: BorderSide(color: c.border)), + ), + child: Column( + children: [ + const SizedBox(height: 8), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: c.strongBorder, + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l.tr('docker.sheetTitle'), + style: TextStyle( + color: c.text, + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 3), + Text( + l.tr('docker.sheetSubtitle'), + style: TextStyle( + color: c.softMuted, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close_rounded), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: SizedBox( + height: 42, + child: TextField( + controller: _searchCtrl, + cursorColor: c.primary, + decoration: InputDecoration( + hintText: l.tr('docker.searchHint'), + prefixIcon: const Icon(Icons.search, size: 19), + isDense: true, + filled: true, + fillColor: c.card, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + onChanged: (value) => + setState(() => _query = value.trim().toLowerCase()), + ), + ), + ), + Expanded( + child: hostsAsync.when( + loading: () => Center( + child: CircularProgressIndicator(color: c.primary), + ), + error: (error, _) => ListView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), + children: [ + Text(error.toString(), textAlign: TextAlign.center), + Center( + child: TextButton( + onPressed: widget.onRefresh, + child: Text(l.tr('common.retry')), + ), + ), + ], + ), + data: (servers) => _buildServerList( + context, + scrollController, + _filterServers(servers), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildServerList( + BuildContext context, + ScrollController scrollController, + List servers, + ) { + final l = AppLocalizations.of(context); + if (servers.isEmpty) { + return ListView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(20, 36, 20, 24), + children: [ + Text( + l.tr('servers.emptyFiltered'), + textAlign: TextAlign.center, + style: TextStyle(color: context.colors.muted), + ), + ], + ); + } + final connected = servers + .where((server) => widget.connectedServerIds.contains(server.id)) + .toList(growable: false); + final disconnected = servers + .where((server) => !widget.connectedServerIds.contains(server.id)) + .toList(growable: false); + return ListView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + children: [ + if (connected.isNotEmpty) ...[ + _DockerSectionLabel(l.tr('docker.connectedSection')), + for (final server in connected) + _DockerServerRow( + server: server, + connected: true, + active: server.id == widget.activeServerId, + onTap: () => Navigator.of(context).pop(server), + ), + ], + if (disconnected.isNotEmpty) ...[ + _DockerSectionLabel(l.tr('docker.disconnectedSection')), + for (final server in disconnected) + _DockerServerRow( + server: server, + connected: false, + active: false, + onTap: () => Navigator.of(context).pop(server), + ), + ], + ], + ); + } + + List _filterServers(List servers) { + final filtered = servers.where((server) => !server.isWindows).toList(); + if (_query.isEmpty) return filtered; + return filtered + .where((server) { + final haystack = [ + server.displayName, + server.host, + server.username, + ...server.tag, + ].join(' ').toLowerCase(); + return haystack.contains(_query); + }) + .toList(growable: false); + } +} + +class _DockerSectionLabel extends StatelessWidget { + const _DockerSectionLabel(this.label); + + final String label; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(4, 10, 4, 6), + child: Text( + label, + style: TextStyle( + color: context.colors.softMuted, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ); + } +} + +class _DockerServerRow extends StatelessWidget { + const _DockerServerRow({ + required this.server, + required this.connected, + required this.active, + required this.onTap, + }); + + final ServerModel server; + final bool connected; + final bool active; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final c = context.colors; + final l = AppLocalizations.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Material( + color: active ? c.accentSoft : c.card, + borderRadius: BorderRadius.circular(14), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: c.banner, + borderRadius: BorderRadius.circular(12), + ), + child: IconTheme( + data: IconThemeData(color: c.primary, size: 20), + child: const DockerIcon(), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + server.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: c.text, + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 3), + Text( + server.connectionLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: c.softMuted, fontSize: 12), + ), + ], + ), + ), + Text( + connected + ? l.tr('docker.statusConnected') + : l.tr('docker.statusDisconnected'), + style: TextStyle( + color: connected ? c.success : c.softMuted, + fontSize: 11, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/native/lib/features/shell/main_shell_page.dart b/native/lib/features/shell/main_shell_page.dart index be9b3ea..1351e8d 100644 --- a/native/lib/features/shell/main_shell_page.dart +++ b/native/lib/features/shell/main_shell_page.dart @@ -11,6 +11,8 @@ import '../../state/app_update_notifier.dart'; import '../../state/auth_notifier.dart'; import '../../state/plus_info_notifier.dart'; import '../../state/terminal_providers.dart'; +import '../docker/docker_icon.dart'; +import '../docker/docker_tab.dart'; import '../servers/servers_tab.dart'; import 'scripts_tab.dart'; import 'settings_tab.dart'; @@ -34,6 +36,7 @@ class _MainShellPageState extends ConsumerState { static const _tabs = [ ServersTab(), SftpTab(), + DockerTab(), ScriptsTab(), SettingsTab(), ]; @@ -91,19 +94,23 @@ class _MainShellPageState extends ConsumerState { onSelected: (i) => setState(() => _index = i), items: [ _WarmBottomBarItem( - icon: Icons.monitor_outlined, + icon: const Icon(Icons.monitor_outlined), label: l.tr('tabs.servers'), ), _WarmBottomBarItem( - icon: Icons.folder_outlined, + icon: const Icon(Icons.folder_outlined), label: l.tr('tabs.sftp'), ), _WarmBottomBarItem( - icon: Icons.article_outlined, + icon: const DockerIcon(), + label: l.tr('tabs.docker'), + ), + _WarmBottomBarItem( + icon: const Icon(Icons.article_outlined), label: l.tr('tabs.scripts'), ), _WarmBottomBarItem( - icon: Icons.settings_outlined, + icon: const Icon(Icons.settings_outlined), label: l.tr('tabs.settings'), ), ], @@ -276,7 +283,9 @@ class _WarmBottomBarButton extends StatelessWidget { @override Widget build(BuildContext context) { - final color = selected ? context.colors.fontOnPrimary : context.colors.softMuted; + final color = selected + ? context.colors.fontOnPrimary + : context.colors.softMuted; return Tooltip( message: item.label, child: Material( @@ -287,7 +296,7 @@ class _WarmBottomBarButton extends StatelessWidget { onTap: onTap, child: IconTheme( data: IconThemeData(size: 22, color: color), - child: Center(child: Icon(item.icon)), + child: Center(child: item.icon), ), ), ), @@ -298,6 +307,6 @@ class _WarmBottomBarButton extends StatelessWidget { class _WarmBottomBarItem { const _WarmBottomBarItem({required this.icon, required this.label}); - final IconData icon; + final Widget icon; final String label; } diff --git a/native/lib/features/shell/sftp_tab.dart b/native/lib/features/shell/sftp_tab.dart index fb53df9..526c2b2 100644 --- a/native/lib/features/shell/sftp_tab.dart +++ b/native/lib/features/shell/sftp_tab.dart @@ -1332,11 +1332,7 @@ class _SftpDirectoryPickerSheetState extends State<_SftpDirectoryPickerSheet> { ), Expanded( child: _loading - ? Center( - child: CircularProgressIndicator( - color: c.primary, - ), - ) + ? Center(child: CircularProgressIndicator(color: c.primary)) : _error != null ? Center( child: Padding( @@ -1643,9 +1639,7 @@ class _SftpFileRow extends StatelessWidget { ? Icons.link_rounded : Icons.insert_drive_file_outlined, size: 20, - color: entry.isDirectory - ? c.accent - : c.muted, + color: entry.isDirectory ? c.accent : c.muted, ), const SizedBox(width: 8), Expanded( @@ -1667,10 +1661,7 @@ class _SftpFileRow extends StatelessWidget { maxLines: 1, textAlign: TextAlign.right, overflow: TextOverflow.ellipsis, - style: TextStyle( - color: c.softMuted, - fontSize: 12, - ), + style: TextStyle(color: c.softMuted, fontSize: 12), ), ), const SizedBox(width: 8), @@ -1681,10 +1672,7 @@ class _SftpFileRow extends StatelessWidget { maxLines: 1, textAlign: TextAlign.right, overflow: TextOverflow.ellipsis, - style: TextStyle( - color: c.softMuted, - fontSize: 10, - ), + style: TextStyle(color: c.softMuted, fontSize: 10), ), ), ], @@ -1791,29 +1779,35 @@ class _SftpEmptyCard extends StatelessWidget { children: [ const _SftpOrbitIcon(), const SizedBox(height: 18), - Text( - l.tr('sftp.emptyTitle'), - textAlign: TextAlign.center, - style: TextStyle( - color: c.text, - fontSize: 20, - fontWeight: FontWeight.w800, + SizedBox( + height: 24, + child: Text( + l.tr('sftp.emptyTitle'), + textAlign: TextAlign.center, + style: TextStyle( + color: c.text, + fontSize: 20, + fontWeight: FontWeight.w800, + ), ), ), const SizedBox(height: 8), - Text( - l.tr('sftp.emptyBody'), - textAlign: TextAlign.center, - style: TextStyle( - color: c.softMuted, - fontSize: 13, - fontWeight: FontWeight.w600, - height: 1.5, + SizedBox( + height: 40, + child: Text( + l.tr('sftp.emptyBody'), + textAlign: TextAlign.center, + style: TextStyle( + color: c.softMuted, + fontSize: 13, + fontWeight: FontWeight.w600, + height: 1.5, + ), ), ), const SizedBox(height: 18), _SftpPrimaryButton( - icon: Icons.dns_outlined, + icon: Icons.folder_outlined, label: l.tr('sftp.chooseServer'), trailing: Icons.keyboard_arrow_down_rounded, onTap: onChooseServer, @@ -1864,11 +1858,7 @@ class _SftpOrbitIcon extends StatelessWidget { borderRadius: BorderRadius.circular(28), border: Border.all(color: const Color(0x66E5B33A)), ), - child: Icon( - Icons.folder_rounded, - color: c.primary, - size: 42, - ), + child: Icon(Icons.folder_rounded, color: c.primary, size: 42), ), ), ], @@ -1986,7 +1976,9 @@ class _SftpServerPickerSheetState return Container( decoration: BoxDecoration( color: c.canvas, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20), + ), border: Border(top: BorderSide(color: c.border)), ), child: Column( @@ -2043,10 +2035,7 @@ class _SftpServerPickerSheetState child: TextField( controller: _searchCtrl, cursorColor: c.primary, - style: TextStyle( - color: c.text, - fontSize: 14, - ), + style: TextStyle(color: c.text, fontSize: 14), decoration: _sftpSearchDecoration( context, hintText: l.tr('sftp.searchHint'), @@ -2059,9 +2048,7 @@ class _SftpServerPickerSheetState Expanded( child: hostsAsync.when( loading: () => Center( - child: CircularProgressIndicator( - color: c.primary, - ), + child: CircularProgressIndicator(color: c.primary), ), error: (error, _) => ListView( controller: scrollController, @@ -2230,11 +2217,9 @@ class _SftpServerRow extends StatelessWidget { borderRadius: BorderRadius.circular(10), ), child: Icon( - Icons.dns_outlined, + Icons.folder_outlined, size: 21, - color: connected - ? c.primary - : c.muted, + color: connected ? c.primary : c.muted, ), ), const SizedBox(width: 12), @@ -2269,11 +2254,7 @@ class _SftpServerRow extends StatelessWidget { ), const SizedBox(width: 10), if (active) - Icon( - Icons.check_rounded, - color: c.success, - size: 20, - ) + Icon(Icons.check_rounded, color: c.success, size: 20) else if (!connected) _SftpStatusPill( label: l.tr('sftp.statusDisconnected'), @@ -2389,11 +2370,7 @@ InputDecoration _sftpSearchDecoration( isDense: true, filled: true, fillColor: c.chip, - prefixIcon: Icon( - Icons.search_rounded, - size: 18, - color: c.softMuted, - ), + prefixIcon: Icon(Icons.search_rounded, size: 18, color: c.softMuted), contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), hintStyle: TextStyle(color: c.softMuted, fontSize: 13), enabledBorder: OutlineInputBorder( diff --git a/native/lib/l10n/strings_en.dart b/native/lib/l10n/strings_en.dart index 2609bf4..3cc8bef 100644 --- a/native/lib/l10n/strings_en.dart +++ b/native/lib/l10n/strings_en.dart @@ -56,6 +56,7 @@ const Map stringsEn = { // Main shell tabs 'tabs.servers': 'Servers', 'tabs.sftp': 'SFTP', + 'tabs.docker': 'Docker', 'tabs.scripts': 'Scripts', 'tabs.settings': 'Settings', @@ -191,6 +192,45 @@ const Map stringsEn = { 'This system picker did not provide a readable folder path.', 'sftp.chooseTargetFolder': 'Choose target folder', 'sftp.selectThisFolder': 'Select this folder', + + // Docker + 'docker.emptyTitle': 'No server selected', + 'docker.emptyBody': 'Choose a Linux server to manage Docker containers.', + 'docker.chooseServer': 'Choose server', + 'docker.switchServer': 'Switch server', + 'docker.sheetTitle': 'Select server', + 'docker.sheetSubtitle': 'Connected Docker sessions are shown first.', + 'docker.searchHint': 'Search by name, host, or user', + 'docker.connectedSection': 'Connected', + 'docker.disconnectedSection': 'Not connected', + 'docker.statusConnected': 'Connected', + 'docker.statusDisconnected': 'Not connected', + 'docker.connecting': 'Connecting Docker...', + 'docker.noContainers': 'No containers found.', + 'docker.plusRequired': 'Docker management requires server authorization.', + 'docker.connectFailed': 'Docker service connection failed.', + 'docker.notPlus': 'Docker management is not enabled on this server.', + 'docker.refresh': 'Refresh', + 'docker.logs': 'Logs', + 'docker.start': 'Start', + 'docker.stop': 'Stop', + 'docker.restart': 'Restart', + 'docker.delete': 'Delete', + 'docker.deleteTitle': 'Delete container?', + 'docker.deleteBody': 'Delete container "{0}"? This cannot be undone.', + 'docker.operationFailed': 'Operation failed: {0}', + 'docker.logsTitle': '{0} logs', + 'docker.noLogs': 'No logs.', + 'docker.openPortFailed': 'Unable to parse port mapping.', + 'docker.selectAll': 'Select all', + 'docker.selectedCount': '{0} selected', + 'docker.batchStart': 'Start', + 'docker.batchRestart': 'Restart', + 'docker.batchStop': 'Stop', + 'docker.batchDelete': 'Delete', + 'docker.batchDeleteTitle': 'Delete {0} containers?', + 'docker.batchDeleteBody': + 'Are you sure you want to delete {0} selected containers? This cannot be undone.', 'scripts.placeholder': 'Coming soon: script library', // Scripts tab diff --git a/native/lib/l10n/strings_zh.dart b/native/lib/l10n/strings_zh.dart index dfa9428..c19f7c9 100644 --- a/native/lib/l10n/strings_zh.dart +++ b/native/lib/l10n/strings_zh.dart @@ -53,6 +53,7 @@ const Map stringsZh = { // Tabs 'tabs.servers': '服务器', 'tabs.sftp': '文件', + 'tabs.docker': '容器', 'tabs.scripts': '脚本', 'tabs.settings': '设置', @@ -179,6 +180,44 @@ const Map stringsZh = { 'sftp.folderUploadUnsupported': '系统选择器没有返回可读取的文件夹路径。', 'sftp.chooseTargetFolder': '选择目标文件夹', 'sftp.selectThisFolder': '选择此文件夹', + + // Docker + 'docker.emptyTitle': '还没有选择服务器', + 'docker.emptyBody': '请选择一台 Linux 服务器,开始管理 Docker 容器。', + 'docker.chooseServer': '选择服务器', + 'docker.switchServer': '切换服务器', + 'docker.sheetTitle': '选择服务器', + 'docker.sheetSubtitle': '已连接的 Docker 会话会优先显示。', + 'docker.searchHint': '按名称 / 主机 / 用户名搜索', + 'docker.connectedSection': '已连接', + 'docker.disconnectedSection': '未连接', + 'docker.statusConnected': '已连接', + 'docker.statusDisconnected': '未连接', + 'docker.connecting': '正在连接 Docker...', + 'docker.noContainers': '暂无容器', + 'docker.plusRequired': 'Docker 管理需要服务端授权。', + 'docker.connectFailed': 'Docker 服务连接失败。', + 'docker.notPlus': '当前服务端未启用 Docker 管理能力。', + 'docker.refresh': '刷新', + 'docker.logs': '日志', + 'docker.start': '启动', + 'docker.stop': '停止', + 'docker.restart': '重启', + 'docker.delete': '删除', + 'docker.deleteTitle': '删除容器?', + 'docker.deleteBody': '确认删除容器“{0}”?此操作无法撤销。', + 'docker.operationFailed': '操作失败:{0}', + 'docker.logsTitle': '{0} 日志', + 'docker.noLogs': '暂无日志', + 'docker.openPortFailed': '无法解析端口映射。', + 'docker.selectAll': '全选', + 'docker.selectedCount': '已选中 {0} 个', + 'docker.batchStart': '启动', + 'docker.batchRestart': '重启', + 'docker.batchStop': '停止', + 'docker.batchDelete': '删除', + 'docker.batchDeleteTitle': '删除 {0} 个容器?', + 'docker.batchDeleteBody': '确认删除 {0} 个已选容器?此操作无法撤销。', 'scripts.placeholder': '即将上线:脚本库', // Scripts tab diff --git a/native/lib/state/docker_providers.dart b/native/lib/state/docker_providers.dart new file mode 100644 index 0000000..d5d0af2 --- /dev/null +++ b/native/lib/state/docker_providers.dart @@ -0,0 +1,9 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../features/docker/docker_session_manager.dart'; + +final dockerSessionManagerProvider = Provider((ref) { + final manager = DockerSessionManager(); + ref.onDispose(manager.dispose); + return manager; +}); diff --git a/native/pubspec.lock b/native/pubspec.lock index 2e42dc7..930cb98 100644 --- a/native/pubspec.lock +++ b/native/pubspec.lock @@ -900,6 +900,22 @@ packages: description: flutter source: sdk version: "0.0.0" + socket_io_client: + dependency: "direct main" + description: + name: socket_io_client + sha256: f5990ff303d385e7b2150f0e57cca097a8d20c6a2ae0a22cb2d496661ea8ed9b + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.6" + socket_io_common: + dependency: transitive + description: + name: socket_io_common + sha256: "162fbaecbf4bf9a9372a62a341b3550b51dcef2f02f3e5830a297fd48203d45b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.1" source_span: dependency: transitive description: @@ -1102,6 +1118,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.1" win32: dependency: transitive description: diff --git a/native/pubspec.yaml b/native/pubspec.yaml index 57573a4..1bc14c5 100644 --- a/native/pubspec.yaml +++ b/native/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: photo_view: ^0.15.0 url_launcher: ^6.3.1 package_info_plus: ^8.1.0 + socket_io_client: ^3.1.6 dev_dependencies: flutter_test: @@ -107,5 +108,6 @@ flutter: assets: - assets/logo_v2_01.png - assets/plus.png + - assets/docker.svg - assets/linux.svg - assets/windows.svg