oto/packages/flutter/oto_console/lib/src/oto_console_shell.dart

623 lines
18 KiB
Dart

import 'package:flutter/material.dart';
import 'oto_agent_panel.dart';
import 'oto_console_contract.dart';
class OtoConsoleShell extends StatefulWidget {
final Widget overview;
final Widget? runners;
final Widget? pipelines;
final Widget? executions;
final Widget? artifacts;
final Widget? agent;
final Widget? settings;
final OtoConsoleSection initialSection;
final OtoConsoleConfig? config;
final OtoConsoleThemeAdapter? themeAdapter;
final OtoConsoleNavigationCallback? onNavigate;
final OtoCapabilityPack? capabilities;
const OtoConsoleShell({
super.key,
required this.overview,
this.runners,
this.pipelines,
this.executions,
this.artifacts,
this.agent,
this.settings,
this.initialSection = OtoConsoleSection.overview,
this.config,
this.themeAdapter,
this.onNavigate,
this.capabilities,
});
@override
State<OtoConsoleShell> createState() => _OtoConsoleShellState();
}
class _OtoConsoleShellState extends State<OtoConsoleShell> {
late OtoConsoleSection _section = widget.initialSection;
@override
void didUpdateWidget(covariant OtoConsoleShell oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialSection != widget.initialSection) {
_section = widget.initialSection;
}
}
@override
Widget build(BuildContext context) {
final theme = widget.themeAdapter ?? const OtoConsoleThemeAdapter();
final descriptor = _SectionDescriptor.forSection(_section);
return Scaffold(
backgroundColor: theme.backgroundColor,
body: SafeArea(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_OtoConsoleRail(
selected: _section,
onSelect: _select,
theme: theme,
),
Expanded(
child: _ConsolePageFrame(
descriptor: descriptor,
theme: theme,
child: _buildContent(theme),
),
),
],
),
),
);
}
void _select(OtoConsoleSection section) {
setState(() {
_section = section;
});
widget.onNavigate?.call(section);
}
Widget _buildContent(OtoConsoleThemeAdapter theme) {
return switch (_section) {
OtoConsoleSection.overview => widget.overview,
OtoConsoleSection.runners =>
widget.runners ??
OtoConsoleSectionSurface(
icon: Icons.precision_manufacturing_outlined,
title: 'Runners',
emptyTitle: 'No runners connected',
emptyMessage: 'Runner registry is empty.',
theme: theme,
),
OtoConsoleSection.pipelines =>
widget.pipelines ??
OtoConsoleSectionSurface(
icon: Icons.account_tree_outlined,
title: 'Pipelines / Jobs',
emptyTitle: 'No pipeline jobs',
emptyMessage: 'Job queue is empty.',
theme: theme,
),
OtoConsoleSection.executions =>
widget.executions ??
OtoConsoleSectionSurface(
icon: Icons.play_circle_outline,
title: 'Executions',
emptyTitle: 'No executions',
emptyMessage: 'Execution history is empty.',
theme: theme,
),
OtoConsoleSection.artifacts =>
widget.artifacts ??
OtoConsoleSectionSurface(
icon: Icons.inventory_2_outlined,
title: 'Artifacts',
emptyTitle: 'No artifacts',
emptyMessage: 'Artifact events are empty.',
theme: theme,
),
OtoConsoleSection.agent =>
widget.agent ?? OtoAgentPanel(capabilities: widget.capabilities),
OtoConsoleSection.settings =>
widget.settings ??
OtoConsoleSectionSurface(
icon: Icons.tune,
title: 'Settings',
emptyTitle: 'No local preferences',
emptyMessage: 'Settings are using runtime defaults.',
detailGroups: [
OtoConsoleSectionDetailGroup(
title: 'Runtime endpoints',
details: [
if (widget.config != null)
OtoConsoleSectionDetail(
label: 'HTTP API',
value: widget.config!.serverHttpUrl,
),
if (widget.config != null)
OtoConsoleSectionDetail(
label: 'Wire URL',
value: widget.config!.serverWireUrl,
),
],
),
const OtoConsoleSectionDetailGroup(
title: 'Browser policy',
details: [
OtoConsoleSectionDetail(
label: 'Browser API policy',
value: 'CORS headers or a same-origin proxy are required.',
),
],
),
],
theme: theme,
),
};
}
}
class _SectionDescriptor {
final OtoConsoleSection section;
final String label;
final String group;
const _SectionDescriptor({
required this.section,
required this.label,
required this.group,
});
static _SectionDescriptor forSection(OtoConsoleSection section) {
return switch (section) {
OtoConsoleSection.overview => _SectionDescriptor(
section: section,
label: 'Overview',
group: 'overview',
),
OtoConsoleSection.pipelines || OtoConsoleSection.executions =>
_SectionDescriptor(
section: section,
label: section == OtoConsoleSection.pipelines
? 'Pipelines'
: 'Executions',
group: 'workflow',
),
OtoConsoleSection.runners || OtoConsoleSection.artifacts =>
_SectionDescriptor(
section: section,
label: section == OtoConsoleSection.runners
? 'Runners'
: 'Artifacts',
group: 'operations',
),
OtoConsoleSection.agent => _SectionDescriptor(
section: section,
label: 'Agent',
group: 'admin',
),
OtoConsoleSection.settings => _SectionDescriptor(
section: section,
label: 'Settings',
group: 'admin',
),
};
}
}
class _ConsolePageFrame extends StatelessWidget {
final _SectionDescriptor descriptor;
final OtoConsoleThemeAdapter theme;
final Widget child;
const _ConsolePageFrame({
required this.descriptor,
required this.theme,
required this.child,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PageHeader(descriptor: descriptor, theme: theme),
Expanded(child: child),
],
);
}
}
class _PageHeader extends StatelessWidget {
final _SectionDescriptor descriptor;
final OtoConsoleThemeAdapter theme;
const _PageHeader({
required this.descriptor,
required this.theme,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
color: theme.railBackgroundColor,
child: Row(
children: [
Icon(descriptor.section == OtoConsoleSection.overview
? Icons.dashboard_outlined
: descriptor.section == OtoConsoleSection.runners
? Icons.precision_manufacturing_outlined
: descriptor.section == OtoConsoleSection.pipelines
? Icons.account_tree_outlined
: descriptor.section == OtoConsoleSection.executions
? Icons.play_circle_outline
: descriptor.section == OtoConsoleSection.artifacts
? Icons.inventory_2_outlined
: descriptor.section == OtoConsoleSection.agent
? Icons.smart_toy_outlined
: Icons.tune,
color: theme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
Text(
descriptor.label,
style: TextStyle(
color: theme.textColor,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
],
),
);
}
}
class _RailDivider extends StatelessWidget {
const _RailDivider();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Container(
height: 1,
color: Colors.white38,
),
);
}
}
class _OtoConsoleRail extends StatelessWidget {
final OtoConsoleSection selected;
final ValueChanged<OtoConsoleSection> onSelect;
final OtoConsoleThemeAdapter theme;
const _OtoConsoleRail({
required this.selected,
required this.onSelect,
required this.theme,
});
@override
Widget build(BuildContext context) {
return Container(
width: 60,
decoration: BoxDecoration(
color: theme.railBackgroundColor,
border: Border(right: BorderSide(color: theme.borderColor)),
),
child: Column(
children: [
const SizedBox(height: 8),
_RailButton(
tooltip: 'Overview',
icon: Icons.dashboard_outlined,
selected: selected == OtoConsoleSection.overview,
onPressed: () => onSelect(OtoConsoleSection.overview),
theme: theme,
),
const _RailDivider(),
_RailButton(
tooltip: 'Pipelines',
icon: Icons.account_tree_outlined,
selected: selected == OtoConsoleSection.pipelines,
onPressed: () => onSelect(OtoConsoleSection.pipelines),
theme: theme,
),
_RailButton(
tooltip: 'Executions',
icon: Icons.play_circle_outline,
selected: selected == OtoConsoleSection.executions,
onPressed: () => onSelect(OtoConsoleSection.executions),
theme: theme,
),
const _RailDivider(),
_RailButton(
tooltip: 'Runners',
icon: Icons.precision_manufacturing_outlined,
selected: selected == OtoConsoleSection.runners,
onPressed: () => onSelect(OtoConsoleSection.runners),
theme: theme,
),
_RailButton(
tooltip: 'Artifacts',
icon: Icons.inventory_2_outlined,
selected: selected == OtoConsoleSection.artifacts,
onPressed: () => onSelect(OtoConsoleSection.artifacts),
theme: theme,
),
const Spacer(),
_RailButton(
tooltip: 'Settings',
icon: Icons.tune,
selected: selected == OtoConsoleSection.settings,
onPressed: () => onSelect(OtoConsoleSection.settings),
theme: theme,
),
_RailButton(
tooltip: 'Agent',
icon: Icons.smart_toy_outlined,
selected: selected == OtoConsoleSection.agent,
onPressed: () => onSelect(OtoConsoleSection.agent),
theme: theme,
),
const SizedBox(height: 8),
],
),
);
}
}
class _RailButton extends StatelessWidget {
final String tooltip;
final IconData icon;
final bool selected;
final VoidCallback onPressed;
final OtoConsoleThemeAdapter theme;
const _RailButton({
required this.tooltip,
required this.icon,
required this.selected,
required this.onPressed,
required this.theme,
});
@override
Widget build(BuildContext context) {
final color = selected ? theme.primaryColor : theme.textSecondaryColor;
return Tooltip(
message: tooltip,
waitDuration: const Duration(milliseconds: 500),
child: SizedBox(
width: 56,
height: 48,
child: IconButton(
onPressed: onPressed,
icon: Icon(icon, color: color),
style: IconButton.styleFrom(
backgroundColor: selected
? theme.primaryColor.withValues(alpha: 0.1)
: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
);
}
}
class OtoConsoleSectionDetail {
final String label;
final String value;
const OtoConsoleSectionDetail({required this.label, required this.value});
}
class OtoConsoleSectionDetailGroup {
final String title;
final List<OtoConsoleSectionDetail> details;
const OtoConsoleSectionDetailGroup({
required this.title,
required this.details,
});
}
class OtoConsoleSectionSurface extends StatelessWidget {
final IconData icon;
final String title;
final String emptyTitle;
final String emptyMessage;
final OtoConsoleThemeAdapter theme;
final List<OtoConsoleSectionDetail> details;
final List<OtoConsoleSectionDetailGroup> detailGroups;
const OtoConsoleSectionSurface({
super.key,
required this.icon,
required this.title,
required this.emptyTitle,
required this.emptyMessage,
required this.theme,
this.details = const [],
this.detailGroups = const [],
});
@override
Widget build(BuildContext context) {
return Container(
color: theme.backgroundColor,
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 820),
child: ListView(
shrinkWrap: true,
children: [
Row(
children: [
Icon(icon, color: theme.primaryColor, size: 24),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: theme.textColor,
fontSize: 24,
fontWeight: FontWeight.w800,
),
),
),
],
),
const SizedBox(height: 20),
_EmptyStatePanel(
icon: icon,
title: emptyTitle,
message: emptyMessage,
theme: theme,
),
if (detailGroups.isNotEmpty) ...[
for (final group in detailGroups) ...[
const SizedBox(height: 20),
Text(
group.title,
style: TextStyle(
color: theme.textSecondaryColor,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 10),
for (final detail in group.details) ...[
_SectionDetailTile(detail: detail, theme: theme),
const SizedBox(height: 10),
],
],
] else if (details.isNotEmpty) ...[
const SizedBox(height: 20),
for (final detail in details) ...[
_SectionDetailTile(detail: detail, theme: theme),
const SizedBox(height: 10),
],
],
],
),
),
),
),
);
}
}
class _EmptyStatePanel extends StatelessWidget {
final IconData icon;
final String title;
final String message;
final OtoConsoleThemeAdapter theme;
const _EmptyStatePanel({
required this.icon,
required this.title,
required this.message,
required this.theme,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: theme.railBackgroundColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.borderColor),
),
child: Row(
children: [
Icon(icon, color: theme.primaryColor, size: 32),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
color: theme.textColor,
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
message,
overflow: TextOverflow.ellipsis,
maxLines: 2,
style: TextStyle(color: theme.textSecondaryColor),
),
],
),
),
],
),
);
}
}
class _SectionDetailTile extends StatelessWidget {
final OtoConsoleSectionDetail detail;
final OtoConsoleThemeAdapter theme;
const _SectionDetailTile({required this.detail, required this.theme});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.primaryColor.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.primaryColor.withValues(alpha: 0.18)),
),
child: Row(
children: [
SizedBox(
width: 150,
child: Text(
detail.label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: theme.textSecondaryColor,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
detail.value,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(color: theme.textColor),
),
),
],
),
);
}
}