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 createState() => _OtoConsoleShellState(); } class _OtoConsoleShellState extends State { 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 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 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 details; final List 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), ), ), ], ), ); } }