diff --git a/.gitignore b/.gitignore index 2fd8e1f..cf087fd 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,6 @@ pubspec.lock *.freezed.dart *.gr.dart *.mocks.dart -lib/generated_plugin_registrant.dart # Keep version.g.dart - it's generated but should be committed # so CI/CD builds have version info without running the generator diff --git a/lib/features/control_room/npm/data/datasources/proxy_hosts_datasource.dart b/lib/features/control_room/npm/data/datasources/proxy_hosts_datasource.dart new file mode 100644 index 0000000..314053d --- /dev/null +++ b/lib/features/control_room/npm/data/datasources/proxy_hosts_datasource.dart @@ -0,0 +1,69 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tatlock_ui/core/api/api_client.dart'; +import 'package:tatlock_ui/features/control_room/npm/data/models/proxy_host_model.dart'; + +part 'proxy_hosts_datasource.g.dart'; + +/// Remote data source for NPM proxy host operations via Core API. +class ProxyHostsDatasource { + ProxyHostsDatasource(this._dio); + + final Dio _dio; + + /// Gets all configured domains from Core API. + Future> getDomains() async { + final response = await _dio.get>( + '/infrastructure/domains', + ); + + return response.data! + .map((json) => DomainInfoModel.fromJson(json as Map)) + .toList(); + } + + /// Gets detailed proxy host configuration by ID. + Future getProxyHost(int proxyId) async { + final response = await _dio.get>( + '/infrastructure/proxy/$proxyId', + ); + + return ProxyHostModel.fromJson(response.data!); + } + + /// Creates a new proxy host. + Future createProxyHost({ + required List domainNames, + required String forwardScheme, + required String forwardHost, + required int forwardPort, + bool sslEnabled = false, + }) async { + await _dio.post( + '/infrastructure/proxy', + data: { + 'domain_names': domainNames, + 'forward_scheme': forwardScheme, + 'forward_host': forwardHost, + 'forward_port': forwardPort, + 'ssl_enabled': sslEnabled, + }, + ); + } + + /// Updates an existing proxy host. + Future updateProxyHost(int proxyId, Map config) async { + await _dio.put( + '/infrastructure/proxy/$proxyId', + data: config, + ); + } +} + +/// Provides the proxy hosts datasource. +@riverpod +ProxyHostsDatasource proxyHostsDatasource(Ref ref) { + final dio = ref.watch(coreApiClientProvider); + return ProxyHostsDatasource(dio); +} diff --git a/lib/features/control_room/npm/data/models/proxy_host_model.dart b/lib/features/control_room/npm/data/models/proxy_host_model.dart new file mode 100644 index 0000000..0abf1ea --- /dev/null +++ b/lib/features/control_room/npm/data/models/proxy_host_model.dart @@ -0,0 +1,137 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:tatlock_ui/features/control_room/npm/domain/entities/proxy_host.dart'; + +part 'proxy_host_model.freezed.dart'; +part 'proxy_host_model.g.dart'; + +/// Converts API values that can be either int or bool (false) to int?. +/// NPM returns false instead of null for missing IDs. +class NullableIntOrBoolConverter implements JsonConverter { + const NullableIntOrBoolConverter(); + + @override + int? fromJson(dynamic json) { + if (json == null || json == false) return null; + if (json is int) return json; + if (json is num) return json.toInt(); + return null; + } + + @override + dynamic toJson(int? object) => object; +} + +/// Converts API values that can be either int or bool to int. +/// NPM returns true/false for some 0/1 fields. +class IntOrBoolConverter implements JsonConverter { + const IntOrBoolConverter(); + + @override + int fromJson(dynamic json) { + if (json == null) return 0; + if (json is bool) return json ? 1 : 0; + if (json is int) return json; + if (json is num) return json.toInt(); + return 0; + } + + @override + dynamic toJson(int object) => object; +} + +/// Proxy host data model for API serialization. +/// +/// Maps to the NPM API response format via Core API. +@freezed +class ProxyHostModel with _$ProxyHostModel { + const factory ProxyHostModel({ + required int id, + @JsonKey(name: 'domain_names') required List domainNames, + @JsonKey(name: 'forward_scheme') required String forwardScheme, + @JsonKey(name: 'forward_host') required String forwardHost, + @JsonKey(name: 'forward_port') required int forwardPort, + @JsonKey(name: 'ssl_forced') @Default(false) bool sslForced, + @NullableIntOrBoolConverter() @JsonKey(name: 'certificate_id') int? certificateId, + @IntOrBoolConverter() @Default(1) int enabled, + @IntOrBoolConverter() @JsonKey(name: 'http2_support') @Default(0) int http2Support, + @IntOrBoolConverter() @JsonKey(name: 'hsts_enabled') @Default(0) int hstsEnabled, + @NullableIntOrBoolConverter() @JsonKey(name: 'access_list_id') int? accessListId, + @IntOrBoolConverter() @JsonKey(name: 'caching_enabled') @Default(0) int cachingEnabled, + @IntOrBoolConverter() @JsonKey(name: 'block_exploits') @Default(0) int blockExploits, + @IntOrBoolConverter() @JsonKey(name: 'allow_websocket_upgrade') @Default(0) int allowWebsocketUpgrade, + @JsonKey(name: 'created_on') String? createdOn, + @JsonKey(name: 'modified_on') String? modifiedOn, + @Default([]) List locations, + }) = _ProxyHostModel; + + const ProxyHostModel._(); + + factory ProxyHostModel.fromJson(Map json) => + _$ProxyHostModelFromJson(json); + + /// Converts to domain entity. + ProxyHost toEntity() { + return ProxyHost( + id: id, + domainNames: domainNames, + forwardScheme: forwardScheme, + forwardHost: forwardHost, + forwardPort: forwardPort, + sslEnabled: certificateId != null && certificateId! > 0, + certificateId: certificateId, + enabled: enabled == 1, + http2Support: http2Support == 1, + hstsEnabled: hstsEnabled == 1, + forceSSL: sslForced, + accessListId: accessListId, + cacheAssets: cachingEnabled == 1, + blockExploits: blockExploits == 1, + websocketSupport: allowWebsocketUpgrade == 1, + locations: locations.map((l) => l.toEntity()).toList(), + createdAt: createdOn != null ? DateTime.tryParse(createdOn!) : null, + modifiedAt: modifiedOn != null ? DateTime.tryParse(modifiedOn!) : null, + ); + } +} + +/// Proxy location model. +@freezed +class ProxyLocationModel with _$ProxyLocationModel { + const factory ProxyLocationModel({ + required String path, + @JsonKey(name: 'forward_scheme') required String forwardScheme, + @JsonKey(name: 'forward_host') required String forwardHost, + @JsonKey(name: 'forward_port') required int forwardPort, + }) = _ProxyLocationModel; + + const ProxyLocationModel._(); + + factory ProxyLocationModel.fromJson(Map json) => + _$ProxyLocationModelFromJson(json); + + ProxyLocation toEntity() { + return ProxyLocation( + path: path, + forwardScheme: forwardScheme, + forwardHost: forwardHost, + forwardPort: forwardPort, + ); + } +} + +/// Domain info model for the /infrastructure/domains endpoint. +/// +/// This is a simpler model used for listing domains. +@freezed +class DomainInfoModel with _$DomainInfoModel { + const factory DomainInfoModel({ + required String domain, + required String service, + @JsonKey(name: 'proxy_host_id') required int proxyHostId, + @JsonKey(name: 'ssl_enabled') @Default(false) bool sslEnabled, + @JsonKey(name: 'certificate_id') int? certificateId, + }) = _DomainInfoModel; + + factory DomainInfoModel.fromJson(Map json) => + _$DomainInfoModelFromJson(json); +} diff --git a/lib/features/control_room/npm/domain/entities/proxy_host.dart b/lib/features/control_room/npm/domain/entities/proxy_host.dart new file mode 100644 index 0000000..8147f38 --- /dev/null +++ b/lib/features/control_room/npm/domain/entities/proxy_host.dart @@ -0,0 +1,93 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'proxy_host.freezed.dart'; + +/// NPM proxy host entity representing a domain configuration. +@freezed +class ProxyHost with _$ProxyHost { + const factory ProxyHost({ + /// Proxy host ID. + required int id, + + /// Domain names (can have multiple). + required List domainNames, + + /// Forward scheme (http or https). + required String forwardScheme, + + /// Forward host (IP or hostname). + required String forwardHost, + + /// Forward port. + required int forwardPort, + + /// Whether SSL is enabled. + required bool sslEnabled, + + /// SSL certificate ID (if enabled). + int? certificateId, + + /// Whether the host is enabled. + @Default(true) bool enabled, + + /// Whether HTTP/2 is enabled. + @Default(false) bool http2Support, + + /// Whether HSTS is enabled. + @Default(false) bool hstsEnabled, + + /// Whether to force SSL. + @Default(false) bool forceSSL, + + /// Custom locations (advanced nginx config). + @Default([]) List locations, + + /// Access list ID (for auth). + int? accessListId, + + /// Cache assets enabled. + @Default(false) bool cacheAssets, + + /// Block common exploits. + @Default(false) bool blockExploits, + + /// Websocket support. + @Default(false) bool websocketSupport, + + /// Created timestamp. + DateTime? createdAt, + + /// Modified timestamp. + DateTime? modifiedAt, + }) = _ProxyHost; + + const ProxyHost._(); + + /// Primary domain (first in list). + String get primaryDomain => + domainNames.isNotEmpty ? domainNames.first : 'Unknown'; + + /// Forward URL (scheme://host:port). + String get forwardUrl => '$forwardScheme://$forwardHost:$forwardPort'; + + /// SSL status label. + String get sslStatus => sslEnabled ? 'SSL Enabled' : 'No SSL'; +} + +/// Proxy location for advanced routing. +@freezed +class ProxyLocation with _$ProxyLocation { + const factory ProxyLocation({ + /// Location path. + required String path, + + /// Forward scheme. + required String forwardScheme, + + /// Forward host. + required String forwardHost, + + /// Forward port. + required int forwardPort, + }) = _ProxyLocation; +} diff --git a/lib/features/control_room/npm/presentation/pages/proxy_host_page.dart b/lib/features/control_room/npm/presentation/pages/proxy_host_page.dart new file mode 100644 index 0000000..fa19457 --- /dev/null +++ b/lib/features/control_room/npm/presentation/pages/proxy_host_page.dart @@ -0,0 +1,297 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tatlock_ui/features/control_room/npm/domain/entities/proxy_host.dart'; +import 'package:tatlock_ui/features/control_room/npm/presentation/providers/proxy_hosts_provider.dart'; +import 'package:tatlock_ui/features/control_room/npm/presentation/widgets/proxy_host_form.dart'; +import 'package:tatlock_ui/shared/widgets/entity_page.dart'; + +/// Unified page for proxy host create, view, and edit. +/// +/// Usage: +/// - Create: `ProxyHostPage.create(onClose: ...)` +/// - View/Edit: `ProxyHostPage(proxyHostId: 123, onClose: ...)` +class ProxyHostPage extends ConsumerStatefulWidget { + const ProxyHostPage({ + super.key, + required this.proxyHostId, + required this.onClose, + }) : _isCreate = false; + + const ProxyHostPage.create({ + super.key, + required this.onClose, + }) : proxyHostId = null, + _isCreate = true; + + final int? proxyHostId; + final VoidCallback onClose; + final bool _isCreate; + + @override + ConsumerState createState() => _ProxyHostPageState(); +} + +class _ProxyHostPageState extends ConsumerState + with EntityPageModeMixin { + @override + void initState() { + super.initState(); + // Start in create mode if no ID, otherwise view mode + mode = widget._isCreate ? EntityPageMode.create : EntityPageMode.view; + } + + String get _title { + switch (mode) { + case EntityPageMode.create: + return 'New Proxy Host'; + case EntityPageMode.view: + return 'Proxy Host Details'; + case EntityPageMode.edit: + return 'Edit Proxy Host'; + } + } + + void _handleSaved() { + if (widget._isCreate) { + // After create, close and return to list + widget.onClose(); + } else { + // After edit, return to view mode and refresh + stopEditing(); + ref.invalidate(proxyHostProvider(widget.proxyHostId!)); + } + ref.invalidate(domainsProvider); + } + + @override + Widget build(BuildContext context) { + // Create mode - no need to fetch existing data + if (widget._isCreate) { + return EntityPageScaffold( + title: _title, + onBack: widget.onClose, + child: ProxyHostForm( + mode: EntityPageMode.create, + onCancel: widget.onClose, + onSaved: _handleSaved, + ), + ); + } + + // View/Edit mode - fetch existing proxy host + final proxyHostAsync = ref.watch(proxyHostProvider(widget.proxyHostId!)); + + return EntityPageScaffold( + title: _title, + onBack: widget.onClose, + actions: [ + if (isViewing) + IconButton( + icon: const Icon(Icons.edit), + tooltip: 'Edit', + onPressed: startEditing, + ), + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Refresh', + onPressed: () => + ref.invalidate(proxyHostProvider(widget.proxyHostId!)), + ), + ], + child: EntityAsyncContent( + isLoading: proxyHostAsync.isLoading, + error: proxyHostAsync.error, + data: proxyHostAsync.valueOrNull, + onRetry: () => ref.invalidate(proxyHostProvider(widget.proxyHostId!)), + builder: (proxyHost) { + if (isEditing) { + return ProxyHostForm( + mode: EntityPageMode.edit, + proxyHost: proxyHost, + onCancel: stopEditing, + onSaved: _handleSaved, + ); + } + return _ProxyHostView(proxyHost: proxyHost); + }, + ), + ); + } +} + +/// Read-only view of proxy host details. +class _ProxyHostView extends StatelessWidget { + const _ProxyHostView({required this.proxyHost}); + + final ProxyHost proxyHost; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Domain names + EntitySection( + title: 'Domain Names', + icon: Icons.public, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: proxyHost.domainNames.map((domain) { + return Chip( + avatar: Icon( + proxyHost.sslEnabled ? Icons.lock : Icons.lock_open, + size: 16, + color: proxyHost.sslEnabled ? Colors.green : null, + ), + label: Text(domain), + ); + }).toList(), + ), + ), + const SizedBox(height: 24), + + // Forward destination + EntitySection( + title: 'Forward Destination', + icon: Icons.arrow_forward, + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.dns, + color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + proxyHost.forwardUrl, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontFamily: 'monospace'), + ), + const SizedBox(height: 4), + Text( + '${proxyHost.forwardScheme.toUpperCase()} → ${proxyHost.forwardHost}:${proxyHost.forwardPort}', + style: TextStyle( + color: Theme.of(context).colorScheme.outline), + ), + ], + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 24), + + // SSL Settings + EntitySection( + title: 'SSL Settings', + icon: Icons.verified_user, + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + EntitySettingRow( + label: 'SSL Enabled', + value: proxyHost.sslEnabled, + ), + if (proxyHost.sslEnabled) ...[ + const Divider(), + EntitySettingRow( + label: 'Force SSL', + value: proxyHost.forceSSL, + subtitle: 'Redirect HTTP to HTTPS', + ), + const Divider(), + EntitySettingRow( + label: 'HTTP/2 Support', + value: proxyHost.http2Support, + ), + const Divider(), + EntitySettingRow( + label: 'HSTS Enabled', + value: proxyHost.hstsEnabled, + ), + ], + ], + ), + ), + ), + ), + const SizedBox(height: 24), + + // Advanced Settings + EntitySection( + title: 'Advanced Settings', + icon: Icons.settings, + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + EntitySettingRow( + label: 'WebSocket Support', + value: proxyHost.websocketSupport, + ), + const Divider(), + EntitySettingRow( + label: 'Block Exploits', + value: proxyHost.blockExploits, + ), + const Divider(), + EntitySettingRow( + label: 'Cache Assets', + value: proxyHost.cacheAssets, + ), + const Divider(), + EntitySettingRow( + label: 'Enabled', + value: proxyHost.enabled, + ), + ], + ), + ), + ), + ), + + // Locations (if any) + if (proxyHost.locations.isNotEmpty) ...[ + const SizedBox(height: 24), + EntitySection( + title: 'Custom Locations', + icon: Icons.route, + child: Card( + child: ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: proxyHost.locations.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final loc = proxyHost.locations[index]; + return ListTile( + leading: const Icon(Icons.subdirectory_arrow_right), + title: Text(loc.path, + style: const TextStyle(fontFamily: 'monospace')), + subtitle: Text( + '${loc.forwardScheme}://${loc.forwardHost}:${loc.forwardPort}'), + ); + }, + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/features/control_room/npm/presentation/pages/proxy_hosts_page.dart b/lib/features/control_room/npm/presentation/pages/proxy_hosts_page.dart new file mode 100644 index 0000000..db12e98 --- /dev/null +++ b/lib/features/control_room/npm/presentation/pages/proxy_hosts_page.dart @@ -0,0 +1,316 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tatlock_ui/features/control_room/npm/data/models/proxy_host_model.dart'; +import 'package:tatlock_ui/features/control_room/npm/presentation/pages/proxy_host_page.dart'; +import 'package:tatlock_ui/features/control_room/npm/presentation/providers/proxy_hosts_provider.dart'; + +/// Page displaying the list of proxy hosts (domains) from NPM. +class ProxyHostsPage extends ConsumerWidget { + const ProxyHostsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final domainsAsync = ref.watch(domainsProvider); + final selectedId = ref.watch(selectedProxyHostProvider); + final isCreating = ref.watch(creatingProxyHostProvider); + final colorScheme = Theme.of(context).colorScheme; + + // Show create page if creating new + if (isCreating) { + return ProxyHostPage.create( + onClose: () => ref.read(creatingProxyHostProvider.notifier).stop(), + ); + } + + // Show detail page if a proxy host is selected + if (selectedId != null) { + return ProxyHostPage( + proxyHostId: selectedId, + onClose: () => ref.read(selectedProxyHostProvider.notifier).clear(), + ); + } + + return domainsAsync.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 proxy hosts'), + const SizedBox(height: 8), + Text( + error.toString(), + style: TextStyle(color: colorScheme.outline), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () => ref.invalidate(domainsProvider), + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + data: (domains) => _DomainsList( + domains: domains, + onRefresh: () => ref.invalidate(domainsProvider), + onSelect: (id) => ref.read(selectedProxyHostProvider.notifier).select(id), + onCreateNew: () => ref.read(creatingProxyHostProvider.notifier).start(), + ), + ); + } +} + +class _DomainsList extends StatefulWidget { + const _DomainsList({ + required this.domains, + required this.onRefresh, + required this.onSelect, + required this.onCreateNew, + }); + + final List domains; + final VoidCallback onRefresh; + final void Function(int proxyHostId) onSelect; + final VoidCallback onCreateNew; + + @override + State<_DomainsList> createState() => _DomainsListState(); +} + +class _DomainsListState extends State<_DomainsList> { + final _searchController = TextEditingController(); + String _searchQuery = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + List get _filteredDomains { + if (_searchQuery.isEmpty) return widget.domains; + final query = _searchQuery.toLowerCase(); + return widget.domains.where((d) { + return d.domain.toLowerCase().contains(query) || + d.service.toLowerCase().contains(query); + }).toList(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final filtered = _filteredDomains; + + return Column( + children: [ + // Toolbar with search + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Text( + '${filtered.length} of ${widget.domains.length} proxy hosts', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const Spacer(), + SizedBox( + width: 250, + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search domains...', + 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, + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: widget.onCreateNew, + icon: const Icon(Icons.add), + label: const Text('New'), + ), + ], + ), + ), + // Domain list or empty state + Expanded( + child: filtered.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _searchQuery.isEmpty + ? Icons.public_off + : Icons.search_off, + size: 64, + color: colorScheme.outline, + ), + const SizedBox(height: 16), + Text( + _searchQuery.isEmpty + ? 'No proxy hosts configured' + : 'No domains 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 domain = filtered[index]; + return _DomainListTile( + domain: domain, + onTap: () => widget.onSelect(domain.proxyHostId), + ); + }, + ), + ), + ], + ); + } +} + +class _DomainListTile extends StatelessWidget { + const _DomainListTile({required this.domain, required this.onTap}); + + final DomainInfoModel domain; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + // SSL status icon + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: domain.sslEnabled + ? Colors.green.withValues(alpha: 0.1) + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + domain.sslEnabled ? Icons.lock : Icons.lock_open, + size: 20, + color: domain.sslEnabled ? Colors.green : colorScheme.outline, + ), + ), + const SizedBox(width: 12), + // Domain and service info + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + domain.domain, + style: const TextStyle(fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + domain.service, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // Service target + Expanded( + child: Text( + domain.service, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + ), + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + // SSL badge + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: domain.sslEnabled + ? Colors.green.withValues(alpha: 0.1) + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + domain.sslEnabled ? 'SSL' : 'HTTP', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: domain.sslEnabled ? Colors.green : colorScheme.outline, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/control_room/npm/presentation/providers/proxy_hosts_provider.dart b/lib/features/control_room/npm/presentation/providers/proxy_hosts_provider.dart new file mode 100644 index 0000000..6db875a --- /dev/null +++ b/lib/features/control_room/npm/presentation/providers/proxy_hosts_provider.dart @@ -0,0 +1,73 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:tatlock_ui/features/control_room/npm/data/datasources/proxy_hosts_datasource.dart'; +import 'package:tatlock_ui/features/control_room/npm/data/models/proxy_host_model.dart'; +import 'package:tatlock_ui/features/control_room/npm/domain/entities/proxy_host.dart'; + +part 'proxy_hosts_provider.g.dart'; + +/// Provider for the list of configured domains. +@riverpod +Future> domains(Ref ref) async { + final datasource = ref.watch(proxyHostsDatasourceProvider); + return datasource.getDomains(); +} + +/// Provider for a specific proxy host details. +@riverpod +Future proxyHost(Ref ref, int proxyId) async { + final datasource = ref.watch(proxyHostsDatasourceProvider); + final model = await datasource.getProxyHost(proxyId); + return model.toEntity(); +} + +/// Provider for selected proxy host ID (for detail view). +@riverpod +class SelectedProxyHost extends _$SelectedProxyHost { + @override + int? build() => null; + + void select(int id) => state = id; + void clear() => state = null; +} + +/// Provider for tracking if we're creating a new proxy host. +@riverpod +class CreatingProxyHost extends _$CreatingProxyHost { + @override + bool build() => false; + + void start() => state = true; + void stop() => state = false; +} + +/// Provider for creating a new proxy host. +@riverpod +Future createProxyHost( + Ref ref, { + required List domainNames, + required String forwardScheme, + required String forwardHost, + required int forwardPort, + bool sslEnabled = false, +}) async { + final datasource = ref.watch(proxyHostsDatasourceProvider); + await datasource.createProxyHost( + domainNames: domainNames, + forwardScheme: forwardScheme, + forwardHost: forwardHost, + forwardPort: forwardPort, + sslEnabled: sslEnabled, + ); +} + +/// Provider for updating an existing proxy host. +@riverpod +Future updateProxyHost( + Ref ref, { + required int id, + required Map config, +}) async { + final datasource = ref.watch(proxyHostsDatasourceProvider); + await datasource.updateProxyHost(id, config); +} diff --git a/lib/features/control_room/npm/presentation/widgets/proxy_host_form.dart b/lib/features/control_room/npm/presentation/widgets/proxy_host_form.dart new file mode 100644 index 0000000..8320e00 --- /dev/null +++ b/lib/features/control_room/npm/presentation/widgets/proxy_host_form.dart @@ -0,0 +1,291 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tatlock_ui/features/control_room/npm/domain/entities/proxy_host.dart'; +import 'package:tatlock_ui/features/control_room/npm/presentation/providers/proxy_hosts_provider.dart'; +import 'package:tatlock_ui/shared/widgets/entity_form_dialog.dart'; +import 'package:tatlock_ui/shared/widgets/entity_page.dart'; + +/// Form for creating or editing a proxy host. +class ProxyHostForm extends ConsumerStatefulWidget { + const ProxyHostForm({ + super.key, + this.proxyHost, + this.mode = EntityPageMode.create, + required this.onCancel, + required this.onSaved, + }); + + /// Existing proxy host for editing (null for create). + final ProxyHost? proxyHost; + + /// Form mode - create or edit. + final EntityPageMode mode; + + final VoidCallback onCancel; + final VoidCallback onSaved; + + bool get isEditing => mode == EntityPageMode.edit; + + @override + ConsumerState createState() => _ProxyHostFormState(); +} + +class _ProxyHostFormState extends ConsumerState { + final _formKey = GlobalKey(); + + late final TextEditingController _domainController; + late final TextEditingController _forwardHostController; + late final TextEditingController _forwardPortController; + + late String _forwardScheme; + late bool _forceSSL; + late bool _http2Support; + late bool _websocketSupport; + late bool _blockExploits; + late bool _cacheAssets; + + bool _isSaving = false; + String? _error; + + @override + void initState() { + super.initState(); + final host = widget.proxyHost; + + _domainController = TextEditingController( + text: host?.domainNames.join(', ') ?? '', + ); + _forwardHostController = TextEditingController( + text: host?.forwardHost ?? '', + ); + _forwardPortController = TextEditingController( + text: host?.forwardPort.toString() ?? '80', + ); + + _forwardScheme = host?.forwardScheme ?? 'http'; + _forceSSL = host?.forceSSL ?? false; + _http2Support = host?.http2Support ?? false; + _websocketSupport = host?.websocketSupport ?? false; + _blockExploits = host?.blockExploits ?? true; + _cacheAssets = host?.cacheAssets ?? false; + } + + @override + void dispose() { + _domainController.dispose(); + _forwardHostController.dispose(); + _forwardPortController.dispose(); + super.dispose(); + } + + Future _handleSave() async { + if (!_formKey.currentState!.validate()) return; + + setState(() { + _isSaving = true; + _error = null; + }); + + try { + final domains = _domainController.text + .split(',') + .map((d) => d.trim()) + .where((d) => d.isNotEmpty) + .toList(); + + if (widget.isEditing) { + await ref.read(updateProxyHostProvider( + id: widget.proxyHost!.id, + config: { + 'domain_names': domains, + 'forward_scheme': _forwardScheme, + 'forward_host': _forwardHostController.text, + 'forward_port': int.parse(_forwardPortController.text), + 'ssl_forced': _forceSSL, + 'http2_support': _http2Support ? 1 : 0, + 'allow_websocket_upgrade': _websocketSupport ? 1 : 0, + 'block_exploits': _blockExploits ? 1 : 0, + 'caching_enabled': _cacheAssets ? 1 : 0, + }, + ).future); + } else { + await ref.read(createProxyHostProvider( + domainNames: domains, + forwardScheme: _forwardScheme, + forwardHost: _forwardHostController.text, + forwardPort: int.parse(_forwardPortController.text), + ).future); + } + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.isEditing + ? 'Proxy host updated successfully' + : 'Proxy host created successfully'), + backgroundColor: Colors.green, + ), + ); + widget.onSaved(); + } + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) { + setState(() => _isSaving = false); + } + } + } + + @override + Widget build(BuildContext context) { + return EntityForm( + formKey: _formKey, + mode: widget.mode, + onCancel: widget.onCancel, + onSave: _handleSave, + isSaving: _isSaving, + error: _error, + children: [ + // Domain Names + Text('Domain Names', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + TextFormField( + controller: _domainController, + decoration: const InputDecoration( + hintText: 'example.com, www.example.com', + helperText: 'Separate multiple domains with commas', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'At least one domain is required'; + } + return null; + }, + ), + const SizedBox(height: 24), + + // Forward Destination + Text('Forward Destination', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Scheme dropdown + SizedBox( + width: 120, + child: DropdownButtonFormField( + initialValue: _forwardScheme, + decoration: const InputDecoration( + labelText: 'Scheme', + border: OutlineInputBorder(), + ), + items: const [ + DropdownMenuItem(value: 'http', child: Text('HTTP')), + DropdownMenuItem(value: 'https', child: Text('HTTPS')), + ], + onChanged: (value) { + if (value != null) setState(() => _forwardScheme = value); + }, + ), + ), + const SizedBox(width: 12), + // Host + Expanded( + flex: 2, + child: TextFormField( + controller: _forwardHostController, + decoration: const InputDecoration( + labelText: 'Host', + hintText: '192.168.1.100 or hostname', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Host is required'; + } + return null; + }, + ), + ), + const SizedBox(width: 12), + // Port + SizedBox( + width: 100, + child: TextFormField( + controller: _forwardPortController, + decoration: const InputDecoration( + labelText: 'Port', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Required'; + } + final port = int.tryParse(value); + if (port == null || port < 1 || port > 65535) { + return 'Invalid'; + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 24), + + // SSL & Security Options + Text('SSL & Security', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Card( + child: Column( + children: [ + SwitchListTile( + title: const Text('Force SSL'), + subtitle: const Text('Redirect HTTP to HTTPS'), + value: _forceSSL, + onChanged: (v) => setState(() => _forceSSL = v), + ), + const Divider(height: 1), + SwitchListTile( + title: const Text('HTTP/2 Support'), + value: _http2Support, + onChanged: (v) => setState(() => _http2Support = v), + ), + const Divider(height: 1), + SwitchListTile( + title: const Text('Block Common Exploits'), + value: _blockExploits, + onChanged: (v) => setState(() => _blockExploits = v), + ), + ], + ), + ), + const SizedBox(height: 24), + + // Advanced Options + Text('Advanced', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Card( + child: Column( + children: [ + SwitchListTile( + title: const Text('WebSocket Support'), + value: _websocketSupport, + onChanged: (v) => setState(() => _websocketSupport = v), + ), + const Divider(height: 1), + SwitchListTile( + title: const Text('Cache Assets'), + value: _cacheAssets, + onChanged: (v) => setState(() => _cacheAssets = v), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/control_room/presentation/pages/control_room_page.dart b/lib/features/control_room/presentation/pages/control_room_page.dart index 8b7d4f4..9c3fa39 100644 --- a/lib/features/control_room/presentation/pages/control_room_page.dart +++ b/lib/features/control_room/presentation/pages/control_room_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart' hide Stack; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:tatlock_ui/features/control_room/containers/presentation/pages/containers_list_page.dart'; +import 'package:tatlock_ui/features/control_room/npm/presentation/pages/proxy_hosts_page.dart'; import 'package:tatlock_ui/features/control_room/router.dart'; import 'package:tatlock_ui/features/control_room/stacks/data/repositories/stack_repository_impl.dart'; import 'package:tatlock_ui/features/control_room/stacks/domain/entities/stack.dart'; @@ -71,6 +72,8 @@ class _SectionContent extends ConsumerWidget { switch (nav) { case ControlRoomNav.containers: return const _ContainersSection(); + case ControlRoomNav.proxyHosts: + return const ProxyHostsPage(); default: return _PlaceholderSection(nav: nav); } diff --git a/lib/shared/widgets/entity_form_dialog.dart b/lib/shared/widgets/entity_form_dialog.dart new file mode 100644 index 0000000..cccf5df --- /dev/null +++ b/lib/shared/widgets/entity_form_dialog.dart @@ -0,0 +1,288 @@ +import 'package:flutter/material.dart'; +import 'package:tatlock_ui/shared/widgets/entity_page.dart'; + +/// A reusable dialog wrapper for entity create/edit forms. +/// +/// Provides consistent styling across all Control Room sections: +/// - Constrained max width/height +/// - AppBar with title and close button +/// - Scrollable content area +/// +/// Usage: +/// ```dart +/// showEntityFormDialog( +/// context: context, +/// title: 'New Proxy Host', +/// child: ProxyHostForm( +/// onCancel: () => Navigator.of(context).pop(), +/// onSaved: () { +/// Navigator.of(context).pop(); +/// ref.invalidate(domainsProvider); +/// }, +/// ), +/// ); +/// ``` +void showEntityFormDialog({ + required BuildContext context, + required String title, + required Widget child, + double maxWidth = 600, + double maxHeight = 700, +}) { + showDialog( + context: context, + builder: (context) => EntityFormDialog( + title: title, + maxWidth: maxWidth, + maxHeight: maxHeight, + child: child, + ), + ); +} + +/// Dialog widget for entity forms. +class EntityFormDialog extends StatelessWidget { + const EntityFormDialog({ + super.key, + required this.title, + required this.child, + this.maxWidth = 600, + this.maxHeight = 700, + }); + + final String title; + final Widget child; + final double maxWidth; + final double maxHeight; + + @override + Widget build(BuildContext context) { + return Dialog( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxWidth, + maxHeight: maxHeight, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppBar( + title: Text(title), + automaticallyImplyLeading: false, + actions: [ + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + Flexible(child: child), + ], + ), + ), + ); + } +} + +/// A standardized form wrapper with common patterns. +/// +/// Handles: +/// - Error display banner +/// - Loading state on save button +/// - Cancel/Save button row +/// - Form key management +/// - Create vs Edit mode awareness +/// +/// Use [mode] to indicate whether this is a create or edit form. +/// Child widgets can use [EntityFormScope.of(context)] to check the mode +/// and disable fields that should only be editable during creation. +class EntityForm extends StatelessWidget { + const EntityForm({ + super.key, + required this.formKey, + required this.onCancel, + required this.onSave, + required this.isSaving, + required this.children, + this.mode = EntityPageMode.create, + this.error, + this.saveLabel, + this.cancelLabel = 'Cancel', + }); + + final GlobalKey formKey; + final VoidCallback onCancel; + final VoidCallback onSave; + final bool isSaving; + final List children; + final EntityPageMode mode; + final String? error; + final String? saveLabel; + final String cancelLabel; + + bool get isCreating => mode == EntityPageMode.create; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final effectiveSaveLabel = saveLabel ?? (isCreating ? 'Create' : 'Save'); + + return EntityFormScope( + mode: mode, + child: Form( + key: formKey, + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Error banner + if (error != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.error, color: colorScheme.onErrorContainer), + const SizedBox(width: 8), + Expanded( + child: Text( + error!, + style: TextStyle(color: colorScheme.onErrorContainer), + ), + ), + ], + ), + ), + + // Form fields + ...children, + + const SizedBox(height: 32), + + // Action buttons + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton( + onPressed: isSaving ? null : onCancel, + child: Text(cancelLabel), + ), + const SizedBox(width: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save), + label: Text(effectiveSaveLabel), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +/// InheritedWidget to provide form mode to descendants. +/// +/// Allows form fields to check if they're in create or edit mode +/// and adjust their behavior accordingly (e.g., disable create-only fields). +class EntityFormScope extends InheritedWidget { + const EntityFormScope({ + super.key, + required this.mode, + required super.child, + }); + + final EntityPageMode mode; + + bool get isCreating => mode == EntityPageMode.create; + bool get isEditing => mode == EntityPageMode.edit; + + static EntityFormScope? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + static EntityFormScope of(BuildContext context) { + final scope = maybeOf(context); + assert(scope != null, 'No EntityFormScope found in context'); + return scope!; + } + + @override + bool updateShouldNotify(EntityFormScope oldWidget) => mode != oldWidget.mode; +} + +/// A form field wrapper that can be marked as create-only. +/// +/// When [createOnly] is true, the field will be disabled in edit mode. +/// Shows a lock icon and tooltip to indicate the field is immutable. +/// +/// Usage: +/// ```dart +/// CreateOnlyField( +/// createOnly: true, +/// child: TextFormField( +/// controller: _nameController, +/// decoration: InputDecoration(labelText: 'Name'), +/// ), +/// ) +/// ``` +class CreateOnlyField extends StatelessWidget { + const CreateOnlyField({ + super.key, + required this.child, + this.createOnly = true, + this.disabledHint = 'This field cannot be changed after creation', + }); + + final Widget child; + final bool createOnly; + final String disabledHint; + + @override + Widget build(BuildContext context) { + final scope = EntityFormScope.maybeOf(context); + final isEditing = scope?.isEditing ?? false; + final shouldDisable = createOnly && isEditing; + + if (!shouldDisable) { + return child; + } + + return Tooltip( + message: disabledHint, + child: AbsorbPointer( + absorbing: true, + child: Opacity( + opacity: 0.6, + child: Stack( + children: [ + child, + Positioned( + right: 8, + top: 8, + child: Icon( + Icons.lock, + size: 16, + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/shared/widgets/entity_page.dart b/lib/shared/widgets/entity_page.dart new file mode 100644 index 0000000..ae0c201 --- /dev/null +++ b/lib/shared/widgets/entity_page.dart @@ -0,0 +1,272 @@ +import 'package:flutter/material.dart'; + +/// Reusable scaffold for entity pages (create, view, edit). +/// +/// Provides consistent layout across all Control Room entity pages: +/// - AppBar with back button, title, and customizable actions +/// - Loading, error, and content states +/// - Consistent padding and styling +/// +/// Usage: +/// ```dart +/// EntityPageScaffold( +/// title: 'Proxy Host Details', +/// onBack: () => Navigator.pop(context), +/// actions: [ +/// IconButton(icon: Icon(Icons.edit), onPressed: onEdit), +/// ], +/// child: MyContent(), +/// ) +/// ``` +class EntityPageScaffold extends StatelessWidget { + const EntityPageScaffold({ + super.key, + required this.title, + required this.child, + this.onBack, + this.actions, + this.floatingActionButton, + }); + + final String title; + final Widget child; + final VoidCallback? onBack; + final List? actions; + final Widget? floatingActionButton; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: onBack != null + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: onBack, + ) + : null, + title: Text(title), + actions: actions, + ), + body: child, + floatingActionButton: floatingActionButton, + ); + } +} + +/// Async content wrapper with loading, error, and data states. +/// +/// Use with Riverpod AsyncValue for consistent loading/error handling. +class EntityAsyncContent extends StatelessWidget { + const EntityAsyncContent({ + super.key, + required this.isLoading, + required this.error, + required this.data, + required this.onRetry, + required this.builder, + }); + + final bool isLoading; + final Object? error; + final T? data; + final VoidCallback onRetry; + final Widget Function(T data) builder; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + if (isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (error != null) { + return 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'), + const SizedBox(height: 8), + Text( + error.toString(), + style: TextStyle(color: colorScheme.outline), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ); + } + + if (data != null) { + return builder(data as T); + } + + return const SizedBox.shrink(); + } +} + +/// Section header for entity detail pages. +/// +/// Consistent styling for grouping related fields. +class EntitySection extends StatelessWidget { + const EntitySection({ + super.key, + required this.title, + required this.icon, + required this.child, + this.trailing, + }); + + final String title; + final IconData icon; + final Widget child; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 20, color: colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + if (trailing != null) trailing!, + ], + ), + const SizedBox(height: 8), + child, + ], + ); + } +} + +/// Row displaying a boolean setting with label and indicator. +class EntitySettingRow extends StatelessWidget { + const EntitySettingRow({ + super.key, + required this.label, + required this.value, + this.subtitle, + }); + + final String label; + final bool value; + final String? subtitle; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label), + if (subtitle != null) + Text( + subtitle!, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + ), + ), + Icon( + value ? Icons.check_circle : Icons.cancel, + color: value ? Colors.green : Colors.grey, + size: 20, + ), + ], + ); + } +} + +/// Row displaying a key-value pair. +class EntityInfoRow extends StatelessWidget { + const EntityInfoRow({ + super.key, + required this.label, + required this.value, + this.monospace = false, + }); + + final String label; + final String value; + final bool monospace; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: TextStyle(color: colorScheme.outline), + ), + ), + Expanded( + child: Text( + value, + style: monospace + ? const TextStyle(fontFamily: 'monospace') + : null, + ), + ), + ], + ); + } +} + +/// Enum for entity page modes. +enum EntityPageMode { + create, + view, + edit, +} + +/// Mixin for pages that support view/edit mode toggle. +/// +/// Provides standard mode management for entity detail pages. +mixin EntityPageModeMixin on State { + EntityPageMode _mode = EntityPageMode.view; + + EntityPageMode get mode => _mode; + set mode(EntityPageMode value) => _mode = value; + + bool get isViewing => _mode == EntityPageMode.view; + bool get isEditing => _mode == EntityPageMode.edit; + bool get isCreating => _mode == EntityPageMode.create; + + void setMode(EntityPageMode newMode) { + setState(() => _mode = newMode); + } + + void startEditing() => setMode(EntityPageMode.edit); + void stopEditing() => setMode(EntityPageMode.view); +}