diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 4fefa51..59533be 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -58,4 +58,16 @@ class AppConfig { static bool get requiresAuth => coreApiUrl.contains('schweitz.net') || tatlockApiUrl.contains('schweitz.net'); + + /// Portainer URL for container management + static const portainerUrl = String.fromEnvironment( + 'PORTAINER_URL', + defaultValue: 'http://192.168.86.149:9000', + ); + + /// Netdata URL for system monitoring + static const netdataUrl = String.fromEnvironment( + 'NETDATA_URL', + defaultValue: 'http://192.168.86.149:19999', + ); } diff --git a/lib/features/control_room/containers/data/datasources/containers_datasource.dart b/lib/features/control_room/containers/data/datasources/containers_datasource.dart new file mode 100644 index 0000000..3327bb8 --- /dev/null +++ b/lib/features/control_room/containers/data/datasources/containers_datasource.dart @@ -0,0 +1,73 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../../../core/api/api_client.dart'; +import '../models/container_model.dart'; + +part 'containers_datasource.g.dart'; + +/// Remote data source for container operations. +class ContainersDatasource { + ContainersDatasource(this._dio); + + final Dio _dio; + + /// Gets all containers from the API. + Future> getContainers({bool all = true}) async { + final response = await _dio.get>( + '/infrastructure/containers', + queryParameters: {'all': all}, + ); + + return response.data! + .map((json) => ContainerModel.fromJson(json as Map)) + .toList(); + } + + /// Gets a single container by ID. + Future getContainer(String id) async { + final response = await _dio.get>( + '/infrastructure/containers/$id', + ); + + return ContainerModel.fromJson(response.data!); + } + + /// Performs an action on a container. + Future containerAction(String id, String action) async { + await _dio.post('/infrastructure/containers/$id/$action'); + } + + /// Gets container logs. + Future getContainerLogs( + String id, { + int? tail, + bool timestamps = false, + }) async { + final response = await _dio.get( + '/infrastructure/containers/$id/logs', + queryParameters: { + if (tail != null) 'tail': tail, + 'timestamps': timestamps, + }, + ); + + return response.data ?? ''; + } + + /// Removes a container. + Future removeContainer(String id, {bool force = false}) async { + await _dio.delete( + '/infrastructure/containers/$id', + queryParameters: {'force': force}, + ); + } +} + +/// Provides the containers datasource. +@riverpod +ContainersDatasource containersDatasource(Ref ref) { + final dio = ref.watch(coreApiClientProvider); + return ContainersDatasource(dio); +} diff --git a/lib/features/control_room/containers/data/models/container_model.dart b/lib/features/control_room/containers/data/models/container_model.dart new file mode 100644 index 0000000..647d07e --- /dev/null +++ b/lib/features/control_room/containers/data/models/container_model.dart @@ -0,0 +1,130 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../domain/entities/container.dart'; + +part 'container_model.freezed.dart'; +part 'container_model.g.dart'; + +/// Container data model for API serialization. +@freezed +class ContainerModel with _$ContainerModel { + const factory ContainerModel({ + @JsonKey(name: 'Id') required String id, + @JsonKey(name: 'Names') required List names, + @JsonKey(name: 'Image') required String image, + @JsonKey(name: 'State') required String state, + @JsonKey(name: 'Status') required String status, + @JsonKey(name: 'Labels') @Default({}) Map labels, + @JsonKey(name: 'Ports') @Default([]) List ports, + @JsonKey(name: 'Mounts') @Default([]) List mounts, + @JsonKey(name: 'NetworkSettings') NetworkSettingsModel? networkSettings, + @JsonKey(name: 'Created') int? created, + @JsonKey(name: 'SizeRw') int? sizeRw, + @JsonKey(name: 'SizeRootFs') int? sizeRootFs, + }) = _ContainerModel; + + const ContainerModel._(); + + factory ContainerModel.fromJson(Map json) => + _$ContainerModelFromJson(json); + + /// Converts to domain entity. + Container toEntity() { + // Extract stack name from labels (Docker Compose convention) + final stackName = labels['com.docker.compose.project']; + final stackId = stackName; // Use project name as ID for now + + return Container( + id: id.substring(0, 12), + fullId: id, + name: names.isNotEmpty ? names.first.replaceFirst('/', '') : id, + image: image, + state: _parseState(state), + status: status, + stackName: stackName, + stackId: stackId, + ports: ports.map((p) => p.toEntity()).toList(), + labels: labels, + mounts: mounts.map((m) => m.toEntity()).toList(), + networks: networkSettings?.networks.keys.toList() ?? [], + createdAt: created != null + ? DateTime.fromMillisecondsSinceEpoch(created! * 1000) + : null, + ); + } + + ContainerState _parseState(String state) { + return switch (state.toLowerCase()) { + 'created' => ContainerState.created, + 'running' => ContainerState.running, + 'paused' => ContainerState.paused, + 'restarting' => ContainerState.restarting, + 'removing' => ContainerState.removing, + 'exited' => ContainerState.exited, + 'dead' => ContainerState.dead, + _ => ContainerState.exited, + }; + } +} + +/// Port mapping model. +@freezed +class PortModel with _$PortModel { + const factory PortModel({ + @JsonKey(name: 'IP') String? ip, + @JsonKey(name: 'PrivatePort') required int privatePort, + @JsonKey(name: 'PublicPort') int? publicPort, + @JsonKey(name: 'Type') @Default('tcp') String type, + }) = _PortModel; + + const PortModel._(); + + factory PortModel.fromJson(Map json) => + _$PortModelFromJson(json); + + PortMapping toEntity() { + return PortMapping( + hostIp: ip, + hostPort: publicPort, + containerPort: privatePort, + protocol: type, + ); + } +} + +/// Mount model. +@freezed +class MountModel with _$MountModel { + const factory MountModel({ + @JsonKey(name: 'Type') required String type, + @JsonKey(name: 'Source') required String source, + @JsonKey(name: 'Destination') required String destination, + @JsonKey(name: 'Mode') @Default('rw') String mode, + @JsonKey(name: 'RW') @Default(true) bool rw, + }) = _MountModel; + + const MountModel._(); + + factory MountModel.fromJson(Map json) => + _$MountModelFromJson(json); + + VolumeMount toEntity() { + return VolumeMount( + type: type, + source: source, + destination: destination, + mode: rw ? 'rw' : 'ro', + ); + } +} + +/// Network settings model. +@freezed +class NetworkSettingsModel with _$NetworkSettingsModel { + const factory NetworkSettingsModel({ + @JsonKey(name: 'Networks') @Default({}) Map networks, + }) = _NetworkSettingsModel; + + factory NetworkSettingsModel.fromJson(Map json) => + _$NetworkSettingsModelFromJson(json); +} diff --git a/lib/features/control_room/containers/data/repositories/container_repository_impl.dart b/lib/features/control_room/containers/data/repositories/container_repository_impl.dart new file mode 100644 index 0000000..34a3b4c --- /dev/null +++ b/lib/features/control_room/containers/data/repositories/container_repository_impl.dart @@ -0,0 +1,84 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../domain/entities/container.dart'; +import '../../domain/repositories/container_repository.dart'; +import '../datasources/containers_datasource.dart'; + +part 'container_repository_impl.g.dart'; + +/// Implementation of ContainerRepository using remote datasource. +class ContainerRepositoryImpl implements ContainerRepository { + ContainerRepositoryImpl(this._datasource); + + final ContainersDatasource _datasource; + + @override + Future> getContainers() async { + final models = await _datasource.getContainers(); + return models.map((m) => m.toEntity()).toList(); + } + + @override + Future> getContainersByStack(String stackId) async { + final containers = await getContainers(); + return containers.where((c) => c.stackId == stackId).toList(); + } + + @override + Future getContainer(String id) async { + final model = await _datasource.getContainer(id); + return model.toEntity(); + } + + @override + Future startContainer(String id) async { + await _datasource.containerAction(id, 'start'); + } + + @override + Future stopContainer(String id) async { + await _datasource.containerAction(id, 'stop'); + } + + @override + Future restartContainer(String id) async { + await _datasource.containerAction(id, 'restart'); + } + + @override + Future pauseContainer(String id) async { + await _datasource.containerAction(id, 'pause'); + } + + @override + Future unpauseContainer(String id) async { + await _datasource.containerAction(id, 'unpause'); + } + + @override + Future removeContainer(String id, {bool force = false}) async { + await _datasource.removeContainer(id, force: force); + } + + @override + Future getContainerLogs( + String id, { + int? tail, + bool timestamps = false, + }) async { + return _datasource.getContainerLogs(id, tail: tail, timestamps: timestamps); + } + + @override + Stream streamContainerLogs(String id, {bool timestamps = false}) { + // TODO: Implement WebSocket/SSE streaming + throw UnimplementedError('Log streaming not yet implemented'); + } +} + +/// Provides the container repository. +@riverpod +ContainerRepository containerRepository(ContainerRepositoryRef ref) { + final datasource = ref.watch(containersDatasourceProvider); + return ContainerRepositoryImpl(datasource); +} diff --git a/lib/features/control_room/containers/domain/entities/container.dart b/lib/features/control_room/containers/domain/entities/container.dart new file mode 100644 index 0000000..fb9b9aa --- /dev/null +++ b/lib/features/control_room/containers/domain/entities/container.dart @@ -0,0 +1,171 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'container.freezed.dart'; + +/// Docker container entity. +@freezed +class Container with _$Container { + const factory Container({ + /// Container ID (short form). + required String id, + + /// Full container ID. + required String fullId, + + /// Container name (without leading slash). + required String name, + + /// Image name with tag. + required String image, + + /// Current container state. + required ContainerState state, + + /// Container status string (e.g., "Up 2 hours"). + required String status, + + /// Stack/project this container belongs to. + String? stackName, + + /// Stack ID if part of a stack. + String? stackId, + + /// Mapped ports. + @Default([]) List ports, + + /// Environment variables (key-value pairs). + @Default({}) Map environment, + + /// Container labels. + @Default({}) Map labels, + + /// Mounted volumes. + @Default([]) List mounts, + + /// Networks the container is connected to. + @Default([]) List networks, + + /// When the container was created. + DateTime? createdAt, + + /// When the container was started. + DateTime? startedAt, + + /// CPU usage percentage (0-100). + double? cpuPercent, + + /// Memory usage in bytes. + int? memoryUsage, + + /// Memory limit in bytes. + int? memoryLimit, + }) = _Container; + + const Container._(); + + /// Whether the container is running. + bool get isRunning => state == ContainerState.running; + + /// Whether the container can be started. + bool get canStart => + state == ContainerState.exited || + state == ContainerState.created || + state == ContainerState.paused; + + /// Whether the container can be stopped. + bool get canStop => state == ContainerState.running; + + /// Whether the container can be restarted. + bool get canRestart => + state == ContainerState.running || state == ContainerState.exited; + + /// Memory usage as a percentage of the limit. + double? get memoryPercent { + if (memoryUsage == null || memoryLimit == null || memoryLimit == 0) { + return null; + } + return (memoryUsage! / memoryLimit!) * 100; + } + + /// Formatted memory usage string. + String get memoryFormatted { + if (memoryUsage == null) return '--'; + return _formatBytes(memoryUsage!); + } + + /// Formatted memory limit string. + String get memoryLimitFormatted { + if (memoryLimit == null) return '--'; + return _formatBytes(memoryLimit!); + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; + } +} + +/// Container state. +enum ContainerState { + created, + running, + paused, + restarting, + removing, + exited, + dead, +} + +/// Port mapping configuration. +@freezed +class PortMapping with _$PortMapping { + const factory PortMapping({ + /// Host IP (usually 0.0.0.0). + String? hostIp, + + /// Port on the host. + int? hostPort, + + /// Port inside the container. + required int containerPort, + + /// Protocol (tcp/udp). + @Default('tcp') String protocol, + }) = _PortMapping; + + const PortMapping._(); + + /// Formatted string representation. + String get formatted { + if (hostPort == null) return '$containerPort/$protocol'; + final ip = hostIp == '0.0.0.0' ? '' : '$hostIp:'; + return '$ip$hostPort->$containerPort/$protocol'; + } +} + +/// Volume mount configuration. +@freezed +class VolumeMount with _$VolumeMount { + const factory VolumeMount({ + /// Mount type (bind, volume, tmpfs). + required String type, + + /// Source path or volume name. + required String source, + + /// Destination path in container. + required String destination, + + /// Mount mode (rw, ro). + @Default('rw') String mode, + }) = _VolumeMount; + + const VolumeMount._(); + + /// Whether the mount is read-only. + bool get isReadOnly => mode == 'ro'; +} diff --git a/lib/features/control_room/containers/domain/repositories/container_repository.dart b/lib/features/control_room/containers/domain/repositories/container_repository.dart new file mode 100644 index 0000000..1e553df --- /dev/null +++ b/lib/features/control_room/containers/domain/repositories/container_repository.dart @@ -0,0 +1,44 @@ +import '../entities/container.dart'; + +/// Repository interface for container operations. +abstract class ContainerRepository { + /// Gets all containers. + Future> getContainers(); + + /// Gets containers filtered by stack. + Future> getContainersByStack(String stackId); + + /// Gets a single container by ID. + Future getContainer(String id); + + /// Starts a container. + Future startContainer(String id); + + /// Stops a container. + Future stopContainer(String id); + + /// Restarts a container. + Future restartContainer(String id); + + /// Pauses a container. + Future pauseContainer(String id); + + /// Unpauses a container. + Future unpauseContainer(String id); + + /// Removes a container. + Future removeContainer(String id, {bool force = false}); + + /// Gets container logs. + Future getContainerLogs( + String id, { + int? tail, + bool timestamps = false, + }); + + /// Streams container logs in real-time. + Stream streamContainerLogs( + String id, { + bool timestamps = false, + }); +} diff --git a/lib/features/control_room/containers/presentation/pages/containers_list_page.dart b/lib/features/control_room/containers/presentation/pages/containers_list_page.dart new file mode 100644 index 0000000..c3d4cb3 --- /dev/null +++ b/lib/features/control_room/containers/presentation/pages/containers_list_page.dart @@ -0,0 +1,396 @@ +import 'package:flutter/material.dart' hide Container; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../domain/entities/container.dart'; +import '../providers/containers_provider.dart'; +import '../widgets/container_logs_viewer.dart'; +import '../widgets/container_status_badge.dart'; + +/// Page displaying the list of containers using DataGrid. +class ContainersListPage extends ConsumerWidget { + const ContainersListPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final containersAsync = ref.watch(containersProvider); + final colorScheme = Theme.of(context).colorScheme; + + // Listen for container action results to show snackbars + ref.listen>(containerActionsProvider, (previous, next) { + if (previous?.isLoading == true && !next.isLoading) { + next.whenOrNull( + data: (_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Container action completed'), + backgroundColor: colorScheme.primaryContainer, + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), + ); + }, + error: (error, _) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + Icon(Icons.error_outline, color: colorScheme.onErrorContainer), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Container action failed: $error', + style: TextStyle(color: colorScheme.onErrorContainer), + ), + ), + ], + ), + backgroundColor: colorScheme.errorContainer, + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 4), + ), + ); + }, + ); + } + }); + + return containersAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: colorScheme.error, + ), + const SizedBox(height: 16), + const Text('Failed to load containers'), + const SizedBox(height: 8), + Text( + error.toString(), + style: TextStyle(color: colorScheme.outline), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () => ref.invalidate(containersProvider), + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + data: (containers) => _ContainersList( + containers: containers, + onAction: (container, action) => _handleAction(ref, container, action), + onViewLogs: (c) => showContainerLogs( + context, + containerId: c.fullId, + containerName: c.name, + ), + onRefresh: () => ref.invalidate(containersProvider), + ), + ); + } + + void _handleAction(WidgetRef ref, Container container, String action) { + final actions = ref.read(containerActionsProvider.notifier); + switch (action) { + case 'start': + actions.start(container.fullId); + case 'stop': + actions.stop(container.fullId); + case 'restart': + actions.restart(container.fullId); + } + } +} + +class _ContainersList extends StatefulWidget { + const _ContainersList({ + required this.containers, + required this.onAction, + required this.onViewLogs, + required this.onRefresh, + }); + + final List containers; + final void Function(Container, String) onAction; + final void Function(Container) onViewLogs; + final VoidCallback onRefresh; + + @override + State<_ContainersList> createState() => _ContainersListState(); +} + +class _ContainersListState extends State<_ContainersList> { + final _searchController = TextEditingController(); + String _searchQuery = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + List get _filteredContainers { + if (_searchQuery.isEmpty) return widget.containers; + final query = _searchQuery.toLowerCase(); + return widget.containers.where((c) { + return c.name.toLowerCase().contains(query) || + c.image.toLowerCase().contains(query) || + c.status.toLowerCase().contains(query); + }).toList(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final filtered = _filteredContainers; + + return Column( + children: [ + // Toolbar with search + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Text( + '${filtered.length} of ${widget.containers.length} containers', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const Spacer(), + SizedBox( + width: 250, + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search containers...', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + ) + : null, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onChanged: (value) => setState(() => _searchQuery = value), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Refresh', + onPressed: widget.onRefresh, + ), + ], + ), + ), + // Container list or empty state + Expanded( + child: filtered.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _searchQuery.isEmpty + ? Icons.inbox_outlined + : Icons.search_off, + size: 64, + color: colorScheme.outline, + ), + const SizedBox(height: 16), + Text( + _searchQuery.isEmpty + ? 'No containers found' + : 'No containers match "$_searchQuery"', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + if (_searchQuery.isNotEmpty) ...[ + const SizedBox(height: 8), + TextButton( + onPressed: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + child: const Text('Clear search'), + ), + ], + ], + ), + ) + : ListView.builder( + itemCount: filtered.length, + itemBuilder: (context, index) { + final container = filtered[index]; + return _ContainerListTile( + container: container, + onAction: (action) => widget.onAction(container, action), + onViewLogs: () => widget.onViewLogs(container), + ); + }, + ), + ), + ], + ); + } +} + +class _ContainerListTile extends StatelessWidget { + const _ContainerListTile({ + required this.container, + required this.onAction, + required this.onViewLogs, + }); + + final Container container; + final void Function(String) onAction; + final VoidCallback onViewLogs; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + // Status and name + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ContainerStatusBadge(state: container.state), + const SizedBox(width: 8), + Expanded( + child: Text( + container.name, + style: const TextStyle(fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + container.image, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // Ports + if (container.ports.isNotEmpty) + Expanded( + child: Text( + container.ports.map((p) => p.formatted).join(', '), + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + ), + overflow: TextOverflow.ellipsis, + ), + ), + // Status text + SizedBox( + width: 120, + child: Text( + container.status, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.end, + ), + ), + // Actions + const SizedBox(width: 8), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + tooltip: 'Actions', + onSelected: (action) { + if (action == 'logs') { + onViewLogs(); + } else { + onAction(action); + } + }, + itemBuilder: (context) => [ + if (container.canStart) + const PopupMenuItem( + value: 'start', + child: Row( + children: [ + Icon(Icons.play_arrow, size: 18), + SizedBox(width: 8), + Text('Start'), + ], + ), + ), + if (container.canStop) + const PopupMenuItem( + value: 'stop', + child: Row( + children: [ + Icon(Icons.stop, size: 18), + SizedBox(width: 8), + Text('Stop'), + ], + ), + ), + if (container.canRestart) + const PopupMenuItem( + value: 'restart', + child: Row( + children: [ + Icon(Icons.refresh, size: 18), + SizedBox(width: 8), + Text('Restart'), + ], + ), + ), + const PopupMenuItem( + value: 'logs', + child: Row( + children: [ + Icon(Icons.article, size: 18), + SizedBox(width: 8), + Text('View Logs'), + ], + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/control_room/containers/presentation/providers/containers_provider.dart b/lib/features/control_room/containers/presentation/providers/containers_provider.dart new file mode 100644 index 0000000..6098a16 --- /dev/null +++ b/lib/features/control_room/containers/presentation/providers/containers_provider.dart @@ -0,0 +1,92 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../stacks/presentation/providers/stacks_provider.dart'; +import '../../data/repositories/container_repository_impl.dart'; +import '../../domain/entities/container.dart'; + +part 'containers_provider.g.dart'; + +/// Provides all containers. +@riverpod +Future> allContainers(AllContainersRef ref) async { + final repository = ref.watch(containerRepositoryProvider); + return repository.getContainers(); +} + +/// Provides containers filtered by the selected stack. +@riverpod +Future> containers(ContainersRef ref) async { + final repository = ref.watch(containerRepositoryProvider); + final selectedStack = ref.watch(selectedStackProvider); + + if (selectedStack == null) { + return repository.getContainers(); + } + + return repository.getContainersByStack(selectedStack); +} + +/// Provides a single container by ID. +@riverpod +Future container(ContainerRef ref, String id) async { + final repository = ref.watch(containerRepositoryProvider); + return repository.getContainer(id); +} + +/// Controller for container actions. +@riverpod +class ContainerActions extends _$ContainerActions { + @override + AsyncValue build() => const AsyncValue.data(null); + + Future start(String id) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final repository = ref.read(containerRepositoryProvider); + await repository.startContainer(id); + ref.invalidate(allContainersProvider); + ref.invalidate(containersProvider); + }); + } + + Future stop(String id) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final repository = ref.read(containerRepositoryProvider); + await repository.stopContainer(id); + ref.invalidate(allContainersProvider); + ref.invalidate(containersProvider); + }); + } + + Future restart(String id) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final repository = ref.read(containerRepositoryProvider); + await repository.restartContainer(id); + ref.invalidate(allContainersProvider); + ref.invalidate(containersProvider); + }); + } + + Future remove(String id, {bool force = false}) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final repository = ref.read(containerRepositoryProvider); + await repository.removeContainer(id, force: force); + ref.invalidate(allContainersProvider); + ref.invalidate(containersProvider); + }); + } +} + +/// Container logs provider. +@riverpod +Future containerLogs( + ContainerLogsRef ref, + String id, { + int? tail = 100, +}) async { + final repository = ref.watch(containerRepositoryProvider); + return repository.getContainerLogs(id, tail: tail); +} diff --git a/lib/features/control_room/containers/presentation/widgets/container_logs_viewer.dart b/lib/features/control_room/containers/presentation/widgets/container_logs_viewer.dart new file mode 100644 index 0000000..5971092 --- /dev/null +++ b/lib/features/control_room/containers/presentation/widgets/container_logs_viewer.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/containers_provider.dart'; + +/// Widget for displaying container logs. +class ContainerLogsViewer extends ConsumerStatefulWidget { + const ContainerLogsViewer({ + super.key, + required this.containerId, + required this.containerName, + }); + + final String containerId; + final String containerName; + + @override + ConsumerState createState() => + _ContainerLogsViewerState(); +} + +class _ContainerLogsViewerState extends ConsumerState { + final _scrollController = ScrollController(); + int _tailLines = 100; + bool _autoScroll = true; + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final logsAsync = ref.watch( + containerLogsProvider(widget.containerId, tail: _tailLines), + ); + final colorScheme = Theme.of(context).colorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Toolbar + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + border: Border( + bottom: BorderSide(color: colorScheme.outlineVariant), + ), + ), + child: Row( + children: [ + Text( + widget.containerName, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + // Tail lines selector + DropdownButton( + value: _tailLines, + items: const [ + DropdownMenuItem(value: 50, child: Text('50 lines')), + DropdownMenuItem(value: 100, child: Text('100 lines')), + DropdownMenuItem(value: 500, child: Text('500 lines')), + DropdownMenuItem(value: 1000, child: Text('1000 lines')), + ], + onChanged: (value) { + if (value != null) { + setState(() => _tailLines = value); + } + }, + underline: const SizedBox.shrink(), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.copy, size: 18), + tooltip: 'Copy logs', + onPressed: logsAsync.whenOrNull( + data: (logs) => () async { + await Clipboard.setData(ClipboardData(text: logs)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Logs copied to clipboard')), + ); + } + }, + ), + ), + IconButton( + icon: const Icon(Icons.refresh, size: 18), + tooltip: 'Refresh', + onPressed: () { + ref.invalidate( + containerLogsProvider(widget.containerId, tail: _tailLines), + ); + }, + ), + ], + ), + ), + // Logs content + Expanded( + child: logsAsync.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 logs'), + const SizedBox(height: 8), + Text( + error.toString(), + style: TextStyle(color: colorScheme.outline), + ), + ], + ), + ), + data: (logs) => logs.isEmpty + ? Center( + child: Text( + 'No logs available', + style: TextStyle(color: colorScheme.outline), + ), + ) + : Container( + color: Colors.black87, + padding: const EdgeInsets.all(12), + child: SelectableText( + logs, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: Colors.white70, + height: 1.4, + ), + ), + ), + ), + ), + ], + ); + } +} + +/// Shows container logs in a bottom sheet. +void showContainerLogs( + BuildContext context, { + required String containerId, + required String containerName, +}) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.3, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => ContainerLogsViewer( + containerId: containerId, + containerName: containerName, + ), + ), + ); +} diff --git a/lib/features/control_room/containers/presentation/widgets/container_status_badge.dart b/lib/features/control_room/containers/presentation/widgets/container_status_badge.dart new file mode 100644 index 0000000..9f48624 --- /dev/null +++ b/lib/features/control_room/containers/presentation/widgets/container_status_badge.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart' hide Container; + +import '../../domain/entities/container.dart'; + +/// Status badge for container state. +class ContainerStatusBadge extends StatelessWidget { + const ContainerStatusBadge({ + super.key, + required this.state, + this.showLabel = true, + }); + + final ContainerState state; + final bool showLabel; + + @override + Widget build(BuildContext context) { + final (color, icon, label) = _getStateStyle(context); + + return DecoratedBox( + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + if (showLabel) ...[ + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: color, + ), + ), + ], + ], + ), + ), + ); + } + + (Color, IconData, String) _getStateStyle(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return switch (state) { + ContainerState.running => ( + Colors.green, + Icons.play_circle, + 'Running', + ), + ContainerState.paused => ( + Colors.orange, + Icons.pause_circle, + 'Paused', + ), + ContainerState.restarting => ( + Colors.blue, + Icons.refresh, + 'Restarting', + ), + ContainerState.exited => ( + colorScheme.outline, + Icons.stop_circle, + 'Exited', + ), + ContainerState.created => ( + colorScheme.outline, + Icons.circle_outlined, + 'Created', + ), + ContainerState.removing => ( + Colors.red, + Icons.delete, + 'Removing', + ), + ContainerState.dead => ( + colorScheme.error, + Icons.error, + 'Dead', + ), + }; + } +} diff --git a/lib/features/control_room/presentation/pages/control_room_page.dart b/lib/features/control_room/presentation/pages/control_room_page.dart new file mode 100644 index 0000000..e2328df --- /dev/null +++ b/lib/features/control_room/presentation/pages/control_room_page.dart @@ -0,0 +1,375 @@ +import 'package:flutter/material.dart' hide Stack; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; + +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/providers/stacks_provider.dart'; +import '../../stacks/presentation/widgets/stack_list_tile.dart'; + +/// Main Control Room page with stacks sidebar and containers view. +class ControlRoomPage extends ConsumerWidget { + const ControlRoomPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + body: Row( + children: [ + // Stacks sidebar + const _StacksSidebar(), + // Divider + VerticalDivider( + width: 1, + thickness: 1, + color: Theme.of(context).colorScheme.outlineVariant, + ), + // Main content area + const Expanded( + child: ContainersListPage(), + ), + ], + ), + ); + } +} + +/// Sidebar showing stacks list. +class _StacksSidebar extends ConsumerWidget { + const _StacksSidebar(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final stacksAsync = ref.watch(stacksProvider); + final selectedStack = ref.watch(selectedStackProvider); + final colorScheme = Theme.of(context).colorScheme; + + return SizedBox( + width: 280, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + 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), + Text( + 'Stacks', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.refresh, size: 20), + tooltip: 'Refresh stacks', + onPressed: () => ref.invalidate(stacksProvider), + ), + ], + ), + ), + // All containers option + ListTile( + selected: selectedStack == null, + selectedTileColor: colorScheme.primaryContainer.withValues(alpha: 0.3), + leading: Icon( + Icons.all_inbox, + color: selectedStack == null + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + title: const Text('All Containers'), + onTap: () => ref.read(selectedStackProvider.notifier).clear(), + ), + const Divider(height: 1), + // Stacks list + Expanded( + child: stacksAsync.when( + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, _) => Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + color: colorScheme.error, + ), + const SizedBox(height: 8), + Text( + 'Failed to load stacks', + style: TextStyle(color: colorScheme.error), + ), + ], + ), + ), + ), + data: (stacks) => _StacksList( + stacks: stacks, + selectedStackId: selectedStack, + onStackSelected: (id) => + ref.read(selectedStackProvider.notifier).select(id), + onStackAction: (id, action) => + _handleStackAction(context, ref, id, action), + ), + ), + ), + // External links + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'External', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.outline, + ), + ), + const SizedBox(height: 4), + Wrap( + spacing: 4, + children: [ + _ExternalLinkChip( + label: 'Portainer', + icon: Icons.dashboard, + onTap: () => launchUrl( + Uri.parse(AppConfig.portainerUrl), + mode: LaunchMode.externalApplication, + ), + ), + _ExternalLinkChip( + label: 'Netdata', + icon: Icons.analytics, + onTap: () => launchUrl( + Uri.parse(AppConfig.netdataUrl), + mode: LaunchMode.externalApplication, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + Future _handleStackAction( + BuildContext context, + WidgetRef ref, + String stackId, + String action, + ) async { + final repository = ref.read(stackRepositoryProvider); + final messenger = ScaffoldMessenger.of(context); + final colorScheme = Theme.of(context).colorScheme; + + try { + switch (action) { + case 'start': + await repository.startStack(stackId); + case 'stop': + await repository.stopStack(stackId); + case 'restart': + await repository.restartStack(stackId); + } + + // Refresh data + ref.invalidate(stacksProvider); + + // Show success snackbar + messenger.showSnackBar( + SnackBar( + content: Text('Stack ${action}ed successfully'), + backgroundColor: colorScheme.primaryContainer, + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), + ); + } catch (e) { + messenger.showSnackBar( + SnackBar( + content: Row( + children: [ + Icon(Icons.error_outline, color: colorScheme.onErrorContainer), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Failed to $action stack: ${e.toString()}', + style: TextStyle(color: colorScheme.onErrorContainer), + ), + ), + ], + ), + backgroundColor: colorScheme.errorContainer, + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 4), + ), + ); + } + } +} + +class _StacksList extends StatefulWidget { + const _StacksList({ + required this.stacks, + required this.selectedStackId, + required this.onStackSelected, + required this.onStackAction, + }); + + final List stacks; + final String? selectedStackId; + final void Function(String) onStackSelected; + final void Function(String, String) onStackAction; + + @override + State<_StacksList> createState() => _StacksListState(); +} + +class _StacksListState extends State<_StacksList> { + final _searchController = TextEditingController(); + String _searchQuery = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + List get _filteredStacks { + if (_searchQuery.isEmpty) return widget.stacks; + final query = _searchQuery.toLowerCase(); + return widget.stacks.where((s) { + return s.name.toLowerCase().contains(query); + }).toList(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final filtered = _filteredStacks; + + return Column( + children: [ + // Search field + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search stacks...', + prefixIcon: const Icon(Icons.search, size: 18), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 16), + onPressed: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + ) + : null, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + style: const TextStyle(fontSize: 13), + onChanged: (value) => setState(() => _searchQuery = value), + ), + ), + // Stack list or empty state + Expanded( + child: filtered.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _searchQuery.isEmpty + ? Icons.layers_clear + : Icons.search_off, + size: 32, + color: colorScheme.outline, + ), + const SizedBox(height: 8), + Text( + _searchQuery.isEmpty + ? 'No stacks found' + : 'No stacks match "$_searchQuery"', + style: TextStyle( + color: colorScheme.outline, + fontSize: 13, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ) + : ListView.builder( + itemCount: filtered.length, + itemBuilder: (context, index) { + final stack = filtered[index]; + return StackListTile( + stack: stack, + isSelected: stack.id == widget.selectedStackId, + onTap: () => widget.onStackSelected(stack.id), + onStart: () => widget.onStackAction(stack.id, 'start'), + onStop: () => widget.onStackAction(stack.id, 'stop'), + onRestart: () => widget.onStackAction(stack.id, 'restart'), + ); + }, + ), + ), + ], + ); + } +} + +class _ExternalLinkChip extends StatelessWidget { + const _ExternalLinkChip({ + required this.label, + required this.icon, + required this.onTap, + }); + + final String label; + final IconData icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ActionChip( + avatar: Icon(icon, size: 16), + label: Text(label), + onPressed: onTap, + ); + } +} diff --git a/lib/features/control_room/stacks/data/datasources/stacks_datasource.dart b/lib/features/control_room/stacks/data/datasources/stacks_datasource.dart new file mode 100644 index 0000000..52178f5 --- /dev/null +++ b/lib/features/control_room/stacks/data/datasources/stacks_datasource.dart @@ -0,0 +1,84 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../containers/data/datasources/containers_datasource.dart'; +import '../../../containers/domain/entities/container.dart'; +import '../../domain/entities/stack.dart'; + +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. +class StacksDatasource { + StacksDatasource(this._containersDatasource); + + final ContainersDatasource _containersDatasource; + + /// Gets all stacks by aggregating container data. + Future> getStacks() async { + final containers = await _containersDatasource.getContainers(); + final containerEntities = containers.map((c) => c.toEntity()).toList(); + + // Group containers by stack name + final stackMap = >{}; + + for (final container in containerEntities) { + final stackName = container.stackName; + if (stackName != null) { + stackMap.putIfAbsent(stackName, () => []).add(container); + } + } + + // Convert to Stack entities + return stackMap.entries.map((entry) { + final name = entry.key; + final stackContainers = entry.value; + final runningCount = stackContainers.where((c) => c.isRunning).length; + + return Stack( + id: name, // Use name as ID for compose stacks + name: name, + type: StackType.compose, + status: runningCount == stackContainers.length + ? StackStatus.active + : runningCount > 0 + ? StackStatus.active + : StackStatus.inactive, + containerCount: stackContainers.length, + runningCount: runningCount, + ); + }).toList() + ..sort((a, b) => a.name.compareTo(b.name)); + } + + /// Gets a single stack by ID. + Future getStack(String id) async { + final stacks = await getStacks(); + return stacks.firstWhere( + (s) => s.id == id, + orElse: () => throw Exception('Stack not found: $id'), + ); + } + + /// Performs an action on all containers in a stack. + Future stackAction(String stackId, String action) async { + final containers = await _containersDatasource.getContainers(); + final stackContainers = containers + .map((c) => c.toEntity()) + .where((c) => c.stackId == stackId) + .toList(); + + for (final container in stackContainers) { + await _containersDatasource.containerAction(container.fullId, action); + } + } +} + +/// Provides the stacks datasource. +@riverpod +StacksDatasource stacksDatasource(StacksDatasourceRef ref) { + final containersDatasource = ref.watch(containersDatasourceProvider); + return StacksDatasource(containersDatasource); +} diff --git a/lib/features/control_room/stacks/data/models/stack_model.dart b/lib/features/control_room/stacks/data/models/stack_model.dart new file mode 100644 index 0000000..df81167 --- /dev/null +++ b/lib/features/control_room/stacks/data/models/stack_model.dart @@ -0,0 +1,62 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../domain/entities/stack.dart'; + +part 'stack_model.freezed.dart'; +part 'stack_model.g.dart'; + +/// Stack data model for API serialization. +@freezed +class StackModel with _$StackModel { + const factory StackModel({ + required String id, + required String name, + @JsonKey(name: 'type') String? typeString, + @JsonKey(name: 'status') String? statusString, + @JsonKey(name: 'container_count') @Default(0) int containerCount, + @JsonKey(name: 'running_count') @Default(0) int runningCount, + @JsonKey(name: 'compose_file') String? composeFile, + String? environment, + @JsonKey(name: 'created_at') String? createdAt, + @JsonKey(name: 'updated_at') String? updatedAt, + }) = _StackModel; + + const StackModel._(); + + factory StackModel.fromJson(Map json) => + _$StackModelFromJson(json); + + /// Converts to domain entity. + Stack toEntity() { + return Stack( + id: id, + name: name, + type: _parseStackType(typeString), + status: _parseStackStatus(statusString), + containerCount: containerCount, + runningCount: runningCount, + composeFile: composeFile, + environment: environment, + createdAt: createdAt != null ? DateTime.tryParse(createdAt!) : null, + updatedAt: updatedAt != null ? DateTime.tryParse(updatedAt!) : null, + ); + } + + StackType _parseStackType(String? type) { + return switch (type?.toLowerCase()) { + 'compose' => StackType.compose, + 'swarm' => StackType.swarm, + 'kubernetes' || 'k8s' => StackType.kubernetes, + _ => StackType.compose, + }; + } + + StackStatus _parseStackStatus(String? status) { + return switch (status?.toLowerCase()) { + 'active' || 'running' => StackStatus.active, + 'inactive' || 'stopped' => StackStatus.inactive, + 'error' => StackStatus.error, + _ => StackStatus.unknown, + }; + } +} 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 new file mode 100644 index 0000000..682a87d --- /dev/null +++ b/lib/features/control_room/stacks/data/repositories/stack_repository_impl.dart @@ -0,0 +1,52 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../domain/entities/stack.dart'; +import '../../domain/repositories/stack_repository.dart'; +import '../datasources/stacks_datasource.dart'; + +part 'stack_repository_impl.g.dart'; + +/// Implementation of StackRepository. +class StackRepositoryImpl implements StackRepository { + StackRepositoryImpl(this._datasource); + + final StacksDatasource _datasource; + + @override + Future> getStacks() async { + return _datasource.getStacks(); + } + + @override + Future getStack(String id) async { + return _datasource.getStack(id); + } + + @override + Future startStack(String id) async { + await _datasource.stackAction(id, 'start'); + } + + @override + Future stopStack(String id) async { + await _datasource.stackAction(id, 'stop'); + } + + @override + Future restartStack(String id) async { + await _datasource.stackAction(id, 'restart'); + } + + @override + Future removeStack(String id) async { + // TODO: Implement stack removal + throw UnimplementedError('Stack removal not yet implemented'); + } +} + +/// Provides the stack repository. +@riverpod +StackRepository stackRepository(StackRepositoryRef ref) { + final datasource = ref.watch(stacksDatasourceProvider); + return StackRepositoryImpl(datasource); +} diff --git a/lib/features/control_room/stacks/domain/entities/stack.dart b/lib/features/control_room/stacks/domain/entities/stack.dart new file mode 100644 index 0000000..575d42a --- /dev/null +++ b/lib/features/control_room/stacks/domain/entities/stack.dart @@ -0,0 +1,66 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'stack.freezed.dart'; + +/// Container orchestration stack (Docker Compose stack). +@freezed +class Stack with _$Stack { + const factory Stack({ + /// Unique stack identifier. + required String id, + + /// Stack name. + required String name, + + /// Stack type (compose, swarm, kubernetes). + @Default(StackType.compose) StackType type, + + /// Current stack status. + @Default(StackStatus.unknown) StackStatus status, + + /// Number of containers in the stack. + @Default(0) int containerCount, + + /// Number of running containers. + @Default(0) int runningCount, + + /// Path to the compose file (if applicable). + String? composeFile, + + /// Environment name (e.g., production, staging). + String? environment, + + /// When the stack was created. + DateTime? createdAt, + + /// When the stack was last updated. + DateTime? updatedAt, + }) = _Stack; + + const Stack._(); + + /// Whether all containers in the stack are running. + bool get isHealthy => runningCount == containerCount && containerCount > 0; + + /// Whether the stack has any running containers. + bool get hasRunningContainers => runningCount > 0; + + /// Whether the stack is partially running. + bool get isPartial => + runningCount > 0 && runningCount < containerCount; +} + +/// Stack orchestration type. +enum StackType { + compose, + swarm, + kubernetes, +} + +/// Stack status. +enum StackStatus { + active, + inactive, + error, + unknown, +} diff --git a/lib/features/control_room/stacks/domain/repositories/stack_repository.dart b/lib/features/control_room/stacks/domain/repositories/stack_repository.dart new file mode 100644 index 0000000..fba6ad8 --- /dev/null +++ b/lib/features/control_room/stacks/domain/repositories/stack_repository.dart @@ -0,0 +1,22 @@ +import '../entities/stack.dart'; + +/// Repository interface for stack operations. +abstract class StackRepository { + /// Gets all stacks. + Future> getStacks(); + + /// Gets a single stack by ID. + Future getStack(String id); + + /// Starts all containers in a stack. + Future startStack(String id); + + /// Stops all containers in a stack. + Future stopStack(String id); + + /// Restarts all containers in a stack. + Future restartStack(String id); + + /// Removes a stack (stops and removes containers). + Future removeStack(String id); +} diff --git a/lib/features/control_room/stacks/presentation/providers/stacks_provider.dart b/lib/features/control_room/stacks/presentation/providers/stacks_provider.dart new file mode 100644 index 0000000..64fd64d --- /dev/null +++ b/lib/features/control_room/stacks/presentation/providers/stacks_provider.dart @@ -0,0 +1,35 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../data/repositories/stack_repository_impl.dart'; +import '../../domain/entities/stack.dart'; + +part 'stacks_provider.g.dart'; + +/// Provides the list of all stacks. +@riverpod +Future> stacks(StacksRef ref) async { + final repository = ref.watch(stackRepositoryProvider); + return repository.getStacks(); +} + +/// Provides a single stack by ID. +@riverpod +Future stack(StackRef ref, String id) async { + final repository = ref.watch(stackRepositoryProvider); + return repository.getStack(id); +} + +/// Currently selected stack ID (null = all containers). +@riverpod +class SelectedStack extends _$SelectedStack { + @override + String? build() => null; + + void select(String? stackId) { + state = stackId; + } + + void clear() { + state = null; + } +} diff --git a/lib/features/control_room/stacks/presentation/widgets/stack_list_tile.dart b/lib/features/control_room/stacks/presentation/widgets/stack_list_tile.dart new file mode 100644 index 0000000..313cb50 --- /dev/null +++ b/lib/features/control_room/stacks/presentation/widgets/stack_list_tile.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart' hide Stack; + +import '../../domain/entities/stack.dart'; + +/// List tile for displaying a stack in the sidebar. +class StackListTile extends StatelessWidget { + const StackListTile({ + super.key, + required this.stack, + required this.isSelected, + required this.onTap, + this.onStart, + this.onStop, + this.onRestart, + }); + + final Stack stack; + final bool isSelected; + final VoidCallback onTap; + final VoidCallback? onStart; + final VoidCallback? onStop; + final VoidCallback? onRestart; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return ListTile( + selected: isSelected, + selectedTileColor: colorScheme.primaryContainer.withValues(alpha: 0.3), + leading: _buildStatusIndicator(context), + title: Text( + stack.name, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + subtitle: Text( + '${stack.runningCount}/${stack.containerCount} containers', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ), + trailing: _buildActions(context), + onTap: onTap, + ); + } + + Widget _buildStatusIndicator(BuildContext context) { + final color = switch (stack.status) { + StackStatus.active when stack.isHealthy => Colors.green, + StackStatus.active when stack.isPartial => Colors.orange, + StackStatus.active => Colors.green, + StackStatus.inactive => Theme.of(context).colorScheme.outline, + StackStatus.error => Theme.of(context).colorScheme.error, + StackStatus.unknown => Theme.of(context).colorScheme.outline, + }; + + return Container( + width: 12, + height: 12, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + boxShadow: [ + if (stack.isHealthy) + BoxShadow( + color: color.withValues(alpha: 0.4), + blurRadius: 4, + spreadRadius: 1, + ), + ], + ), + ); + } + + Widget? _buildActions(BuildContext context) { + if (onStart == null && onStop == null && onRestart == null) { + return null; + } + + return PopupMenuButton( + icon: const Icon(Icons.more_vert, size: 18), + tooltip: 'Stack actions', + onSelected: (action) { + switch (action) { + case 'start': + onStart?.call(); + case 'stop': + onStop?.call(); + case 'restart': + onRestart?.call(); + } + }, + itemBuilder: (context) => [ + if (onStart != null && !stack.isHealthy) + const PopupMenuItem( + value: 'start', + child: Row( + children: [ + Icon(Icons.play_arrow, size: 18), + SizedBox(width: 8), + Text('Start'), + ], + ), + ), + if (onStop != null && stack.hasRunningContainers) + const PopupMenuItem( + value: 'stop', + child: Row( + children: [ + Icon(Icons.stop, size: 18), + SizedBox(width: 8), + Text('Stop'), + ], + ), + ), + if (onRestart != null && stack.hasRunningContainers) + const PopupMenuItem( + value: 'restart', + child: Row( + children: [ + Icon(Icons.refresh, size: 18), + SizedBox(width: 8), + Text('Restart'), + ], + ), + ), + ], + ); + } +} diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index d72803f..d14fc58 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -2,8 +2,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../shared/layouts/app_scaffold.dart'; +import '../features/control_room/presentation/pages/control_room_page.dart'; import '../features/front_hall/presentation/pages/front_hall_page.dart'; +import '../shared/layouts/app_scaffold.dart'; part 'app_router.g.dart'; @@ -35,26 +36,7 @@ GoRouter appRouter(AppRouterRef ref) { GoRoute( path: AppRoutes.controlRoom, name: 'controlRoom', - builder: (context, state) => - const _PlaceholderPage(title: 'Control Room'), - routes: [ - GoRoute( - path: 'containers', - name: 'containers', - builder: (context, state) => - const _PlaceholderPage(title: 'Containers'), - routes: [ - GoRoute( - path: ':id', - name: 'containerDetail', - builder: (context, state) { - final id = state.pathParameters['id']!; - return _PlaceholderPage(title: 'Container: $id'); - }, - ), - ], - ), - ], + builder: (context, state) => const ControlRoomPage(), ), GoRoute( path: AppRoutes.parlor, diff --git a/lib/shared/components/data_grid/adapters/core_api_source.dart b/lib/shared/components/data_grid/adapters/core_api_source.dart new file mode 100644 index 0000000..6404d23 --- /dev/null +++ b/lib/shared/components/data_grid/adapters/core_api_source.dart @@ -0,0 +1,130 @@ +import 'package:dio/dio.dart'; + +import '../data_grid_source.dart'; + +/// Data source adapter for Core API endpoints. +/// +/// Implements the DataGrid data source interface for fetching data +/// from the Core API with support for search, sorting, and pagination. +/// +/// Example: +/// ```dart +/// final containersSource = CoreApiDataSource( +/// dio: dio, +/// endpoint: '/infrastructure/containers', +/// fromJson: Container.fromJson, +/// searchParam: 'search', +/// sortParam: 'sort_by', +/// orderParam: 'order', +/// ); +/// ``` +class CoreApiDataSource extends DataGridSource { + CoreApiDataSource({ + required this.dio, + required this.endpoint, + required this.fromJson, + this.searchParam = 'search', + this.sortParam = 'sort', + this.orderParam = 'order', + this.offsetParam = 'offset', + this.limitParam = 'limit', + this.itemsKey = 'items', + this.totalCountKey = 'total', + }); + + /// Dio HTTP client instance. + final Dio dio; + + /// API endpoint path. + final String endpoint; + + /// Function to parse JSON into the item type. + final T Function(Map json) fromJson; + + /// Query parameter name for search. + final String searchParam; + + /// Query parameter name for sort field. + final String sortParam; + + /// Query parameter name for sort order. + final String orderParam; + + /// Query parameter name for pagination offset. + final String offsetParam; + + /// Query parameter name for pagination limit. + final String limitParam; + + /// JSON key for items array in response. + final String itemsKey; + + /// JSON key for total count in response. + final String totalCountKey; + + @override + Future> fetch({ + String? searchQuery, + String? sortField, + bool sortDescending = false, + int? offset, + int? limit, + }) async { + final queryParams = {}; + + if (searchQuery != null && searchQuery.isNotEmpty) { + queryParams[searchParam] = searchQuery; + } + + if (sortField != null) { + queryParams[sortParam] = sortField; + queryParams[orderParam] = sortDescending ? 'desc' : 'asc'; + } + + if (offset != null) { + queryParams[offsetParam] = offset; + } + + if (limit != null) { + queryParams[limitParam] = limit; + } + + final response = await dio.get>( + endpoint, + queryParameters: queryParams, + ); + + final data = response.data!; + + // Handle both paginated and wrapped responses + List itemsJson; + int totalCount; + + if (data.containsKey(itemsKey)) { + // Paginated response: { items: [...], total: N } + itemsJson = data[itemsKey] as List; + totalCount = data[totalCountKey] as int? ?? itemsJson.length; + } else { + // Try common wrapper patterns: { data: [...] } or { results: [...] } + itemsJson = (data['data'] ?? data['results'] ?? []) as List; + totalCount = data['count'] as int? ?? + data['total'] as int? ?? + data['totalCount'] as int? ?? + itemsJson.length; + } + + final items = itemsJson + .map((json) => fromJson(json as Map)) + .toList(); + + final hasMore = offset != null && limit != null + ? (offset + items.length) < totalCount + : false; + + return DataGridResult( + items: items, + totalCount: totalCount, + hasMore: hasMore, + ); + } +} diff --git a/lib/shared/components/data_grid/data_grid.dart b/lib/shared/components/data_grid/data_grid.dart new file mode 100644 index 0000000..5b6795c --- /dev/null +++ b/lib/shared/components/data_grid/data_grid.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'data_grid_config.dart'; +import 'data_grid_provider.dart'; +import 'data_grid_source.dart'; +import 'data_grid_state.dart'; +import 'widgets/data_grid_bulk_actions.dart'; +import 'widgets/data_grid_empty_state.dart'; +import 'widgets/data_grid_footer.dart'; +import 'widgets/data_grid_header.dart'; +import 'widgets/data_grid_row.dart'; +import 'widgets/data_grid_search_bar.dart'; + +/// A configurable data grid widget for displaying tabular data. +/// +/// Features: +/// - Sortable columns +/// - Row selection (single and bulk) +/// - Per-row and bulk actions +/// - Search/filtering +/// - Pagination or infinite scroll +/// - Custom cell rendering +/// - Empty, loading, and error states +/// +/// Usage: +/// ```dart +/// DataGrid( +/// provider: containersGridProvider, +/// config: containersGridConfig, +/// idSelector: (c) => c.id, +/// ) +/// ``` +class DataGrid extends ConsumerWidget { + const DataGrid({ + super.key, + required this.provider, + required this.config, + required this.idSelector, + this.toolbarActions, + }); + + /// The Riverpod provider for this grid's state. + final StateNotifierProvider, DataGridState> provider; + + /// Grid configuration. + final DataGridConfig config; + + /// Function to extract unique ID from an item. + final Object Function(T item) idSelector; + + /// Additional actions to show in the toolbar (next to search). + final List? toolbarActions; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(provider); + final controller = ref.read(provider.notifier); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Toolbar: search + bulk actions + custom actions + if (config.enableSearch || + (state.selectedCount > 0 && config.bulkActions.isNotEmpty) || + toolbarActions != null) + _buildToolbar(context, state, controller), + + // Header + if (config.showHeader) + DataGridHeader( + config: config, + sortColumnIndex: state.sortColumnIndex, + sortDescending: state.sortDescending, + onSort: controller.sortBy, + showCheckbox: config.rowsSelectable, + allSelected: state.allSelected, + someSelected: state.someSelected, + onSelectAll: controller.toggleSelectAll, + ), + + // Content area + Expanded( + child: _buildContent(context, state, controller), + ), + + // Footer + if (config.showFooter) + DataGridFooter( + totalCount: state.totalCount, + displayedCount: state.items.length, + dataMode: config.dataMode, + currentPage: state.currentPage, + onPageChange: controller.goToPage, + isLoading: state.isLoading, + ), + ], + ); + } + + Widget _buildToolbar( + BuildContext context, + DataGridState state, + DataGridController controller, + ) { + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + if (config.enableSearch) + DataGridSearchBar( + onSearch: controller.search, + onClear: controller.clearSearch, + hintText: config.searchHint, + initialValue: state.searchQuery, + ), + if (state.selectedCount > 0 && config.bulkActions.isNotEmpty) ...[ + const SizedBox(width: 16), + Expanded( + child: DataGridBulkActions( + selectedCount: state.selectedCount, + bulkActions: config.bulkActions, + onClearSelection: controller.clearSelection, + getSelectedItems: controller.getSelectedItems, + ), + ), + ] else ...[ + const Spacer(), + ], + if (toolbarActions != null) ...toolbarActions!, + ], + ), + ); + } + + Widget _buildContent( + BuildContext context, + DataGridState state, + DataGridController controller, + ) { + // Initial loading state + if (state.isInitialLoad && state.isLoading) { + return config.loadingBuilder?.call(context) ?? + const DataGridLoadingState(); + } + + // Error state + if (state.hasError && state.items.isEmpty) { + return config.errorBuilder?.call(context, state.error!, controller.refresh) ?? + DataGridErrorState( + error: state.error!, + onRetry: controller.refresh, + ); + } + + // Empty state + if (state.isEmpty) { + return config.emptyStateBuilder?.call(context) ?? + DataGridEmptyState( + title: state.searchQuery.isNotEmpty + ? 'No results found' + : 'No items found', + subtitle: state.searchQuery.isNotEmpty + ? 'Try a different search term' + : null, + ); + } + + // Data rows + return _buildListView(state, controller); + } + + Widget _buildListView( + DataGridState state, + DataGridController controller, + ) { + final isInfiniteScroll = config.dataMode is InfiniteDataMode; + + return NotificationListener( + onNotification: (notification) { + if (isInfiniteScroll && + notification is ScrollEndNotification && + notification.metrics.extentAfter < 200) { + controller.loadMore(); + } + return false; + }, + child: ListView.builder( + itemCount: state.items.length + (state.isLoading && !state.isInitialLoad ? 1 : 0), + itemBuilder: (context, index) { + // Loading indicator at bottom for infinite scroll + if (index >= state.items.length) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + + final item = state.items[index]; + final isSelected = controller.isSelected(item); + + return DataGridRow( + item: item, + index: index, + config: config, + isSelected: isSelected, + onSelect: () => controller.toggleSelection(item), + onTap: config.onRowTap != null ? () => config.onRowTap!(item) : null, + backgroundColor: config.rowColor?.call(context, item, index), + ); + }, + ), + ); + } +} diff --git a/lib/shared/components/data_grid/data_grid_action.dart b/lib/shared/components/data_grid/data_grid_action.dart new file mode 100644 index 0000000..ed50416 --- /dev/null +++ b/lib/shared/components/data_grid/data_grid_action.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +/// Per-row action for DataGrid. +class DataGridAction { + const DataGridAction({ + required this.icon, + required this.label, + required this.onTap, + this.showWhen, + this.destructive = false, + this.requiresConfirmation = false, + this.confirmationMessage, + }); + + /// Icon to display in the action menu. + final IconData icon; + + /// Label for the action. + final String label; + + /// Callback when the action is triggered. + final Future Function(T item) onTap; + + /// Condition to show/hide this action for specific items. + final bool Function(T item)? showWhen; + + /// Whether this is a destructive action (styled differently). + final bool destructive; + + /// Whether to show a confirmation dialog before executing. + final bool requiresConfirmation; + + /// Custom confirmation message. Defaults to "Are you sure?". + final String? confirmationMessage; + + /// Checks if this action should be shown for the given item. + bool shouldShow(T item) => showWhen?.call(item) ?? true; +} + +/// Bulk action for selected rows in DataGrid. +class DataGridBulkAction { + const DataGridBulkAction({ + required this.icon, + required this.label, + required this.onTap, + this.minSelected = 1, + this.maxSelected, + this.destructive = false, + this.requiresConfirmation = false, + this.confirmationMessage, + }); + + /// Icon to display. + final IconData icon; + + /// Label for the action. + final String label; + + /// Callback when the action is triggered with selected items. + final Future Function(List items) onTap; + + /// Minimum number of items that must be selected. + final int minSelected; + + /// Maximum number of items that can be selected (null = no limit). + final int? maxSelected; + + /// Whether this is a destructive action. + final bool destructive; + + /// Whether to show a confirmation dialog before executing. + final bool requiresConfirmation; + + /// Custom confirmation message. + final String? confirmationMessage; + + /// Checks if this action is available for the given selection count. + bool isAvailable(int selectedCount) { + if (selectedCount < minSelected) return false; + if (maxSelected != null && selectedCount > maxSelected!) return false; + return true; + } +} diff --git a/lib/shared/components/data_grid/data_grid_column.dart b/lib/shared/components/data_grid/data_grid_column.dart new file mode 100644 index 0000000..e6255b1 --- /dev/null +++ b/lib/shared/components/data_grid/data_grid_column.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; + +/// Column width specification for DataGrid columns. +sealed class DataGridColumnWidth { + const DataGridColumnWidth._(); + + /// Fixed width in logical pixels. + const factory DataGridColumnWidth.fixed(double width) = GridFixedWidth; + + /// Flexible width with flex factor (like Expanded). + const factory DataGridColumnWidth.flex([int flex]) = GridFlexWidth; + + /// Fraction of available width (0.0 to 1.0). + const factory DataGridColumnWidth.fraction(double fraction) = + GridFractionWidth; +} + +/// Fixed column width. +final class GridFixedWidth extends DataGridColumnWidth { + const GridFixedWidth(this.width) : super._(); + final double width; +} + +/// Flexible column width. +final class GridFlexWidth extends DataGridColumnWidth { + const GridFlexWidth([this.flex = 1]) : super._(); + final int flex; +} + +/// Fractional column width. +final class GridFractionWidth extends DataGridColumnWidth { + const GridFractionWidth(this.fraction) + : assert(fraction > 0 && fraction <= 1), + super._(); + final double fraction; +} + +/// Column alignment options. +enum DataGridColumnAlignment { + start, + center, + end, +} + +/// Column definition for DataGrid. +class DataGridColumn { + const DataGridColumn({ + required this.header, + required this.valueBuilder, + this.cellBuilder, + this.cellControlsBuilder, + this.width = const DataGridColumnWidth.flex(1), + this.alignment = DataGridColumnAlignment.start, + this.sortable = false, + this.sortField, + this.searchable = false, + this.visible = true, + this.tooltip, + }); + + /// Column header text. + final String header; + + /// Extracts the string value from an item for sorting/searching. + final String Function(T item) valueBuilder; + + /// Custom cell widget builder. If null, displays valueBuilder result as text. + final Widget Function(BuildContext context, T item)? cellBuilder; + + /// Additional controls to show in the cell (e.g., quick actions). + final Widget Function(BuildContext context, T item)? cellControlsBuilder; + + /// Column width specification. + final DataGridColumnWidth width; + + /// Text alignment within the column. + final DataGridColumnAlignment alignment; + + /// Whether this column can be sorted. + final bool sortable; + + /// API field name for server-side sorting. Defaults to using header if null. + final String? sortField; + + /// Whether this column is included in search. + final bool searchable; + + /// Whether this column is visible. + final bool visible; + + /// Tooltip builder for cell hover. + final String Function(T item)? tooltip; + + /// Gets the effective sort field name. + String get effectiveSortField => sortField ?? header.toLowerCase(); + + /// Converts alignment enum to CrossAxisAlignment. + CrossAxisAlignment get crossAxisAlignment => switch (alignment) { + DataGridColumnAlignment.start => CrossAxisAlignment.start, + DataGridColumnAlignment.center => CrossAxisAlignment.center, + DataGridColumnAlignment.end => CrossAxisAlignment.end, + }; + + /// Converts alignment enum to TextAlign. + TextAlign get textAlign => switch (alignment) { + DataGridColumnAlignment.start => TextAlign.start, + DataGridColumnAlignment.center => TextAlign.center, + DataGridColumnAlignment.end => TextAlign.end, + }; +} diff --git a/lib/shared/components/data_grid/data_grid_config.dart b/lib/shared/components/data_grid/data_grid_config.dart new file mode 100644 index 0000000..e8fb89b --- /dev/null +++ b/lib/shared/components/data_grid/data_grid_config.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; + +import 'data_grid_action.dart'; +import 'data_grid_column.dart'; + +/// Data loading mode for the grid. +sealed class DataGridDataMode { + const DataGridDataMode._(); + + /// Load all data at once. + const factory DataGridDataMode.all() = AllDataMode; + + /// Paginated loading with page controls. + const factory DataGridDataMode.paginated({int pageSize}) = PaginatedDataMode; + + /// Infinite scroll loading. + const factory DataGridDataMode.infinite({ + int initialLoad, + int loadMoreThreshold, + }) = InfiniteDataMode; +} + +/// Load all data mode. +final class AllDataMode extends DataGridDataMode { + const AllDataMode() : super._(); +} + +/// Paginated data mode. +final class PaginatedDataMode extends DataGridDataMode { + const PaginatedDataMode({this.pageSize = 25}) : super._(); + final int pageSize; +} + +/// Infinite scroll data mode. +final class InfiniteDataMode extends DataGridDataMode { + const InfiniteDataMode({ + this.initialLoad = 50, + this.loadMoreThreshold = 10, + }) : super._(); + + final int initialLoad; + final int loadMoreThreshold; +} + +/// Main configuration for a DataGrid. +class DataGridConfig { + const DataGridConfig({ + required this.columns, + this.actions = const [], + this.bulkActions = const [], + this.rowsSelectable = false, + this.showHeader = true, + this.showFooter = true, + this.enableSearch = false, + this.searchHint = 'Search...', + this.defaultSortColumn, + this.defaultSortDescending = false, + this.emptyStateBuilder, + this.loadingBuilder, + this.errorBuilder, + this.onRowTap, + this.cellPadding = const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + this.headerHeight = 48.0, + this.rowHeight, + this.dataMode = const DataGridDataMode.all(), + this.rowColor, + this.alternatingRowColors = false, + }); + + /// Column definitions. + final List> columns; + + /// Per-row actions (shown in actions menu). + final List> actions; + + /// Bulk actions for selected rows. + final List> bulkActions; + + /// Whether rows can be selected. + final bool rowsSelectable; + + /// Whether to show the header row. + final bool showHeader; + + /// Whether to show the footer with count/pagination. + final bool showFooter; + + /// Whether to show search bar. + final bool enableSearch; + + /// Placeholder text for search input. + final String searchHint; + + /// Index of column to sort by default. + final int? defaultSortColumn; + + /// Whether default sort is descending. + final bool defaultSortDescending; + + /// Custom empty state widget builder. + final Widget Function(BuildContext context)? emptyStateBuilder; + + /// Custom loading widget builder. + final Widget Function(BuildContext context)? loadingBuilder; + + /// Custom error widget builder. + final Widget Function(BuildContext context, Object error, VoidCallback retry)? + errorBuilder; + + /// Callback when a row is tapped. + final void Function(T item)? onRowTap; + + /// Padding for each cell. + final EdgeInsetsGeometry cellPadding; + + /// Height of the header row. + final double headerHeight; + + /// Height of each data row. If null, rows size to content. + final double? rowHeight; + + /// Data loading mode. + final DataGridDataMode dataMode; + + /// Custom row background color builder. + final Color? Function(BuildContext context, T item, int index)? rowColor; + + /// Whether to use alternating row colors. + final bool alternatingRowColors; + + /// Gets visible columns only. + List> get visibleColumns => + columns.where((c) => c.visible).toList(); + + /// Gets searchable column indices. + List get searchableColumnIndices => columns + .asMap() + .entries + .where((e) => e.value.searchable) + .map((e) => e.key) + .toList(); +} diff --git a/lib/shared/components/data_grid/data_grid_exports.dart b/lib/shared/components/data_grid/data_grid_exports.dart new file mode 100644 index 0000000..93c9455 --- /dev/null +++ b/lib/shared/components/data_grid/data_grid_exports.dart @@ -0,0 +1,85 @@ +/// DataGrid component for displaying tabular data. +/// +/// This library provides a configurable, feature-rich data grid widget +/// for Flutter applications using Riverpod for state management. +/// +/// Features: +/// - Sortable columns +/// - Row selection (single and bulk) +/// - Per-row and bulk actions with confirmations +/// - Search/filtering +/// - Pagination or infinite scroll +/// - Custom cell rendering +/// - Empty, loading, and error states +/// - Responsive column widths +/// +/// Example: +/// ```dart +/// // Define your data source +/// final usersSource = InMemoryDataSource( +/// items: users, +/// searchMatcher: (user, query) => +/// user.name.toLowerCase().contains(query.toLowerCase()), +/// ); +/// +/// // Define grid configuration +/// final usersConfig = DataGridConfig( +/// columns: [ +/// DataGridColumn( +/// header: 'Name', +/// valueBuilder: (u) => u.name, +/// sortable: true, +/// searchable: true, +/// ), +/// DataGridColumn( +/// header: 'Email', +/// valueBuilder: (u) => u.email, +/// ), +/// DataGridColumn( +/// header: 'Status', +/// valueBuilder: (u) => u.status, +/// cellBuilder: (context, u) => StatusBadge(status: u.status), +/// ), +/// ], +/// actions: [ +/// DataGridAction( +/// icon: Icons.edit, +/// label: 'Edit', +/// onTap: (user) async => editUser(user), +/// ), +/// DataGridAction( +/// icon: Icons.delete, +/// label: 'Delete', +/// onTap: (user) async => deleteUser(user), +/// destructive: true, +/// requiresConfirmation: true, +/// ), +/// ], +/// rowsSelectable: true, +/// enableSearch: true, +/// ); +/// +/// // Create the provider +/// final usersGridProvider = dataGridProvider( +/// source: usersSource, +/// config: usersConfig, +/// idSelector: (u) => u.id, +/// ); +/// +/// // Use in widget +/// DataGrid( +/// provider: usersGridProvider, +/// config: usersConfig, +/// idSelector: (u) => u.id, +/// ) +/// ``` +library; + +export 'data_grid.dart'; +export 'data_grid_action.dart'; +export 'data_grid_column.dart'; +export 'data_grid_config.dart'; +export 'data_grid_provider.dart'; +export 'data_grid_source.dart'; +export 'data_grid_state.dart'; +export 'widgets/data_grid_empty_state.dart'; diff --git a/lib/shared/components/data_grid/data_grid_provider.dart b/lib/shared/components/data_grid/data_grid_provider.dart new file mode 100644 index 0000000..3a60db8 --- /dev/null +++ b/lib/shared/components/data_grid/data_grid_provider.dart @@ -0,0 +1,266 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'data_grid_config.dart'; +import 'data_grid_source.dart'; +import 'data_grid_state.dart'; + +/// Controller for a DataGrid instance. +/// +/// Manages loading, searching, sorting, and selection state. +class DataGridController extends StateNotifier> { + DataGridController({ + required this.source, + required this.config, + required this.idSelector, + }) : super(DataGridState( + sortColumnIndex: config.defaultSortColumn, + sortDescending: config.defaultSortDescending, + )) { + // Initial load + _load(); + } + + /// Data source for fetching items. + final DataGridSource source; + + /// Grid configuration. + final DataGridConfig config; + + /// Function to extract unique ID from an item. + final Object Function(T item) idSelector; + + /// Debounce timer for search. + Timer? _searchDebounce; + + @override + void dispose() { + _searchDebounce?.cancel(); + super.dispose(); + } + + /// Loads or reloads data from the source. + Future _load({bool refresh = false}) async { + if (refresh) { + state = state.copyWith(isLoading: true, error: null); + } else { + state = state.copyWith(isLoading: true, isInitialLoad: true, error: null); + } + + try { + final sortField = state.sortColumnIndex != null + ? config.columns[state.sortColumnIndex!].effectiveSortField + : null; + + final (offset, limit) = _getPaginationParams(); + + final result = await source.fetch( + searchQuery: state.searchQuery.isEmpty ? null : state.searchQuery, + sortField: sortField, + sortDescending: state.sortDescending, + offset: offset, + limit: limit, + ); + + state = state.copyWith( + items: result.items, + totalCount: result.totalCount, + hasMore: result.hasMore, + isLoading: false, + isInitialLoad: false, + error: null, + ); + } catch (e, stack) { + debugPrint('DataGrid load error: $e\n$stack'); + state = state.copyWith( + isLoading: false, + isInitialLoad: false, + error: e, + ); + } + } + + /// Gets pagination parameters based on data mode. + (int?, int?) _getPaginationParams() { + return switch (config.dataMode) { + AllDataMode() => (null, null), + PaginatedDataMode(:final pageSize) => ( + state.currentPage * pageSize, + pageSize, + ), + InfiniteDataMode(:final initialLoad) => (0, initialLoad), + }; + } + + /// Refreshes the grid data. + Future refresh() => _load(refresh: true); + + /// Sets the search query with debouncing. + void search(String query) { + _searchDebounce?.cancel(); + _searchDebounce = Timer(const Duration(milliseconds: 300), () { + if (state.searchQuery != query) { + state = state.copyWith(searchQuery: query, currentPage: 0); + _load(refresh: true); + } + }); + } + + /// Clears the search query. + void clearSearch() { + _searchDebounce?.cancel(); + if (state.searchQuery.isNotEmpty) { + state = state.copyWith(searchQuery: '', currentPage: 0); + _load(refresh: true); + } + } + + /// Sorts by the given column index. + void sortBy(int columnIndex) { + final column = config.columns[columnIndex]; + if (!column.sortable) return; + + final newDescending = + state.sortColumnIndex == columnIndex ? !state.sortDescending : false; + + state = state.copyWith( + sortColumnIndex: columnIndex, + sortDescending: newDescending, + currentPage: 0, + ); + _load(refresh: true); + } + + /// Clears sorting. + void clearSort() { + state = state.copyWith( + sortColumnIndex: null, + sortDescending: false, + currentPage: 0, + ); + _load(refresh: true); + } + + /// Toggles selection of an item. + void toggleSelection(T item) { + if (!config.rowsSelectable) return; + + final id = idSelector(item); + final newSelection = Set.from(state.selectedIds); + + if (newSelection.contains(id)) { + newSelection.remove(id); + } else { + newSelection.add(id); + } + + state = state.copyWith(selectedIds: newSelection); + } + + /// Selects all visible items. + void selectAll() { + if (!config.rowsSelectable) return; + + final allIds = state.items.map(idSelector).toSet(); + state = state.copyWith(selectedIds: allIds); + } + + /// Clears all selections. + void clearSelection() { + state = state.copyWith(selectedIds: {}); + } + + /// Toggles select all / clear all. + void toggleSelectAll() { + if (state.allSelected) { + clearSelection(); + } else { + selectAll(); + } + } + + /// Checks if an item is selected. + bool isSelected(T item) => state.selectedIds.contains(idSelector(item)); + + /// Gets the selected items. + List getSelectedItems() { + return state.items.where((item) => isSelected(item)).toList(); + } + + /// Goes to a specific page (for paginated mode). + void goToPage(int page) { + if (config.dataMode is! PaginatedDataMode) return; + + final mode = config.dataMode as PaginatedDataMode; + final maxPage = (state.totalCount / mode.pageSize).ceil() - 1; + + if (page < 0 || page > maxPage) return; + + state = state.copyWith(currentPage: page); + _load(refresh: true); + } + + /// Goes to the next page. + void nextPage() => goToPage(state.currentPage + 1); + + /// Goes to the previous page. + void previousPage() => goToPage(state.currentPage - 1); + + /// Loads more items (for infinite scroll mode). + Future loadMore() async { + if (config.dataMode is! InfiniteDataMode) return; + if (state.isLoading || !state.hasMore) return; + + state = state.copyWith(isLoading: true); + + try { + final sortField = state.sortColumnIndex != null + ? config.columns[state.sortColumnIndex!].effectiveSortField + : null; + + final result = await source.fetch( + searchQuery: state.searchQuery.isEmpty ? null : state.searchQuery, + sortField: sortField, + sortDescending: state.sortDescending, + offset: state.items.length, + limit: (config.dataMode as InfiniteDataMode).initialLoad, + ); + + state = state.copyWith( + items: [...state.items, ...result.items], + totalCount: result.totalCount, + hasMore: result.hasMore, + isLoading: false, + ); + } catch (e) { + state = state.copyWith(isLoading: false, error: e); + } + } +} + +/// Creates a DataGridController provider for a specific grid. +/// +/// Usage: +/// ```dart +/// final containersGridProvider = dataGridProvider( +/// source: containersDataSource, +/// config: containersGridConfig, +/// idSelector: (c) => c.id, +/// ); +/// ``` +StateNotifierProvider, DataGridState> + dataGridProvider({ + required DataGridSource source, + required DataGridConfig config, + required Object Function(T) idSelector, +}) { + return StateNotifierProvider, DataGridState>( + (ref) => DataGridController( + source: source, + config: config, + idSelector: idSelector, + ), + ); +} diff --git a/lib/shared/components/data_grid/data_grid_source.dart b/lib/shared/components/data_grid/data_grid_source.dart new file mode 100644 index 0000000..4dfdfd6 --- /dev/null +++ b/lib/shared/components/data_grid/data_grid_source.dart @@ -0,0 +1,108 @@ +/// Result from a data source fetch operation. +class DataGridResult { + const DataGridResult({ + required this.items, + required this.totalCount, + this.hasMore = false, + }); + + /// The fetched items. + final List items; + + /// Total count of items (for pagination display). + final int totalCount; + + /// Whether there are more items to load (for infinite scroll). + final bool hasMore; + + /// Creates an empty result. + const DataGridResult.empty() + : items = const [], + totalCount = 0, + hasMore = false; +} + +/// Abstract data source for DataGrid. +/// +/// Implement this to provide data to the grid. Can be backed by +/// API calls, local database, or in-memory lists. +abstract class DataGridSource { + /// Fetches items from the data source. + /// + /// - [searchQuery]: Optional search text to filter results. + /// - [sortField]: Field name to sort by. + /// - [sortDescending]: Whether to sort in descending order. + /// - [offset]: Number of items to skip (for pagination). + /// - [limit]: Maximum number of items to return. + Future> fetch({ + String? searchQuery, + String? sortField, + bool sortDescending = false, + int? offset, + int? limit, + }); + + /// Gets the total count of items matching the query. + /// + /// Override this if you need a separate count query. + /// By default, returns the totalCount from the last fetch. + Future count({String? searchQuery}) async { + final result = await fetch(searchQuery: searchQuery, limit: 0); + return result.totalCount; + } +} + +/// In-memory data source for local data. +class InMemoryDataSource extends DataGridSource { + InMemoryDataSource({ + required this.items, + this.searchMatcher, + this.sortComparator, + }); + + /// All items in the data source. + final List items; + + /// Function to check if an item matches the search query. + final bool Function(T item, String query)? searchMatcher; + + /// Function to compare two items for sorting. + final int Function(T a, T b, String field, bool descending)? sortComparator; + + @override + Future> fetch({ + String? searchQuery, + String? sortField, + bool sortDescending = false, + int? offset, + int? limit, + }) async { + var result = List.from(items); + + // Apply search filter + if (searchQuery != null && searchQuery.isNotEmpty && searchMatcher != null) { + result = result.where((item) => searchMatcher!(item, searchQuery)).toList(); + } + + // Apply sorting + if (sortField != null && sortComparator != null) { + result.sort((a, b) => sortComparator!(a, b, sortField, sortDescending)); + } + + final totalCount = result.length; + + // Apply pagination + if (offset != null && offset > 0) { + result = result.skip(offset).toList(); + } + if (limit != null && limit > 0) { + result = result.take(limit).toList(); + } + + return DataGridResult( + items: result, + totalCount: totalCount, + hasMore: offset != null && limit != null && (offset + limit) < totalCount, + ); + } +} diff --git a/lib/shared/components/data_grid/data_grid_state.dart b/lib/shared/components/data_grid/data_grid_state.dart new file mode 100644 index 0000000..e05c1f7 --- /dev/null +++ b/lib/shared/components/data_grid/data_grid_state.dart @@ -0,0 +1,62 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'data_grid_state.freezed.dart'; + +/// State for a DataGrid instance. +@freezed +class DataGridState with _$DataGridState { + const factory DataGridState({ + /// Current items being displayed. + @Default([]) List items, + + /// Total count of items (may differ from items.length for pagination). + @Default(0) int totalCount, + + /// Whether data is currently loading. + @Default(false) bool isLoading, + + /// Whether initial load is in progress. + @Default(true) bool isInitialLoad, + + /// Error that occurred during loading. + Object? error, + + /// Current search query. + @Default('') String searchQuery, + + /// Index of the column currently sorted by. + int? sortColumnIndex, + + /// Whether sort is descending. + @Default(false) bool sortDescending, + + /// Currently selected item IDs (if selectable). + @Default({}) Set selectedIds, + + /// Current page (for paginated mode). + @Default(0) int currentPage, + + /// Whether more items can be loaded (for infinite scroll). + @Default(false) bool hasMore, + }) = _DataGridState; +} + +/// Extension methods for DataGridState. +extension DataGridStateX on DataGridState { + /// Whether the grid has an error. + bool get hasError => error != null; + + /// Whether the grid is empty (no items and not loading). + bool get isEmpty => items.isEmpty && !isLoading && !hasError; + + /// Whether all visible items are selected. + bool get allSelected => + items.isNotEmpty && selectedIds.length == items.length; + + /// Whether some (but not all) items are selected. + bool get someSelected => + selectedIds.isNotEmpty && selectedIds.length < items.length; + + /// Number of selected items. + int get selectedCount => selectedIds.length; +} diff --git a/lib/shared/components/data_grid/widgets/data_grid_actions_menu.dart b/lib/shared/components/data_grid/widgets/data_grid_actions_menu.dart new file mode 100644 index 0000000..16004c6 --- /dev/null +++ b/lib/shared/components/data_grid/widgets/data_grid_actions_menu.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +import '../data_grid_action.dart'; + +/// Actions menu for a DataGrid row. +class DataGridActionsMenu extends StatelessWidget { + const DataGridActionsMenu({ + super.key, + required this.item, + required this.actions, + }); + + final T item; + final List> actions; + + @override + Widget build(BuildContext context) { + final visibleActions = actions.where((a) => a.shouldShow(item)).toList(); + + if (visibleActions.isEmpty) { + return const SizedBox.shrink(); + } + + return PopupMenuButton>( + icon: const Icon(Icons.more_vert), + tooltip: 'Actions', + onSelected: (action) => _handleAction(context, action), + itemBuilder: (context) => visibleActions.map((action) { + final colorScheme = Theme.of(context).colorScheme; + + return PopupMenuItem>( + value: action, + child: Row( + children: [ + Icon( + action.icon, + size: 20, + color: action.destructive ? colorScheme.error : null, + ), + const SizedBox(width: 12), + Text( + action.label, + style: TextStyle( + color: action.destructive ? colorScheme.error : null, + ), + ), + ], + ), + ); + }).toList(), + ); + } + + Future _handleAction( + BuildContext context, + DataGridAction action, + ) async { + if (action.requiresConfirmation) { + final confirmed = await _showConfirmationDialog(context, action); + if (!confirmed) return; + } + + await action.onTap(item); + } + + Future _showConfirmationDialog( + BuildContext context, + DataGridAction action, + ) async { + final colorScheme = Theme.of(context).colorScheme; + + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(action.label), + content: Text( + action.confirmationMessage ?? 'Are you sure you want to proceed?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + style: action.destructive + ? FilledButton.styleFrom( + backgroundColor: colorScheme.error, + foregroundColor: colorScheme.onError, + ) + : null, + child: const Text('Confirm'), + ), + ], + ), + ) ?? + false; + } +} diff --git a/lib/shared/components/data_grid/widgets/data_grid_bulk_actions.dart b/lib/shared/components/data_grid/widgets/data_grid_bulk_actions.dart new file mode 100644 index 0000000..6e6d6e2 --- /dev/null +++ b/lib/shared/components/data_grid/widgets/data_grid_bulk_actions.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; + +import '../data_grid_action.dart'; + +/// Bulk actions bar shown when items are selected. +class DataGridBulkActions extends StatelessWidget { + const DataGridBulkActions({ + super.key, + required this.selectedCount, + required this.bulkActions, + required this.onClearSelection, + required this.getSelectedItems, + }); + + final int selectedCount; + final List> bulkActions; + final VoidCallback onClearSelection; + final List Function() getSelectedItems; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Text( + '$selectedCount selected', + style: TextStyle( + fontWeight: FontWeight.w500, + color: colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 16), + ...bulkActions.where((a) => a.isAvailable(selectedCount)).map( + (action) => Padding( + padding: const EdgeInsets.only(right: 8), + child: _BulkActionButton( + action: action, + onPressed: () => _handleAction(context, action), + ), + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + onPressed: onClearSelection, + tooltip: 'Clear selection', + ), + ], + ), + ); + } + + Future _handleAction( + BuildContext context, + DataGridBulkAction action, + ) async { + if (action.requiresConfirmation) { + final confirmed = await _showConfirmationDialog(context, action); + if (!confirmed) return; + } + + final items = getSelectedItems(); + await action.onTap(items); + } + + Future _showConfirmationDialog( + BuildContext context, + DataGridBulkAction action, + ) async { + final colorScheme = Theme.of(context).colorScheme; + + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(action.label), + content: Text( + action.confirmationMessage ?? + 'Are you sure you want to ${action.label.toLowerCase()} $selectedCount items?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + style: action.destructive + ? FilledButton.styleFrom( + backgroundColor: colorScheme.error, + foregroundColor: colorScheme.onError, + ) + : null, + child: const Text('Confirm'), + ), + ], + ), + ) ?? + false; + } +} + +class _BulkActionButton extends StatelessWidget { + const _BulkActionButton({ + required this.action, + required this.onPressed, + }); + + final DataGridBulkAction action; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + if (action.destructive) { + return OutlinedButton.icon( + onPressed: onPressed, + icon: Icon(action.icon, size: 18), + label: Text(action.label), + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.error, + side: BorderSide(color: colorScheme.error), + ), + ); + } + + return OutlinedButton.icon( + onPressed: onPressed, + icon: Icon(action.icon, size: 18), + label: Text(action.label), + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.onPrimaryContainer, + side: BorderSide(color: colorScheme.onPrimaryContainer), + ), + ); + } +} diff --git a/lib/shared/components/data_grid/widgets/data_grid_empty_state.dart b/lib/shared/components/data_grid/widgets/data_grid_empty_state.dart new file mode 100644 index 0000000..ed4d58d --- /dev/null +++ b/lib/shared/components/data_grid/widgets/data_grid_empty_state.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; + +/// Default empty state for DataGrid. +class DataGridEmptyState extends StatelessWidget { + const DataGridEmptyState({ + super.key, + this.icon = Icons.inbox_outlined, + this.title = 'No items found', + this.subtitle, + this.action, + this.onAction, + }); + + final IconData icon; + final String title; + final String? subtitle; + final String? action; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 64, + color: colorScheme.outline, + ), + const SizedBox(height: 16), + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 8), + Text( + subtitle!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.outline, + ), + textAlign: TextAlign.center, + ), + ], + if (action != null && onAction != null) ...[ + const SizedBox(height: 24), + FilledButton.tonal( + onPressed: onAction, + child: Text(action!), + ), + ], + ], + ), + ), + ); + } +} + +/// Loading state for DataGrid. +class DataGridLoadingState extends StatelessWidget { + const DataGridLoadingState({super.key}); + + @override + Widget build(BuildContext context) { + return const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: CircularProgressIndicator(), + ), + ); + } +} + +/// Error state for DataGrid. +class DataGridErrorState extends StatelessWidget { + const DataGridErrorState({ + super.key, + required this.error, + required this.onRetry, + }); + + final Object error; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: colorScheme.error, + ), + const SizedBox(height: 16), + Text( + 'Failed to load data', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Text( + error.toString(), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.outline, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/shared/components/data_grid/widgets/data_grid_footer.dart b/lib/shared/components/data_grid/widgets/data_grid_footer.dart new file mode 100644 index 0000000..4050529 --- /dev/null +++ b/lib/shared/components/data_grid/widgets/data_grid_footer.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; + +import '../data_grid_config.dart'; + +/// Footer for DataGrid with item count and pagination controls. +class DataGridFooter extends StatelessWidget { + const DataGridFooter({ + super.key, + required this.totalCount, + required this.displayedCount, + required this.dataMode, + this.currentPage = 0, + this.onPageChange, + this.isLoading = false, + }); + + final int totalCount; + final int displayedCount; + final DataGridDataMode dataMode; + final int currentPage; + final void Function(int page)? onPageChange; + final bool isLoading; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + border: Border( + top: BorderSide(color: colorScheme.outlineVariant), + ), + ), + child: Row( + children: [ + Text( + _getCountText(), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const Spacer(), + if (dataMode is PaginatedDataMode) _buildPaginationControls(context), + ], + ), + ); + } + + String _getCountText() { + return switch (dataMode) { + AllDataMode() => '$totalCount items', + PaginatedDataMode(:final pageSize) => _getPaginatedCountText(pageSize), + InfiniteDataMode() => '$displayedCount of $totalCount items', + }; + } + + String _getPaginatedCountText(int pageSize) { + final start = currentPage * pageSize + 1; + final end = (start + displayedCount - 1).clamp(start, totalCount); + return '$start-$end of $totalCount items'; + } + + Widget _buildPaginationControls(BuildContext context) { + final mode = dataMode as PaginatedDataMode; + final totalPages = (totalCount / mode.pageSize).ceil(); + final canGoPrevious = currentPage > 0; + final canGoNext = currentPage < totalPages - 1; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.first_page), + onPressed: canGoPrevious ? () => onPageChange?.call(0) : null, + tooltip: 'First page', + iconSize: 20, + ), + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: + canGoPrevious ? () => onPageChange?.call(currentPage - 1) : null, + tooltip: 'Previous page', + iconSize: 20, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + 'Page ${currentPage + 1} of $totalPages', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: + canGoNext ? () => onPageChange?.call(currentPage + 1) : null, + tooltip: 'Next page', + iconSize: 20, + ), + IconButton( + icon: const Icon(Icons.last_page), + onPressed: + canGoNext ? () => onPageChange?.call(totalPages - 1) : null, + tooltip: 'Last page', + iconSize: 20, + ), + ], + ); + } +} diff --git a/lib/shared/components/data_grid/widgets/data_grid_header.dart b/lib/shared/components/data_grid/widgets/data_grid_header.dart new file mode 100644 index 0000000..2132a3f --- /dev/null +++ b/lib/shared/components/data_grid/widgets/data_grid_header.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; + +import '../data_grid_column.dart'; +import '../data_grid_config.dart'; + +/// Header row for DataGrid. +class DataGridHeader extends StatelessWidget { + const DataGridHeader({ + super.key, + required this.config, + required this.sortColumnIndex, + required this.sortDescending, + required this.onSort, + this.showCheckbox = false, + this.allSelected = false, + this.someSelected = false, + this.onSelectAll, + }); + + final DataGridConfig config; + final int? sortColumnIndex; + final bool sortDescending; + final void Function(int columnIndex) onSort; + final bool showCheckbox; + final bool allSelected; + final bool someSelected; + final VoidCallback? onSelectAll; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final columns = config.visibleColumns; + + return Container( + height: config.headerHeight, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + border: Border( + bottom: BorderSide(color: colorScheme.outlineVariant), + ), + ), + child: Row( + children: [ + if (showCheckbox) + SizedBox( + width: 56, + child: Center( + child: Checkbox( + value: allSelected ? true : (someSelected ? null : false), + tristate: true, + onChanged: (_) => onSelectAll?.call(), + ), + ), + ), + ...columns.asMap().entries.map((entry) { + final index = entry.key; + final column = entry.value; + final isSorted = sortColumnIndex == index; + + return _buildHeaderCell( + context, + column, + index, + isSorted, + isSorted && sortDescending, + ); + }), + if (config.actions.isNotEmpty) + const SizedBox(width: 56), // Space for actions column + ], + ), + ); + } + + Widget _buildHeaderCell( + BuildContext context, + DataGridColumn column, + int index, + bool isSorted, + bool isDescending, + ) { + final colorScheme = Theme.of(context).colorScheme; + final textStyle = Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurfaceVariant, + ); + + Widget content = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + column.header, + style: textStyle, + overflow: TextOverflow.ellipsis, + textAlign: column.textAlign, + ), + ), + if (column.sortable) ...[ + const SizedBox(width: 4), + AnimatedRotation( + turns: isDescending ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + child: Icon( + isSorted ? Icons.arrow_upward : Icons.unfold_more, + size: 16, + color: isSorted + ? colorScheme.primary + : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ), + ), + ], + ], + ); + + if (column.sortable) { + content = InkWell( + onTap: () => onSort(index), + child: Padding( + padding: config.cellPadding, + child: content, + ), + ); + } else { + content = Padding( + padding: config.cellPadding, + child: content, + ); + } + + return _wrapWithWidth(column.width, content); + } + + Widget _wrapWithWidth(DataGridColumnWidth width, Widget child) { + return switch (width) { + GridFixedWidth(:final width) => SizedBox(width: width, child: child), + GridFlexWidth(:final flex) => Expanded(flex: flex, child: child), + GridFractionWidth(:final fraction) => FractionallySizedBox( + widthFactor: fraction, + child: child, + ), + }; + } +} diff --git a/lib/shared/components/data_grid/widgets/data_grid_row.dart b/lib/shared/components/data_grid/widgets/data_grid_row.dart new file mode 100644 index 0000000..7e966c9 --- /dev/null +++ b/lib/shared/components/data_grid/widgets/data_grid_row.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; + +import '../data_grid_column.dart'; +import '../data_grid_config.dart'; +import 'data_grid_actions_menu.dart'; + +/// A single data row in the DataGrid. +class DataGridRow extends StatelessWidget { + const DataGridRow({ + super.key, + required this.item, + required this.index, + required this.config, + this.isSelected = false, + this.onSelect, + this.onTap, + this.backgroundColor, + }); + + final T item; + final int index; + final DataGridConfig config; + final bool isSelected; + final VoidCallback? onSelect; + final VoidCallback? onTap; + final Color? backgroundColor; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final columns = config.visibleColumns; + + // Determine row background color + Color? bgColor = backgroundColor; + if (bgColor == null && config.alternatingRowColors) { + bgColor = index.isOdd + ? colorScheme.surfaceContainerLowest + : colorScheme.surface; + } + if (isSelected) { + bgColor = colorScheme.primaryContainer.withValues(alpha: 0.3); + } + + final rowContent = Container( + height: config.rowHeight, + constraints: config.rowHeight == null + ? const BoxConstraints(minHeight: 48) + : null, + decoration: BoxDecoration( + color: bgColor, + border: Border( + bottom: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ), + ), + child: Row( + children: [ + if (config.rowsSelectable) + SizedBox( + width: 56, + child: Center( + child: Checkbox( + value: isSelected, + onChanged: (_) => onSelect?.call(), + ), + ), + ), + ...columns.map((column) => _buildCell(context, column)), + if (config.actions.isNotEmpty) + SizedBox( + width: 56, + child: DataGridActionsMenu( + item: item, + actions: config.actions, + ), + ), + ], + ), + ); + + if (onTap != null) { + return InkWell( + onTap: onTap, + child: rowContent, + ); + } + + return rowContent; + } + + Widget _buildCell(BuildContext context, DataGridColumn column) { + Widget content; + + if (column.cellBuilder != null) { + content = column.cellBuilder!(context, item); + } else { + content = Text( + column.valueBuilder(item), + textAlign: column.textAlign, + overflow: TextOverflow.ellipsis, + ); + } + + // Wrap with controls if provided + if (column.cellControlsBuilder != null) { + content = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: content), + const SizedBox(width: 8), + column.cellControlsBuilder!(context, item), + ], + ); + } + + // Wrap with tooltip if provided + if (column.tooltip != null) { + content = Tooltip( + message: column.tooltip!(item), + child: content, + ); + } + + final cell = Padding( + padding: config.cellPadding, + child: Align( + alignment: switch (column.alignment) { + DataGridColumnAlignment.start => Alignment.centerLeft, + DataGridColumnAlignment.center => Alignment.center, + DataGridColumnAlignment.end => Alignment.centerRight, + }, + child: content, + ), + ); + + return _wrapWithWidth(column.width, cell); + } + + Widget _wrapWithWidth(DataGridColumnWidth width, Widget child) { + return switch (width) { + GridFixedWidth(:final width) => SizedBox(width: width, child: child), + GridFlexWidth(:final flex) => Expanded(flex: flex, child: child), + GridFractionWidth(:final fraction) => FractionallySizedBox( + widthFactor: fraction, + child: child, + ), + }; + } +} diff --git a/lib/shared/components/data_grid/widgets/data_grid_search_bar.dart b/lib/shared/components/data_grid/widgets/data_grid_search_bar.dart new file mode 100644 index 0000000..f705e48 --- /dev/null +++ b/lib/shared/components/data_grid/widgets/data_grid_search_bar.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +/// Search bar for DataGrid. +class DataGridSearchBar extends StatefulWidget { + const DataGridSearchBar({ + super.key, + required this.onSearch, + required this.onClear, + this.hintText = 'Search...', + this.initialValue = '', + }); + + final void Function(String query) onSearch; + final VoidCallback onClear; + final String hintText; + final String initialValue; + + @override + State createState() => _DataGridSearchBarState(); +} + +class _DataGridSearchBarState extends State { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialValue); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return SizedBox( + width: 300, + child: TextField( + controller: _controller, + decoration: InputDecoration( + hintText: widget.hintText, + prefixIcon: const Icon(Icons.search), + suffixIcon: _controller.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _controller.clear(); + widget.onClear(); + }, + ) + : null, + isDense: true, + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + onChanged: (value) { + setState(() {}); // Update clear button visibility + widget.onSearch(value); + }, + ), + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..f6f23bf 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..f16b4c3 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/pubspec.yaml b/pubspec.yaml index d48f3dc..e696ed4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,9 @@ dependencies: # Icons cupertino_icons: ^1.0.8 + # URL Launcher + url_launcher: ^6.3.1 + dev_dependencies: flutter_test: sdk: flutter diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..4f78848 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..88b22e5 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST