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>
273 lines
6.5 KiB
Dart
273 lines
6.5 KiB
Dart
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);
|
|
}
|