feat: implement NPM Proxy Hosts section with reusable entity patterns

Add full Proxy Hosts feature for Control Room:
- Data layer: datasource, model with custom JSON converters for NPM API quirks
- Domain layer: ProxyHost entity with SSL, caching, websocket support
- Presentation: list page, detail/edit page, form widget

Add reusable entity page patterns for Control Room sections:
- EntityPageScaffold, EntitySection, EntitySettingRow components
- EntityForm with create/view/edit mode support
- EntityPageModeMixin for consistent mode management
- CreateOnlyField for immutable-after-creation fields

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2026-01-01 15:02:06 +01:00
co-authored by Claude Opus 4.5
parent 67d2f777c8
commit 2adb50b352
11 changed files with 1839 additions and 1 deletions
+288
View File
@@ -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<FormState> formKey;
final VoidCallback onCancel;
final VoidCallback onSave;
final bool isSaving;
final List<Widget> 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<EntityFormScope>();
}
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,
),
),
],
),
),
),
);
}
}
+272
View File
@@ -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<Widget>? 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<T> 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<T extends StatefulWidget> on State<T> {
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);
}