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:
co-authored by
Claude Opus 4.5
parent
67d2f777c8
commit
2adb50b352
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user