diff --git a/lib/features/control_room/presentation/pages/control_room_page.dart b/lib/features/control_room/presentation/pages/control_room_page.dart index e2328df..6c3f9df 100644 --- a/lib/features/control_room/presentation/pages/control_room_page.dart +++ b/lib/features/control_room/presentation/pages/control_room_page.dart @@ -6,6 +6,7 @@ import '../../../../core/config/app_config.dart'; import '../../containers/presentation/pages/containers_list_page.dart'; import '../../stacks/data/repositories/stack_repository_impl.dart'; import '../../stacks/domain/entities/stack.dart'; +import '../../stacks/presentation/pages/stack_detail_page.dart'; import '../../stacks/presentation/providers/stacks_provider.dart'; import '../../stacks/presentation/widgets/stack_list_tile.dart'; @@ -15,6 +16,8 @@ class ControlRoomPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final selectedStack = ref.watch(selectedStackProvider); + return Scaffold( body: Row( children: [ @@ -26,9 +29,11 @@ class ControlRoomPage extends ConsumerWidget { thickness: 1, color: Theme.of(context).colorScheme.outlineVariant, ), - // Main content area - const Expanded( - child: ContainersListPage(), + // Main content area - shows either all containers or stack detail + Expanded( + child: selectedStack == null + ? const ContainersListPage() + : StackDetailPage(stackId: selectedStack), ), ], ), diff --git a/lib/features/control_room/stacks/data/datasources/stacks_datasource.dart b/lib/features/control_room/stacks/data/datasources/stacks_datasource.dart index 52178f5..1bd8e52 100644 --- a/lib/features/control_room/stacks/data/datasources/stacks_datasource.dart +++ b/lib/features/control_room/stacks/data/datasources/stacks_datasource.dart @@ -1,5 +1,7 @@ +import 'package:dio/dio.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../../../../../core/api/api_client.dart'; import '../../../containers/data/datasources/containers_datasource.dart'; import '../../../containers/domain/entities/container.dart'; import '../../domain/entities/stack.dart'; @@ -9,11 +11,11 @@ part 'stacks_datasource.g.dart'; /// Data source for stack operations. /// /// Stacks are derived from container labels (Docker Compose convention). -/// The Core API doesn't have a dedicated stacks endpoint, so we aggregate -/// from containers. +/// The Core API provides endpoints for stack YAML and environment variables. class StacksDatasource { - StacksDatasource(this._containersDatasource); + StacksDatasource(this._dio, this._containersDatasource); + final Dio _dio; final ContainersDatasource _containersDatasource; /// Gets all stacks by aggregating container data. @@ -74,11 +76,53 @@ class StacksDatasource { await _containersDatasource.containerAction(container.fullId, action); } } + + /// Gets the compose YAML for a stack. + Future getStackYaml(String stackId) async { + final response = await _dio.get( + '/infrastructure/stacks/$stackId/compose', + ); + return response.data ?? ''; + } + + /// Updates the compose YAML for a stack. + Future updateStackYaml(String stackId, String yaml) async { + await _dio.put( + '/infrastructure/stacks/$stackId/compose', + data: yaml, + options: Options(contentType: 'text/yaml'), + ); + } + + /// Gets the environment variables for a stack. + Future> getStackEnvVars(String stackId) async { + final response = await _dio.get>( + '/infrastructure/stacks/$stackId/env', + ); + return response.data?.map((k, v) => MapEntry(k, v.toString())) ?? {}; + } + + /// Updates the environment variables for a stack. + Future updateStackEnvVars( + String stackId, + Map envVars, + ) async { + await _dio.put( + '/infrastructure/stacks/$stackId/env', + data: envVars, + ); + } + + /// Deploys/redeploys a stack with current configuration. + Future deployStack(String stackId) async { + await _dio.post('/infrastructure/stacks/$stackId/deploy'); + } } /// Provides the stacks datasource. @riverpod StacksDatasource stacksDatasource(StacksDatasourceRef ref) { + final dio = ref.watch(coreApiClientProvider); final containersDatasource = ref.watch(containersDatasourceProvider); - return StacksDatasource(containersDatasource); + return StacksDatasource(dio, containersDatasource); } diff --git a/lib/features/control_room/stacks/data/repositories/stack_repository_impl.dart b/lib/features/control_room/stacks/data/repositories/stack_repository_impl.dart index 682a87d..b258eb0 100644 --- a/lib/features/control_room/stacks/data/repositories/stack_repository_impl.dart +++ b/lib/features/control_room/stacks/data/repositories/stack_repository_impl.dart @@ -42,6 +42,31 @@ class StackRepositoryImpl implements StackRepository { // TODO: Implement stack removal throw UnimplementedError('Stack removal not yet implemented'); } + + @override + Future getStackYaml(String id) async { + return _datasource.getStackYaml(id); + } + + @override + Future updateStackYaml(String id, String yaml) async { + await _datasource.updateStackYaml(id, yaml); + } + + @override + Future> getStackEnvVars(String id) async { + return _datasource.getStackEnvVars(id); + } + + @override + Future updateStackEnvVars(String id, Map envVars) async { + await _datasource.updateStackEnvVars(id, envVars); + } + + @override + Future deployStack(String id) async { + await _datasource.deployStack(id); + } } /// Provides the stack repository. diff --git a/lib/features/control_room/stacks/domain/entities/stack.dart b/lib/features/control_room/stacks/domain/entities/stack.dart index 575d42a..7f7d4e4 100644 --- a/lib/features/control_room/stacks/domain/entities/stack.dart +++ b/lib/features/control_room/stacks/domain/entities/stack.dart @@ -27,6 +27,12 @@ class Stack with _$Stack { /// Path to the compose file (if applicable). String? composeFile, + /// Compose YAML content. + String? yaml, + + /// Environment variables for the stack. + @Default({}) Map envVars, + /// Environment name (e.g., production, staging). String? environment, diff --git a/lib/features/control_room/stacks/domain/repositories/stack_repository.dart b/lib/features/control_room/stacks/domain/repositories/stack_repository.dart index fba6ad8..47a51d0 100644 --- a/lib/features/control_room/stacks/domain/repositories/stack_repository.dart +++ b/lib/features/control_room/stacks/domain/repositories/stack_repository.dart @@ -19,4 +19,19 @@ abstract class StackRepository { /// Removes a stack (stops and removes containers). Future removeStack(String id); + + /// Gets the compose YAML for a stack. + Future getStackYaml(String id); + + /// Updates the compose YAML for a stack. + Future updateStackYaml(String id, String yaml); + + /// Gets the environment variables for a stack. + Future> getStackEnvVars(String id); + + /// Updates the environment variables for a stack. + Future updateStackEnvVars(String id, Map envVars); + + /// Deploys/redeploys a stack with current configuration. + Future deployStack(String id); } diff --git a/lib/features/control_room/stacks/presentation/pages/stack_detail_page.dart b/lib/features/control_room/stacks/presentation/pages/stack_detail_page.dart new file mode 100644 index 0000000..b1e3023 --- /dev/null +++ b/lib/features/control_room/stacks/presentation/pages/stack_detail_page.dart @@ -0,0 +1,327 @@ +import 'package:flutter/material.dart' hide Stack; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../containers/presentation/providers/containers_provider.dart'; +import '../../../containers/presentation/widgets/container_logs_viewer.dart'; +import '../../../containers/presentation/widgets/container_status_badge.dart'; +import '../../domain/entities/stack.dart'; +import '../providers/stacks_provider.dart'; +import '../widgets/env_vars_editor.dart'; +import '../widgets/yaml_editor.dart'; + +/// Detail page for a selected stack with YAML editor, env vars, and container list. +class StackDetailPage extends ConsumerStatefulWidget { + const StackDetailPage({ + super.key, + required this.stackId, + }); + + final String stackId; + + @override + ConsumerState createState() => _StackDetailPageState(); +} + +class _StackDetailPageState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final stackAsync = ref.watch(stackProvider(widget.stackId)); + final containersAsync = ref.watch(containersProvider); + final colorScheme = Theme.of(context).colorScheme; + + // Listen for config action results + ref.listen>(stackConfigActionsProvider, (previous, next) { + if (previous?.isLoading == true && !next.isLoading) { + next.whenOrNull( + data: (_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Configuration saved'), + backgroundColor: colorScheme.primaryContainer, + behavior: SnackBarBehavior.floating, + ), + ); + }, + error: (error, _) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to save: $error'), + backgroundColor: colorScheme.errorContainer, + behavior: SnackBarBehavior.floating, + ), + ); + }, + ); + } + }); + + return Column( + children: [ + // Stack editor (top section - 60%) + Expanded( + flex: 6, + child: stackAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text('Error: $error')), + data: (stack) => _StackEditor( + stack: stack, + tabController: _tabController, + ), + ), + ), + // Divider with drag handle appearance + Container( + height: 8, + color: colorScheme.surfaceContainerHighest, + child: Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.outline, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ), + // Container list (bottom section - 40%) + Expanded( + flex: 4, + child: containersAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text('Error: $error')), + data: (containers) => _CompactContainerList( + containers: containers, + stackId: widget.stackId, + ), + ), + ), + ], + ); + } +} + +/// Stack configuration editor with tabs for YAML and environment variables. +class _StackEditor extends ConsumerWidget { + const _StackEditor({ + required this.stack, + required this.tabController, + }); + + final Stack stack; + final TabController tabController; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + + return Column( + children: [ + // Header with stack info and actions + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + border: Border( + bottom: BorderSide(color: colorScheme.outlineVariant), + ), + ), + child: Row( + children: [ + Icon(Icons.layers, color: colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + stack.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + '${stack.runningCount}/${stack.containerCount} containers running', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + FilledButton.icon( + onPressed: () { + ref.read(stackConfigActionsProvider.notifier).deploy(stack.id); + }, + icon: const Icon(Icons.rocket_launch, size: 18), + label: const Text('Deploy'), + ), + ], + ), + ), + // Tab bar + TabBar( + controller: tabController, + tabs: const [ + Tab(text: 'Compose YAML'), + Tab(text: 'Environment Variables'), + ], + ), + // Tab content + Expanded( + child: TabBarView( + controller: tabController, + children: [ + YamlEditor(stackId: stack.id), + EnvVarsEditor(stackId: stack.id), + ], + ), + ), + ], + ); + } +} + +/// Compact container list for the docked bottom section. +class _CompactContainerList extends ConsumerWidget { + const _CompactContainerList({ + required this.containers, + required this.stackId, + }); + + final List containers; + final String stackId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final actions = ref.read(containerActionsProvider.notifier); + + // Filter containers for this stack + final stackContainers = containers + .where((c) => c.stackId == stackId || c.stackName == stackId) + .toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(Icons.dns, size: 18, color: colorScheme.primary), + const SizedBox(width: 8), + Text( + 'Containers (${stackContainers.length})', + style: Theme.of(context).textTheme.titleSmall, + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.refresh, size: 18), + tooltip: 'Refresh', + onPressed: () => ref.invalidate(containersProvider), + ), + ], + ), + ), + const Divider(height: 1), + // Container list + Expanded( + child: stackContainers.isEmpty + ? Center( + child: Text( + 'No containers in this stack', + style: TextStyle(color: colorScheme.outline), + ), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 8), + itemCount: stackContainers.length, + itemBuilder: (context, index) { + final container = stackContainers[index]; + return Card( + margin: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + child: ListTile( + dense: true, + leading: ContainerStatusBadge(state: container.state), + title: Text( + container.name, + style: const TextStyle(fontSize: 13), + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + container.image, + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurfaceVariant, + ), + overflow: TextOverflow.ellipsis, + ), + trailing: PopupMenuButton( + icon: const Icon(Icons.more_vert, size: 18), + itemBuilder: (context) => [ + if (container.canStart) + const PopupMenuItem( + value: 'start', + child: Text('Start'), + ), + if (container.canStop) + const PopupMenuItem( + value: 'stop', + child: Text('Stop'), + ), + if (container.canRestart) + const PopupMenuItem( + value: 'restart', + child: Text('Restart'), + ), + const PopupMenuItem( + value: 'logs', + child: Text('View Logs'), + ), + ], + onSelected: (action) { + switch (action) { + case 'start': + actions.start(container.fullId); + case 'stop': + actions.stop(container.fullId); + case 'restart': + actions.restart(container.fullId); + case 'logs': + showContainerLogs( + context, + containerId: container.fullId, + containerName: container.name, + ); + } + }, + ), + ), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/features/control_room/stacks/presentation/providers/stacks_provider.dart b/lib/features/control_room/stacks/presentation/providers/stacks_provider.dart index 64fd64d..3401cdf 100644 --- a/lib/features/control_room/stacks/presentation/providers/stacks_provider.dart +++ b/lib/features/control_room/stacks/presentation/providers/stacks_provider.dart @@ -33,3 +33,51 @@ class SelectedStack extends _$SelectedStack { state = null; } } + +/// Provides the YAML content for a stack. +@riverpod +Future stackYaml(StackYamlRef ref, String stackId) async { + final repository = ref.watch(stackRepositoryProvider); + return repository.getStackYaml(stackId); +} + +/// Provides the environment variables for a stack. +@riverpod +Future> stackEnvVars(StackEnvVarsRef ref, String stackId) async { + final repository = ref.watch(stackRepositoryProvider); + return repository.getStackEnvVars(stackId); +} + +/// Controller for stack configuration actions. +@riverpod +class StackConfigActions extends _$StackConfigActions { + @override + AsyncValue build() => const AsyncValue.data(null); + + Future saveYaml(String stackId, String yaml) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final repository = ref.read(stackRepositoryProvider); + await repository.updateStackYaml(stackId, yaml); + ref.invalidate(stackYamlProvider(stackId)); + }); + } + + Future saveEnvVars(String stackId, Map envVars) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final repository = ref.read(stackRepositoryProvider); + await repository.updateStackEnvVars(stackId, envVars); + ref.invalidate(stackEnvVarsProvider(stackId)); + }); + } + + Future deploy(String stackId) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final repository = ref.read(stackRepositoryProvider); + await repository.deployStack(stackId); + ref.invalidate(stacksProvider); + }); + } +} diff --git a/lib/features/control_room/stacks/presentation/widgets/env_vars_editor.dart b/lib/features/control_room/stacks/presentation/widgets/env_vars_editor.dart new file mode 100644 index 0000000..542ccf5 --- /dev/null +++ b/lib/features/control_room/stacks/presentation/widgets/env_vars_editor.dart @@ -0,0 +1,352 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/stacks_provider.dart'; + +/// Environment variables editor for stack configuration. +class EnvVarsEditor extends ConsumerStatefulWidget { + const EnvVarsEditor({ + super.key, + required this.stackId, + }); + + final String stackId; + + @override + ConsumerState createState() => _EnvVarsEditorState(); +} + +class _EnvVarsEditorState extends ConsumerState { + final List<_EnvVarEntry> _entries = []; + Map _originalEnvVars = {}; + bool _hasChanges = false; + + void _initializeEntries(Map envVars) { + if (_originalEnvVars.isEmpty && envVars.isNotEmpty) { + _originalEnvVars = Map.from(envVars); + _entries.clear(); + for (final entry in envVars.entries) { + _entries.add(_EnvVarEntry( + keyController: TextEditingController(text: entry.key), + valueController: TextEditingController(text: entry.value), + )); + } + } + } + + void _checkChanges() { + final currentMap = {}; + for (final entry in _entries) { + final key = entry.keyController.text.trim(); + if (key.isNotEmpty) { + currentMap[key] = entry.valueController.text; + } + } + + final hasChanges = !_mapsEqual(currentMap, _originalEnvVars); + if (hasChanges != _hasChanges) { + setState(() => _hasChanges = hasChanges); + } + } + + bool _mapsEqual(Map a, Map b) { + if (a.length != b.length) return false; + for (final key in a.keys) { + if (!b.containsKey(key) || a[key] != b[key]) return false; + } + return true; + } + + void _addEntry() { + setState(() { + _entries.add(_EnvVarEntry( + keyController: TextEditingController(), + valueController: TextEditingController(), + )); + }); + } + + void _removeEntry(int index) { + setState(() { + _entries[index].dispose(); + _entries.removeAt(index); + _checkChanges(); + }); + } + + void _onSave() { + final envVars = {}; + for (final entry in _entries) { + final key = entry.keyController.text.trim(); + if (key.isNotEmpty) { + envVars[key] = entry.valueController.text; + } + } + + ref + .read(stackConfigActionsProvider.notifier) + .saveEnvVars(widget.stackId, envVars); + + setState(() { + _originalEnvVars = Map.from(envVars); + _hasChanges = false; + }); + } + + void _onReset() { + for (final entry in _entries) { + entry.dispose(); + } + _entries.clear(); + + for (final entry in _originalEnvVars.entries) { + _entries.add(_EnvVarEntry( + keyController: TextEditingController(text: entry.key), + valueController: TextEditingController(text: entry.value), + )); + } + + setState(() => _hasChanges = false); + } + + @override + void dispose() { + for (final entry in _entries) { + entry.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final envVarsAsync = ref.watch(stackEnvVarsProvider(widget.stackId)); + final colorScheme = Theme.of(context).colorScheme; + final configState = ref.watch(stackConfigActionsProvider); + final isLoading = configState.isLoading; + + return envVarsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, color: colorScheme.error, size: 48), + const SizedBox(height: 16), + Text( + 'Failed to load environment variables', + style: TextStyle(color: colorScheme.error), + ), + const SizedBox(height: 8), + Text( + error.toString(), + style: TextStyle(color: colorScheme.outline, fontSize: 12), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () => + ref.invalidate(stackEnvVarsProvider(widget.stackId)), + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + data: (envVars) { + _initializeEntries(envVars); + return Column( + children: [ + // Toolbar + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + border: Border( + bottom: BorderSide(color: colorScheme.outlineVariant), + ), + ), + child: Row( + children: [ + Icon( + Icons.settings, + size: 18, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + '.env', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 13, + color: colorScheme.onSurfaceVariant, + ), + ), + if (_hasChanges) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Modified', + style: TextStyle( + fontSize: 11, + color: colorScheme.onTertiaryContainer, + ), + ), + ), + ], + const Spacer(), + IconButton( + icon: const Icon(Icons.add), + tooltip: 'Add variable', + onPressed: _addEntry, + ), + if (_hasChanges) ...[ + TextButton( + onPressed: isLoading ? null : _onReset, + child: const Text('Reset'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: isLoading ? null : _onSave, + icon: isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save, size: 18), + label: const Text('Save'), + ), + ], + ], + ), + ), + // Environment variables list + Expanded( + child: _entries.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.settings_outlined, + size: 48, + color: colorScheme.outline, + ), + const SizedBox(height: 16), + Text( + 'No environment variables', + style: TextStyle(color: colorScheme.outline), + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: _addEntry, + icon: const Icon(Icons.add), + label: const Text('Add Variable'), + ), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _entries.length, + itemBuilder: (context, index) { + final entry = _entries[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + // Key field + Expanded( + flex: 2, + child: TextField( + controller: entry.keyController, + onChanged: (_) => _checkChanges(), + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 13, + ), + decoration: InputDecoration( + labelText: 'Key', + isDense: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(width: 8), + // Equals sign + Text( + '=', + style: TextStyle( + fontSize: 18, + color: colorScheme.outline, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + // Value field + Expanded( + flex: 3, + child: TextField( + controller: entry.valueController, + onChanged: (_) => _checkChanges(), + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 13, + ), + decoration: InputDecoration( + labelText: 'Value', + isDense: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(width: 8), + // Delete button + IconButton( + icon: Icon( + Icons.delete_outline, + color: colorScheme.error, + ), + tooltip: 'Remove', + onPressed: () => _removeEntry(index), + ), + ], + ), + ); + }, + ), + ), + ], + ); + }, + ); + } +} + +/// Helper class to manage key-value pair controllers. +class _EnvVarEntry { + _EnvVarEntry({ + required this.keyController, + required this.valueController, + }); + + final TextEditingController keyController; + final TextEditingController valueController; + + void dispose() { + keyController.dispose(); + valueController.dispose(); + } +} diff --git a/lib/features/control_room/stacks/presentation/widgets/yaml_editor.dart b/lib/features/control_room/stacks/presentation/widgets/yaml_editor.dart new file mode 100644 index 0000000..8baf1c3 --- /dev/null +++ b/lib/features/control_room/stacks/presentation/widgets/yaml_editor.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/stacks_provider.dart'; + +/// YAML editor for stack compose configuration. +class YamlEditor extends ConsumerStatefulWidget { + const YamlEditor({ + super.key, + required this.stackId, + }); + + final String stackId; + + @override + ConsumerState createState() => _YamlEditorState(); +} + +class _YamlEditorState extends ConsumerState { + final _controller = TextEditingController(); + bool _hasChanges = false; + String _originalYaml = ''; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _initializeController(String yaml) { + if (_originalYaml.isEmpty && yaml.isNotEmpty) { + _originalYaml = yaml; + _controller.text = yaml; + } + } + + void _onChanged(String value) { + setState(() { + _hasChanges = value != _originalYaml; + }); + } + + void _onSave() { + ref + .read(stackConfigActionsProvider.notifier) + .saveYaml(widget.stackId, _controller.text); + setState(() { + _originalYaml = _controller.text; + _hasChanges = false; + }); + } + + void _onReset() { + _controller.text = _originalYaml; + setState(() { + _hasChanges = false; + }); + } + + @override + Widget build(BuildContext context) { + final yamlAsync = ref.watch(stackYamlProvider(widget.stackId)); + final colorScheme = Theme.of(context).colorScheme; + final configState = ref.watch(stackConfigActionsProvider); + final isLoading = configState.isLoading; + + return yamlAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, color: colorScheme.error, size: 48), + const SizedBox(height: 16), + Text( + 'Failed to load YAML', + style: TextStyle(color: colorScheme.error), + ), + const SizedBox(height: 8), + Text( + error.toString(), + style: TextStyle(color: colorScheme.outline, fontSize: 12), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () => ref.invalidate(stackYamlProvider(widget.stackId)), + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + data: (yaml) { + _initializeController(yaml); + return Column( + children: [ + // Toolbar + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + border: Border( + bottom: BorderSide(color: colorScheme.outlineVariant), + ), + ), + child: Row( + children: [ + Icon( + Icons.code, + size: 18, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + 'docker-compose.yml', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 13, + color: colorScheme.onSurfaceVariant, + ), + ), + if (_hasChanges) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Modified', + style: TextStyle( + fontSize: 11, + color: colorScheme.onTertiaryContainer, + ), + ), + ), + ], + const Spacer(), + if (_hasChanges) ...[ + TextButton( + onPressed: isLoading ? null : _onReset, + child: const Text('Reset'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: isLoading ? null : _onSave, + icon: isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save, size: 18), + label: const Text('Save'), + ), + ], + ], + ), + ), + // Editor + Expanded( + child: Container( + color: colorScheme.surfaceContainerLowest, + child: TextField( + controller: _controller, + onChanged: _onChanged, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 13, + color: colorScheme.onSurface, + height: 1.5, + ), + decoration: const InputDecoration( + contentPadding: EdgeInsets.all(16), + border: InputBorder.none, + hintText: 'Enter docker-compose.yml content...', + ), + ), + ), + ), + ], + ); + }, + ); + } +}