1388 lines
42 KiB
Dart
1388 lines
42 KiB
Dart
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…'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|