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>
289 lines
7.7 KiB
Dart
289 lines
7.7 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|