serverpod-offline-sync/example/offline_sync_demo/lib/src/demo_view.dart

1388 lines
42 KiB
Dart
Raw Normal View History

import 'dart:async';
import 'package:flutter/services.dart' show Clipboard, ClipboardData;
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'demo_controller.dart';
import 'models.dart';
import 'relationships.dart';
import 'snapshot.dart';
part 'sheets.dart';
/// The full dashboard: global toolbar, scenario rail, replica panels, server.
class DemoDashboard extends StatelessWidget {
const DemoDashboard({super.key, required this.controller});
final DemoController controller;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Toolbar(controller: controller),
const Gap(12),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 280,
child: _ScenarioRail(controller: controller),
),
const Gap(12),
Expanded(
flex: 4,
child: _ReplicaPanel(
controller: controller,
slot: ReplicaSlot.a,
),
),
const Gap(12),
Expanded(
flex: 4,
child: _ReplicaPanel(
controller: controller,
slot: ReplicaSlot.b,
),
),
const Gap(12),
Expanded(flex: 3, child: _ServerPanel(controller: controller)),
],
),
),
const Gap(12),
_StatusBar(controller: controller),
],
),
);
}
}
class _Toolbar extends StatelessWidget {
const _Toolbar({required this.controller});
final DemoController controller;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (context, _) => OutlinedContainer(
padding: const EdgeInsets.all(12),
child: Wrap(
spacing: 12,
runSpacing: 10,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Checkbox(
state: controller.online
? CheckboxState.checked
: CheckboxState.unchecked,
onChanged: controller.anyBusy
? null
: (value) => unawaited(
controller.setOnline(value == CheckboxState.checked),
),
trailing: const Text('Connectivity'),
),
Checkbox(
state: controller.showHidden
? CheckboxState.checked
: CheckboxState.unchecked,
onChanged: controller.anyBusy
? null
: (value) => unawaited(
controller.setShowHidden(value == CheckboxState.checked),
),
trailing: const Text('Show hidden rows'),
),
OutlineButton(
onPressed: controller.anyBusy
? null
: () => unawaited(controller.refreshAll()),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.refresh, size: 16),
Gap(6),
Text('Refresh'),
],
),
),
Tooltip(
tooltip: (context) => const TooltipContainer(
child: Text('Wipe both replicas and the server scope at once'),
),
child: OutlineButton(
onPressed: controller.anyBusy
? null
: () => unawaited(controller.resetAll()),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.delete_sweep_outlined, size: 16),
Gap(6),
Text('Reset all'),
],
),
),
),
_ReplicaIsolationInfoButton(controller: controller),
],
),
),
);
}
}
class _ReplicaIsolationInfoButton extends StatelessWidget {
const _ReplicaIsolationInfoButton({required this.controller});
final DemoController controller;
@override
Widget build(BuildContext context) {
return IconButton.ghost(
icon: const Icon(Icons.info_outline, size: 18),
onPressed: () {
showPopover(
context: context,
alignment: Alignment.bottomCenter,
builder: (context) => OutlinedContainer(
padding: const EdgeInsets.all(12),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Text(controller.replicaIsolationMessage),
),
),
);
},
);
}
}
// --- Scenario rail ---------------------------------------------------------
class _ScenarioRail extends StatelessWidget {
const _ScenarioRail({required this.controller});
final DemoController controller;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
final active = controller.activeScenario;
return OutlinedContainer(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(Icons.list_alt, size: 16),
const Gap(6),
const Text('Scenarios').semiBold(),
const Spacer(),
if (active != null)
IconButton.ghost(
icon: const Icon(Icons.close, size: 16),
onPressed: controller.stopScenario,
),
],
),
const Gap(4),
const Text(
'Optional quick-starts. Everything here is also doable by hand '
'on the trees.',
).xSmall().muted(),
const Gap(12),
Expanded(
child: SingleChildScrollView(
child: active == null
? _ScenarioList(controller: controller)
: _ScenarioSteps(
controller: controller,
scenario: active,
),
),
),
],
),
);
},
);
}
}
class _ScenarioList extends StatelessWidget {
const _ScenarioList({required this.controller});
final DemoController controller;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final scenario in controller.scenarios)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: OutlineButton(
onPressed: controller.anyBusy
? null
: () => controller.startScenario(scenario),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.play_circle_outline, size: 18),
const Gap(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(scenario.title),
const Gap(2),
Text(scenario.summary).xSmall().muted(),
],
),
),
],
),
),
),
],
);
}
}
class _ScenarioSteps extends StatelessWidget {
const _ScenarioSteps({required this.controller, required this.scenario});
final DemoController controller;
final Scenario scenario;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final steps = controller.activeSteps;
final current = controller.scenarioStepIndex;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(scenario.title).semiBold(),
const Gap(2),
Text(scenario.summary).xSmall().muted(),
const Gap(12),
for (var i = 0; i < steps.length; i++)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
i < current
? Icons.check_circle
: i == current
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
size: 16,
color: i < current
? scheme.primary
: i == current
? scheme.foreground
: scheme.mutedForeground,
),
const Gap(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
steps[i].label,
style: TextStyle(
fontSize: 13,
color: i == current
? scheme.foreground
: scheme.mutedForeground,
fontWeight: i == current
? FontWeight.w500
: FontWeight.normal,
),
),
if (steps[i].replica != null)
Text(steps[i].replica!.label).xSmall().muted(),
],
),
),
],
),
),
const Gap(8),
if (controller.scenarioComplete)
PrimaryButton(
onPressed: controller.stopScenario,
child: const Text('Done'),
)
else
Row(
children: [
Expanded(
child: PrimaryButton(
onPressed:
controller.anyBusy || controller.scenarioAutoPlaying
? null
: () => unawaited(controller.runNextScenarioStep()),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.play_arrow, size: 16),
const Gap(4),
Text('Run step ${current + 1}'),
],
),
),
),
const Gap(8),
Tooltip(
tooltip: (context) => TooltipContainer(
child: Text(
controller.scenarioAutoPlaying
? 'Pause auto-play'
: 'Auto-play steps (1.0s apart)',
),
),
child: OutlineButton(
onPressed:
controller.anyBusy && !controller.scenarioAutoPlaying
? null
: controller.toggleScenarioAutoPlay,
child: Icon(
controller.scenarioAutoPlaying
? Icons.pause
: Icons.play_arrow,
size: 16,
),
),
),
],
),
],
);
}
}
// --- Replica panel ---------------------------------------------------------
class _ReplicaPanel extends StatelessWidget {
const _ReplicaPanel({required this.controller, required this.slot});
final DemoController controller;
final ReplicaSlot slot;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: Listenable.merge([controller, controller.replica(slot)]),
builder: (context, _) {
final scheme = Theme.of(context).colorScheme;
final state = controller.replica(slot);
final canUseReplica = !controller.busy && !state.busy;
final canSync =
controller.online &&
canUseReplica &&
!state.streaming &&
state.session != null;
final actions = _RowActions(controller: controller, slot: slot);
return OutlinedContainer(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
slot == ReplicaSlot.a ? Icons.computer : Icons.laptop,
size: 16,
),
const Gap(6),
Text(slot.label).semiBold(),
const Gap(8),
_SyncBadge(state: state),
if (state.busy) ...[
const Gap(6),
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(),
),
],
const Spacer(),
Text(
'${state.projection.visibleRowCount} visible · '
'${state.projection.hiddenRowCount} hidden',
).xSmall().muted(),
],
),
const Gap(8),
Row(
children: [
_NewRowButton(
controller: controller,
slot: slot,
enabled: canUseReplica && state.session != null,
),
const Spacer(),
PrimaryButton(
onPressed: canSync
? () => unawaited(controller.syncReplica(slot))
: null,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.sync, size: 14),
Gap(4),
Text('Sync'),
],
),
),
const Gap(12),
Checkbox(
state: state.streaming
? CheckboxState.checked
: CheckboxState.unchecked,
onChanged: (!controller.online || !canUseReplica)
? null
: (value) => unawaited(
controller.setReplicaStreaming(
slot,
value == CheckboxState.checked,
),
),
trailing: const Text('Stream'),
),
const Gap(4),
Tooltip(
tooltip: (context) => TooltipContainer(
child: Text('Reset ${slot.label}: wipe local database'),
),
child: IconButton.ghost(
icon: const Icon(Icons.delete_outline, size: 16),
onPressed: canUseReplica
? () => unawaited(controller.resetReplica(slot))
: null,
),
),
],
),
if (state.error != null) ...[
const Gap(6),
Row(
children: [
Icon(
Icons.error_outline,
size: 12,
color: scheme.destructive,
),
const Gap(4),
Expanded(
child: Text(
state.error!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: scheme.destructive,
),
),
),
],
),
],
const Gap(8),
const Divider(),
const Gap(8),
Expanded(
child: controller.initializing
? const _PanelLoading(message: 'Opening local database…')
: _ProjectionTree(
key: ValueKey(controller.showHidden),
nodes: state.projection.nodes,
actions: actions,
onTapRow: (ref) => unawaited(
showRowDetailSheet(context, controller, ref, slot),
),
),
),
],
),
);
},
);
}
}
/// Bundles the per-replica context the row action menus need.
class _RowActions {
_RowActions({required this.controller, required this.slot});
final DemoController controller;
final ReplicaSlot slot;
}
class _NewRowButton extends StatelessWidget {
const _NewRowButton({
required this.controller,
required this.slot,
required this.enabled,
});
final DemoController controller;
final ReplicaSlot slot;
final bool enabled;
@override
Widget build(BuildContext context) {
return OutlineButton(
onPressed: enabled ? () => _open(context) : null,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [Icon(Icons.add, size: 14), Gap(4), Text('Add data')],
),
);
}
void _open(BuildContext context) {
showDropdown(
context: context,
builder: (context) => DropdownMenu(
children: [
MenuButton(
leading: const Icon(Icons.account_tree, size: 16),
onPressed: (_) => unawaited(controller.seedBasicGraph(slot)),
child: const Text('Seed city/person/address/town'),
),
MenuButton(
leading: const Icon(Icons.data_object, size: 16),
onPressed: (_) => unawaited(controller.seedTypedRow(slot)),
child: const Text('Seed typed row'),
),
MenuButton(
leading: const Icon(Icons.hub, size: 16),
onPressed: (_) => unawaited(controller.seedForeignKeyChain(slot)),
child: const Text('Seed FK chain'),
),
for (final table in controller.catalog.creatableRootTables)
MenuButton(
leading: Icon(iconForTable(table, hidden: false), size: 16),
onPressed: (_) => unawaited(controller.createRoot(slot, table)),
child: Text(tableLabel(table)),
),
],
),
);
}
}
// --- Server panel ----------------------------------------------------------
class _ServerPanel extends StatelessWidget {
const _ServerPanel({required this.controller});
final DemoController controller;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: Listenable.merge([controller, controller.server]),
builder: (context, _) {
final state = controller.server;
final serverBusy = controller.busy || state.busy || state.loading;
return OutlinedContainer(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(Icons.dns, size: 16),
const Gap(6),
const Text('Server').semiBold(),
const Gap(8),
_ServerBadge(controller: controller),
if (state.busy) ...[
const Gap(6),
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(),
),
],
],
),
const Gap(6),
Row(
children: [
Expanded(
child: Text(
'${state.projection.visibleRowCount} rows · merged truth',
).xSmall().muted(),
),
_ServerSeedButton(
controller: controller,
enabled: controller.online && !serverBusy,
),
const Gap(4),
Tooltip(
tooltip: (context) => const TooltipContainer(
child: Text('Reset server scope: clear all rows'),
),
child: IconButton.ghost(
icon: const Icon(Icons.delete_outline, size: 16),
onPressed: (!controller.online || serverBusy)
? null
: () => unawaited(controller.resetServer()),
),
),
const Gap(4),
Tooltip(
tooltip: (context) => const TooltipContainer(
child: Text('Refresh server state'),
),
child: IconButton.ghost(
icon: const Icon(Icons.refresh, size: 16),
onPressed: (!controller.online || serverBusy)
? null
: () => unawaited(controller.refreshServer()),
),
),
],
),
const Gap(8),
const Divider(),
const Gap(8),
Expanded(
child: controller.initializing
? const _PanelLoading(message: 'Loading server state…')
: !controller.online
? const Center(
child: Text('Offline — connect to view server state.'),
)
: state.error != null
? Center(
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(state.error!).small().muted(),
),
)
: _ProjectionTree(
key: ValueKey(controller.showHidden),
nodes: state.projection.nodes,
),
),
],
),
);
},
);
}
}
class _ServerSeedButton extends StatelessWidget {
const _ServerSeedButton({required this.controller, required this.enabled});
final DemoController controller;
final bool enabled;
@override
Widget build(BuildContext context) {
return Tooltip(
tooltip: (context) =>
const TooltipContainer(child: Text('Seed the server scope directly')),
child: IconButton.ghost(
icon: const Icon(Icons.add, size: 16),
onPressed: enabled ? () => _open(context) : null,
),
);
}
void _open(BuildContext context) {
showDropdown(
context: context,
builder: (context) => DropdownMenu(
children: [
MenuButton(
leading: const Icon(Icons.account_tree, size: 16),
onPressed: (_) => unawaited(controller.seedServer('basicGraph')),
child: const Text('Seed graph'),
),
MenuButton(
leading: const Icon(Icons.data_object, size: 16),
onPressed: (_) => unawaited(controller.seedServer('typedRow')),
child: const Text('Seed typed row'),
),
MenuButton(
leading: const Icon(Icons.hub, size: 16),
onPressed: (_) => unawaited(controller.seedServer('fkChain')),
child: const Text('Seed FK chain'),
),
],
),
);
}
}
class _Badge extends StatelessWidget {
const _Badge({
required this.text,
required this.background,
required this.foreground,
this.tooltip,
this.compact = false,
});
final String text;
final Color background;
final Color foreground;
final String? tooltip;
final bool compact;
@override
Widget build(BuildContext context) {
final badge = Container(
padding: compact
? const EdgeInsets.symmetric(horizontal: 6, vertical: 1)
: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(6),
),
child: Text(
text,
style: TextStyle(color: foreground, fontSize: compact ? 10 : 11),
),
);
final message = tooltip;
return message == null
? badge
: Tooltip(
tooltip: (context) => TooltipContainer(child: Text(message)),
child: badge,
);
}
}
class _ServerBadge extends StatelessWidget {
const _ServerBadge({required this.controller});
final DemoController controller;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final state = controller.server;
String label;
Color bg;
Color fg;
if (!controller.online) {
label = 'offline';
bg = scheme.muted;
fg = scheme.mutedForeground;
} else if (state.busy || state.loading) {
label = 'loading';
bg = scheme.primary;
fg = scheme.primaryForeground;
} else if (state.error != null) {
label = 'error';
bg = scheme.destructive;
fg = Colors.white;
} else {
label = state.lastFetchedLabel ?? 'idle';
bg = scheme.muted;
fg = scheme.mutedForeground;
}
return _Badge(
text: label,
background: bg,
foreground: fg,
tooltip: state.error ?? label,
);
}
}
class _SyncBadge extends StatelessWidget {
const _SyncBadge({required this.state});
final ReplicaState state;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final failed = state.phase == SyncPhase.failed;
final active =
state.busy ||
state.phase == SyncPhase.syncing ||
state.phase == SyncPhase.streaming;
final bg = failed
? scheme.destructive
: active
? scheme.primary
: scheme.muted;
final fg = failed
? Colors.white
: active
? scheme.primaryForeground
: scheme.mutedForeground;
final label = state.busy && state.phase == SyncPhase.idle
? 'Working'
: state.lastSyncedLabel != null && state.phase == SyncPhase.idle
? state.lastSyncedLabel!
: state.phase.label;
return _Badge(
text: label,
background: bg,
foreground: fg,
tooltip: state.error ?? label,
);
}
}
/// Centered loading indicator shown in a panel body while the local databases
/// are still opening (during [DemoController.initializing]).
class _PanelLoading extends StatelessWidget {
const _PanelLoading({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(),
),
const Gap(10),
Text(message).small().muted(),
],
),
);
}
}
// --- Tree ------------------------------------------------------------------
class _ProjectionTree extends StatefulWidget {
const _ProjectionTree({
super.key,
required this.nodes,
this.onTapRow,
this.actions,
});
final List<TreeNode<DemoTreeItem>> nodes;
final void Function(DemoRowRef ref)? onTapRow;
final _RowActions? actions;
@override
State<_ProjectionTree> createState() => _ProjectionTreeState();
}
class _ProjectionTreeState extends State<_ProjectionTree> {
late List<TreeNode<DemoTreeItem>> nodes;
@override
void initState() {
super.initState();
nodes = widget.nodes;
}
@override
void didUpdateWidget(covariant _ProjectionTree oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.nodes, widget.nodes)) {
nodes = widget.nodes;
}
}
@override
Widget build(BuildContext context) {
if (nodes.isEmpty) {
return const Center(child: Text('No rows yet.'));
}
final onTapRow = widget.onTapRow;
return TreeView<DemoTreeItem>(
nodes: nodes,
expandIcon: true,
branchLine: BranchLine.path,
builder: (context, node) {
return _DemoTreeItemView(
node: node,
actions: widget.actions,
onTapRow: onTapRow,
onExpand: TreeView.defaultItemExpandHandler(nodes, node, (value) {
setState(() => nodes = value);
}),
);
},
);
}
}
class _DemoTreeItemView extends StatefulWidget {
const _DemoTreeItemView({
required this.node,
required this.onExpand,
required this.onTapRow,
required this.actions,
});
final TreeItem<DemoTreeItem> node;
final ValueChanged<bool> onExpand;
final void Function(DemoRowRef ref)? onTapRow;
final _RowActions? actions;
@override
State<_DemoTreeItemView> createState() => _DemoTreeItemViewState();
}
class _DemoTreeItemViewState extends State<_DemoTreeItemView> {
late final FocusNode _focusNode;
bool _hovered = false;
@override
void initState() {
super.initState();
_focusNode = FocusNode(canRequestFocus: false, skipTraversal: true);
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
void _handlePressed() {
final ref = widget.node.data.ref;
final onTapRow = widget.onTapRow;
if (ref != null && onTapRow != null) {
onTapRow(ref);
return;
}
if (widget.node.children.isNotEmpty) {
widget.onExpand(!widget.node.expanded);
}
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final item = widget.node.data;
final ref = item.ref;
final selectable = ref != null && widget.onTapRow != null;
final expandable = widget.node.children.isNotEmpty;
final interactive = selectable || expandable;
final showActions = widget.actions != null && ref != null && !item.metadata;
return MouseRegion(
cursor: interactive ? SystemMouseCursors.click : SystemMouseCursors.basic,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Container(
decoration: BoxDecoration(
color: _hovered ? scheme.muted : Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: TreeItemView(
focusNode: _focusNode,
leading: Icon(
item.icon,
size: 14,
color: item.hidden ? scheme.mutedForeground : null,
),
onExpand: widget.onExpand,
onPressed: interactive ? _handlePressed : null,
trailing: showActions
? _RowActionBar(actions: widget.actions!, ref: ref)
: null,
child: Row(
children: [
Flexible(
child: Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: item.metadata
? FontWeight.normal
: FontWeight.w500,
color: item.hidden
? scheme.mutedForeground
: scheme.foreground,
decoration: item.hidden ? TextDecoration.lineThrough : null,
),
),
),
if (item.detail != null)
Flexible(
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
item.detail!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: scheme.mutedForeground,
),
),
),
),
if (item.relation != null) ...[
const Gap(6),
_FkBadge(relation: item.relation!),
],
if (item.dangling) ...[const Gap(6), _DanglingBadge()],
],
),
),
),
);
}
}
class _FkBadge extends StatelessWidget {
const _FkBadge({required this.relation});
final Relationship relation;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final (bg, fg) = _actionColors(scheme, relation.onDelete);
return _Badge(
text: '${relationLabel(relation.fkColumn)} · ${relation.onDelete.label}',
background: bg,
foreground: fg,
compact: true,
tooltip:
'${relationLabel(relation.fkColumn)} → ${tableLabel(relation.parentTable)} '
'· ${relation.onDelete.label} (${relation.onDelete.summary})',
);
}
}
class _DanglingBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return _Badge(
text: 'dangling',
background: scheme.muted,
foreground: scheme.mutedForeground,
compact: true,
tooltip: 'Foreign key points at a row not present here.',
);
}
}
(Color, Color) _actionColors(ColorScheme scheme, FkAction action) {
return switch (action) {
FkAction.restrict => (scheme.destructive, Colors.white),
FkAction.cascade => (const Color(0xFFB45309), Colors.white),
FkAction.setNull ||
FkAction.setDefault => (scheme.primary, scheme.primaryForeground),
FkAction.noAction => (scheme.muted, scheme.mutedForeground),
};
}
class _RowActionBar extends StatelessWidget {
const _RowActionBar({required this.actions, required this.ref});
final _RowActions actions;
final DemoRowRef ref;
@override
Widget build(BuildContext context) {
final controller = actions.controller;
final children = controller.catalog.childRelationshipsOf(ref.tableName);
final parents = controller.catalog.parentRelationshipsOf(ref.tableName);
final busy = controller.replicaBusy(actions.slot);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (children.isNotEmpty)
_ActionIcon(
icon: Icons.add,
tooltip: 'Attach a child',
onTap: busy ? null : () => _openAddChild(context, children),
),
if (parents.isNotEmpty)
_ActionIcon(
icon: Icons.drive_file_move_outline,
tooltip: 'Re-parent or detach',
onTap: busy ? null : () => _openMove(context, parents),
),
],
);
}
void _openAddChild(BuildContext context, List<Relationship> children) {
showDropdown(
context: context,
builder: (context) => DropdownMenu(
children: [
for (final relation in children)
MenuButton(
leading: Icon(
iconForTable(relation.childTable, hidden: false),
size: 16,
),
trailing: _MiniActionTag(action: relation.onDelete),
onPressed: (_) => unawaited(
actions.controller.createChildFor(actions.slot, ref, relation),
),
child: Text(
'${tableLabel(relation.childTable)} · '
'${relationLabel(relation.fkColumn)}',
),
),
],
),
);
}
void _openMove(BuildContext context, List<Relationship> parents) {
if (parents.length == 1) {
_openParentPicker(context, parents.single);
return;
}
showDropdown(
context: context,
builder: (context) => DropdownMenu(
children: [
for (final relation in parents)
MenuButton(
trailing: _MiniActionTag(action: relation.onDelete),
onPressed: (_) => _openParentPicker(context, relation),
child: Text(
'${relationLabel(relation.fkColumn)} → '
'${tableLabel(relation.parentTable)}',
),
),
],
),
);
}
void _openParentPicker(BuildContext context, Relationship relation) {
final controller = actions.controller;
final options = controller
.parentOptions(actions.slot, relation.parentTable)
.where((option) => option.ref.id.uuid != ref.id.uuid)
.toList();
showDropdown(
context: context,
builder: (context) => DropdownMenu(
children: [
MenuButton(
leading: const Icon(Icons.link_off, size: 16),
onPressed: (_) => unawaited(
controller.reParent(actions.slot, ref, relation.fkColumn, null),
),
child: const Text('Detach (null)'),
),
for (final option in options)
MenuButton(
leading: Icon(
iconForTable(relation.parentTable, hidden: !option.visible),
size: 16,
),
onPressed: (_) => unawaited(
controller.reParent(
actions.slot,
ref,
relation.fkColumn,
option.ref.id,
),
),
child: Text(option.label),
),
],
),
);
}
}
class _ActionIcon extends StatelessWidget {
const _ActionIcon({
required this.icon,
required this.tooltip,
required this.onTap,
});
final IconData icon;
final String tooltip;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final color = onTap == null
? scheme.mutedForeground.withAlpha(110)
: scheme.mutedForeground;
return Tooltip(
tooltip: (context) => TooltipContainer(child: Text(tooltip)),
child: MouseRegion(
cursor: onTap == null
? SystemMouseCursors.basic
: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 3),
child: Icon(icon, size: 15, color: color),
),
),
),
);
}
}
class _MiniActionTag extends StatelessWidget {
const _MiniActionTag({required this.action});
final FkAction action;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final (bg, fg) = _actionColors(scheme, action);
return _Badge(
text: action.label,
background: bg,
foreground: fg,
compact: true,
);
}
}
class _StatusBar extends StatelessWidget {
const _StatusBar({required this.controller});
final DemoController controller;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (context, _) => OutlinedContainer(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
Icon(controller.online ? Icons.wifi : Icons.wifi_off, size: 18),
const Gap(8),
Expanded(child: Text(controller.status)),
if (controller.anyBusy)
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(),
),
],
),
),
);
}
}
/// Top-bar action that flips the app between light and dark themes.
class ThemeToggleButton extends StatelessWidget {
const ThemeToggleButton({
super.key,
required this.isDark,
required this.onToggle,
});
final bool isDark;
final VoidCallback onToggle;
@override
Widget build(BuildContext context) {
return IconButton.ghost(
icon: Icon(isDark ? Icons.light_mode : Icons.dark_mode, size: 18),
onPressed: onToggle,
);
}
}
/// Top-bar avatar that switches between demo users and creates new ones.
class UserAvatarMenu extends StatelessWidget {
const UserAvatarMenu({super.key, required this.controller});
final DemoController controller;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
final user = controller.selectedUser;
return GhostButton(
onPressed: controller.anyBusy ? null : () => _open(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Avatar(initials: user?.initials ?? '?', size: 28),
const Gap(8),
Text(user?.username ?? 'no user'),
const Gap(2),
const Icon(Icons.arrow_drop_down, size: 18),
],
),
);
},
);
}
void _open(BuildContext context) {
showDropdown(
context: context,
builder: (context) => DropdownMenu(
children: [
for (final user in controller.users)
MenuButton(
leading: Avatar(initials: user.initials, size: 20),
onPressed: (_) => unawaited(controller.switchUser(user)),
child: Text(user.username),
),
MenuButton(
leading: const Icon(Icons.person_add, size: 16),
onPressed: (_) =>
unawaited(showCreateUserSheet(context, controller)),
child: const Text('Create user…'),
),
],
),
);
}
}