diff --git a/apps/client/README.md b/apps/client/README.md index dd8bff1..0054c91 100644 --- a/apps/client/README.md +++ b/apps/client/README.md @@ -1,17 +1,30 @@ -# oto_client +# OTO Client -A new Flutter project. +Flutter host application for the OTO console. -## Getting Started +The embeddable UI surface lives in `packages/flutter/oto_console` so other +workspace apps, such as `../nomadcode/apps/client`, can mount OTO as a modular +Flutter widget instead of depending on this host application's `main.dart`. -This project is a starting point for a Flutter application. +## Structure -A few resources to get you started if this is your first Flutter project: +- `lib/main.dart` only starts the OTO client host. +- `lib/src/app/bootstrap.dart` owns app startup concerns such as fullscreen + system UI setup. +- `lib/src/app/oto_client_app.dart` wires the host `MaterialApp` to + `OtoConsoleShell`. +- `../../packages/flutter/oto_console` exports the product-owned embeddable + widgets. That package consumes the workspace shared `agent-shell` package. -- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) -- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) +## Embedding -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +Add the package from another workspace Flutter app: + +```yaml +dependencies: + oto_console: + path: ../../../oto/packages/flutter/oto_console +``` + +Then mount any exported widget, for example `OtoConsoleShell` or +`OtoAgentPanel`, inside the host application's own workbench shell. diff --git a/apps/client/lib/main.dart b/apps/client/lib/main.dart index a2808b4..07f1564 100644 --- a/apps/client/lib/main.dart +++ b/apps/client/lib/main.dart @@ -1,56 +1,3 @@ -import 'package:flutter/material.dart'; - -void main() { - runApp(const MyApp()); -} - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'OTO Console', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.blueGrey), - useMaterial3: true, - ), - home: const MyHomePage(title: 'OTO Console'), - ); - } -} - -class MyHomePage extends StatelessWidget { - const MyHomePage({super.key, required this.title}); - - final String title; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - title: Text(title), - ), - body: const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.dashboard, size: 64, color: Colors.blueGrey), - SizedBox(height: 16), - Text( - 'Welcome to OTO Console', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), - ), - SizedBox(height: 8), - Text( - 'Independent Control Plane', - style: TextStyle(fontSize: 16, color: Colors.grey), - ), - ], - ), - ), - ); - } -} +import 'src/app/bootstrap.dart'; +Future main() => runOtoClient(); diff --git a/apps/client/lib/src/app/bootstrap.dart b/apps/client/lib/src/app/bootstrap.dart new file mode 100644 index 0000000..85aa1d5 --- /dev/null +++ b/apps/client/lib/src/app/bootstrap.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'oto_client_app.dart'; + +Future applyFullscreenMode() async { + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + systemNavigationBarColor: Colors.transparent, + systemNavigationBarDividerColor: Colors.transparent, + ), + ); +} + +Future bootstrapOtoClient() async { + await applyFullscreenMode(); +} + +Future runOtoClient() async { + WidgetsFlutterBinding.ensureInitialized(); + await bootstrapOtoClient(); + runApp(const OtoClientApp()); +} diff --git a/apps/client/lib/src/app/oto_client_app.dart b/apps/client/lib/src/app/oto_client_app.dart new file mode 100644 index 0000000..a947f7e --- /dev/null +++ b/apps/client/lib/src/app/oto_client_app.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:oto_console/oto_console.dart'; + +const otoDefaultCapabilityPack = OtoCapabilityPack( + capabilities: [ + OtoCapability( + name: 'Runner Registry', + description: 'Track connected build runners and bootstrap status.', + ), + OtoCapability( + name: 'Remote Run', + description: 'Dispatch typed command build and deploy pipelines.', + ), + OtoCapability( + name: 'Execution Status', + description: 'Follow active runs, cancellation, and terminal state.', + ), + OtoCapability( + name: 'Step Logs', + description: 'Inspect live command output and execution traces.', + ), + OtoCapability( + name: 'Artifacts', + description: 'Review build output events and published artifacts.', + ), + ], +); + +class OtoClientApp extends StatelessWidget { + final OtoConsoleConfig config; + final String statusText; + final OtoCapabilityPack capabilities; + + const OtoClientApp({ + super.key, + this.config = const OtoConsoleConfig( + serverHttpUrl: String.fromEnvironment( + 'OTO_SERVER_HTTP_URL', + defaultValue: 'http://localhost:8080', + ), + serverWireUrl: String.fromEnvironment( + 'OTO_SERVER_WIRE_URL', + defaultValue: 'ws://localhost:18080/runner', + ), + ), + this.statusText = 'Disconnected', + this.capabilities = otoDefaultCapabilityPack, + }); + + @override + Widget build(BuildContext context) { + const themeAdapter = OtoConsoleThemeAdapter(); + return MaterialApp( + title: 'OTO Client', + debugShowCheckedModeBanner: false, + theme: ThemeData.dark(useMaterial3: true).copyWith( + colorScheme: ColorScheme.fromSeed( + seedColor: themeAdapter.primaryColor, + brightness: Brightness.dark, + primary: themeAdapter.primaryColor, + secondary: themeAdapter.accentColor, + ), + scaffoldBackgroundColor: themeAdapter.backgroundColor, + ), + home: OtoConsoleShell( + config: config, + capabilities: capabilities, + themeAdapter: themeAdapter, + overview: OtoConsoleOverview( + config: config, + statusText: statusText, + themeAdapter: themeAdapter, + ), + ), + ); + } +} diff --git a/apps/client/pubspec.yaml b/apps/client/pubspec.yaml index ec1ff04..f1121e0 100644 --- a/apps/client/pubspec.yaml +++ b/apps/client/pubspec.yaml @@ -35,6 +35,14 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + # Shared embeddable agent interaction shell. + agent_shell: + path: ../../../agent-shell + + # OTO-owned embeddable console widgets. + oto_console: + path: ../../packages/flutter/oto_console + dev_dependencies: flutter_test: sdk: flutter diff --git a/apps/client/test/widget_test.dart b/apps/client/test/widget_test.dart index 53c353b..f44c14e 100644 --- a/apps/client/test/widget_test.dart +++ b/apps/client/test/widget_test.dart @@ -1,29 +1,18 @@ -// This is a basic Flutter widget test for OTO Console app shell. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:oto_client/main.dart'; +import 'package:oto_client/src/app/oto_client_app.dart'; void main() { - testWidgets('OTO Console app shell rendering test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + testWidgets('OTO client hosts embeddable console widgets', (tester) async { + await tester.pumpWidget(const OtoClientApp()); - // Verify that our app bar shows the correct title. - expect(find.text('OTO Console'), findsOneWidget); + expect(find.text('Build Operations Overview'), findsOneWidget); + expect(find.byTooltip('Agent'), findsOneWidget); - // Verify that welcome text and subtext are rendered. - expect(find.text('Welcome to OTO Console'), findsOneWidget); - expect(find.text('Independent Control Plane'), findsOneWidget); + await tester.tap(find.byTooltip('Agent')); + await tester.pumpAndSettle(); - // Verify dashboard icon is present. - expect(find.byIcon(Icons.dashboard), findsOneWidget); + expect(find.textContaining('OTO agent surface is ready'), findsOneWidget); + expect(find.textContaining('Runner Registry'), findsOneWidget); }); } - diff --git a/packages/flutter/oto_console/.gitignore b/packages/flutter/oto_console/.gitignore new file mode 100644 index 0000000..3ca983b --- /dev/null +++ b/packages/flutter/oto_console/.gitignore @@ -0,0 +1,12 @@ +# Flutter/Dart generated files +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Local IDE files +.idea/ +*.iml diff --git a/packages/flutter/oto_console/analysis_options.yaml b/packages/flutter/oto_console/analysis_options.yaml new file mode 100644 index 0000000..a862a18 --- /dev/null +++ b/packages/flutter/oto_console/analysis_options.yaml @@ -0,0 +1,6 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + prefer_const_constructors: true + prefer_final_fields: true diff --git a/packages/flutter/oto_console/lib/oto_console.dart b/packages/flutter/oto_console/lib/oto_console.dart new file mode 100644 index 0000000..c487f49 --- /dev/null +++ b/packages/flutter/oto_console/lib/oto_console.dart @@ -0,0 +1,4 @@ +export 'src/oto_agent_panel.dart'; +export 'src/oto_console_contract.dart'; +export 'src/oto_console_overview.dart'; +export 'src/oto_console_shell.dart'; diff --git a/packages/flutter/oto_console/lib/src/oto_agent_panel.dart b/packages/flutter/oto_console/lib/src/oto_agent_panel.dart new file mode 100644 index 0000000..6802f2a --- /dev/null +++ b/packages/flutter/oto_console/lib/src/oto_agent_panel.dart @@ -0,0 +1,42 @@ +import 'package:agent_shell/agent_shell.dart'; +import 'package:flutter/material.dart'; + +import 'oto_console_contract.dart'; + +class OtoAgentPanel extends StatelessWidget { + final ValueChanged? onSubmit; + final OtoCapabilityPack? capabilities; + + const OtoAgentPanel({super.key, this.onSubmit, this.capabilities}); + + List _buildMessages() { + final introText = StringBuffer( + 'OTO agent surface is ready. Runner, pipeline, execution, log, and artifact capabilities can be registered here.', + ); + + if (capabilities != null && capabilities!.capabilities.isNotEmpty) { + introText.write('\n\nRegistered capabilities:'); + for (final capability in capabilities!.capabilities) { + introText.write('\n- ${capability.name}: ${capability.description}'); + } + } + + return [ + AgentMessage( + id: 'oto-agent-intro', + role: AgentMessageRole.assistant, + text: introText.toString(), + ), + ]; + } + + @override + Widget build(BuildContext context) { + return AgentShell( + messages: _buildMessages(), + busy: false, + placeholder: 'Ask about OTO builds', + onSubmit: onSubmit, + ); + } +} diff --git a/packages/flutter/oto_console/lib/src/oto_console_contract.dart b/packages/flutter/oto_console/lib/src/oto_console_contract.dart new file mode 100644 index 0000000..cafeb2a --- /dev/null +++ b/packages/flutter/oto_console/lib/src/oto_console_contract.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +import 'oto_console_shell.dart'; + +class OtoConsoleConfig { + final String serverHttpUrl; + final String serverWireUrl; + final String? authTokenReference; + + const OtoConsoleConfig({ + required this.serverHttpUrl, + required this.serverWireUrl, + this.authTokenReference, + }); +} + +class OtoConsoleThemeAdapter { + final Color primaryColor; + final Color accentColor; + final Color backgroundColor; + final Color railBackgroundColor; + final Color borderColor; + final Color textColor; + final Color textSecondaryColor; + + const OtoConsoleThemeAdapter({ + this.primaryColor = const Color(0xFF22D3EE), + this.accentColor = const Color(0xFFF59E0B), + this.backgroundColor = const Color(0xFF111318), + this.railBackgroundColor = const Color(0xFF181B22), + this.borderColor = const Color(0xFF2A2F3A), + this.textColor = Colors.white, + this.textSecondaryColor = Colors.white60, + }); +} + +typedef OtoConsoleNavigationCallback = void Function(OtoConsoleSection section); + +class OtoConsoleNavigation { + final OtoConsoleNavigationCallback? onNavigate; + + const OtoConsoleNavigation({this.onNavigate}); +} + +class OtoCapability { + final String name; + final String description; + final IconData? icon; + + const OtoCapability({ + required this.name, + required this.description, + this.icon, + }); +} + +class OtoCapabilityPack { + final List capabilities; + + const OtoCapabilityPack({required this.capabilities}); +} diff --git a/packages/flutter/oto_console/lib/src/oto_console_overview.dart b/packages/flutter/oto_console/lib/src/oto_console_overview.dart new file mode 100644 index 0000000..1171360 --- /dev/null +++ b/packages/flutter/oto_console/lib/src/oto_console_overview.dart @@ -0,0 +1,362 @@ +import 'package:flutter/material.dart'; + +import 'oto_console_contract.dart'; + +class OtoConsoleOverview extends StatelessWidget { + final OtoConsoleConfig config; + final String statusText; + final VoidCallback? onRefresh; + final OtoConsoleThemeAdapter? themeAdapter; + + const OtoConsoleOverview({ + super.key, + required this.config, + required this.statusText, + this.onRefresh, + this.themeAdapter, + }); + + bool get _connected => statusText.trim().toLowerCase() == 'connected'; + + @override + Widget build(BuildContext context) { + final theme = themeAdapter ?? const OtoConsoleThemeAdapter(); + final statusColor = _connected ? theme.primaryColor : theme.accentColor; + + return Container( + color: theme.backgroundColor, + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 840), + child: ListView( + children: [ + LayoutBuilder( + builder: (context, constraints) { + final narrow = constraints.maxWidth < 620; + final title = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'OTO CONTROL PLANE', + style: TextStyle( + color: theme.primaryColor, + fontSize: 12, + fontWeight: FontWeight.bold, + letterSpacing: 1.8, + ), + ), + const SizedBox(height: 4), + Text( + 'Build Operations Overview', + style: TextStyle( + color: theme.textColor, + fontSize: 28, + fontWeight: FontWeight.w800, + ), + ), + ], + ); + final badge = _StatusBadge( + label: statusText, + color: statusColor, + ); + if (narrow) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [title, const SizedBox(height: 16), badge], + ); + } + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(child: title), + const SizedBox(width: 24), + badge, + ], + ); + }, + ), + const SizedBox(height: 32), + _SectionHeader( + title: 'SERVER ENDPOINTS', + icon: Icons.lan_outlined, + theme: theme, + ), + const SizedBox(height: 12), + _EndpointTile( + title: 'OTO Server HTTP API', + value: config.serverHttpUrl, + icon: Icons.http_outlined, + theme: theme, + ), + const SizedBox(height: 16), + _EndpointTile( + title: 'OTO Server Wire', + value: config.serverWireUrl, + icon: Icons.swap_calls_outlined, + theme: theme, + ), + const SizedBox(height: 32), + _SectionHeader( + title: 'RUNNER EXECUTION SURFACE', + icon: Icons.terminal_outlined, + theme: theme, + ), + const SizedBox(height: 12), + _MetricGrid(theme: theme, connected: _connected), + const SizedBox(height: 32), + _SectionHeader( + title: 'AUTHENTICATION', + icon: Icons.security_outlined, + theme: theme, + ), + const SizedBox(height: 12), + _EndpointTile( + title: 'Active Auth Token Reference', + value: config.authTokenReference?.isNotEmpty == true + ? config.authTokenReference! + : 'None', + icon: Icons.key_outlined, + theme: theme, + ), + if (onRefresh != null) ...[ + const SizedBox(height: 32), + Align( + alignment: Alignment.centerLeft, + child: FilledButton.icon( + onPressed: onRefresh, + icon: const Icon(Icons.refresh), + label: const Text('Refresh'), + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +class _StatusBadge extends StatelessWidget { + final String label; + final Color color; + + const _StatusBadge({required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withValues(alpha: 0.5)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + label.toUpperCase(), + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + final String title; + final IconData icon; + final OtoConsoleThemeAdapter theme; + + const _SectionHeader({ + required this.title, + required this.icon, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, color: theme.primaryColor, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: theme.textSecondaryColor, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ); + } +} + +class _EndpointTile extends StatelessWidget { + final String title; + final String value; + final IconData icon; + final OtoConsoleThemeAdapter theme; + + const _EndpointTile({ + required this.title, + required this.value, + required this.icon, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.railBackgroundColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.borderColor), + ), + child: Row( + children: [ + Icon(icon, color: theme.primaryColor, size: 22), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: theme.textSecondaryColor, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + value, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + color: theme.textColor, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _MetricGrid extends StatelessWidget { + final OtoConsoleThemeAdapter theme; + final bool connected; + + const _MetricGrid({required this.theme, required this.connected}); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final narrow = constraints.maxWidth < 620; + final tiles = [ + _MetricTile( + title: 'Status', + value: connected ? 'ACTIVE WIRE' : 'OFFLINE', + color: connected ? theme.primaryColor : theme.textSecondaryColor, + theme: theme, + ), + _MetricTile( + title: 'Domain', + value: 'Build / Deploy', + color: theme.accentColor, + theme: theme, + ), + ]; + if (narrow) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [tiles[0], const SizedBox(height: 16), tiles[1]], + ); + } + return Row( + children: [ + Expanded(child: tiles[0]), + const SizedBox(width: 16), + Expanded(child: tiles[1]), + ], + ); + }, + ); + } +} + +class _MetricTile extends StatelessWidget { + final String title; + final String value; + final Color color; + final OtoConsoleThemeAdapter theme; + + const _MetricTile({ + required this.title, + required this.value, + required this.color, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withValues(alpha: 0.25)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle(color: theme.textSecondaryColor, fontSize: 12), + ), + const SizedBox(height: 6), + Text( + value, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: color, + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ); + } +} diff --git a/packages/flutter/oto_console/lib/src/oto_console_shell.dart b/packages/flutter/oto_console/lib/src/oto_console_shell.dart new file mode 100644 index 0000000..d222c0e --- /dev/null +++ b/packages/flutter/oto_console/lib/src/oto_console_shell.dart @@ -0,0 +1,307 @@ +import 'package:flutter/material.dart'; + +import 'oto_agent_panel.dart'; +import 'oto_console_contract.dart'; + +enum OtoConsoleSection { + overview, + runners, + pipelines, + executions, + artifacts, + agent, + settings, +} + +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 + Widget build(BuildContext context) { + final theme = widget.themeAdapter ?? const OtoConsoleThemeAdapter(); + return Scaffold( + backgroundColor: theme.backgroundColor, + body: SafeArea( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _OtoConsoleRail( + selected: _section, + onSelect: _select, + theme: theme, + ), + Expanded(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 ?? + _OtoPlaceholder( + icon: Icons.precision_manufacturing_outlined, + title: 'Runners', + subtitle: + 'Runner registry, heartbeat, and bootstrap controls mount here.', + theme: theme, + ), + OtoConsoleSection.pipelines => + widget.pipelines ?? + _OtoPlaceholder( + icon: Icons.account_tree_outlined, + title: 'Pipelines', + subtitle: + 'Typed command pipelines and build profiles mount here.', + theme: theme, + ), + OtoConsoleSection.executions => + widget.executions ?? + _OtoPlaceholder( + icon: Icons.play_circle_outline, + title: 'Executions', + subtitle: + 'Remote run status, cancellation, and execution history mount here.', + theme: theme, + ), + OtoConsoleSection.artifacts => + widget.artifacts ?? + _OtoPlaceholder( + icon: Icons.inventory_2_outlined, + title: 'Artifacts', + subtitle: + 'Build outputs, artifact events, and retention views mount here.', + theme: theme, + ), + OtoConsoleSection.agent => + widget.agent ?? OtoAgentPanel(capabilities: widget.capabilities), + OtoConsoleSection.settings => + widget.settings ?? + _OtoPlaceholder( + icon: Icons.tune, + title: 'Settings', + subtitle: + 'Server endpoints, credentials, and operator preferences mount here.', + theme: theme, + ), + }; + } +} + +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, + ), + _RailButton( + tooltip: 'Runners', + icon: Icons.precision_manufacturing_outlined, + selected: selected == OtoConsoleSection.runners, + onPressed: () => onSelect(OtoConsoleSection.runners), + theme: theme, + ), + _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, + ), + _RailButton( + tooltip: 'Artifacts', + icon: Icons.inventory_2_outlined, + selected: selected == OtoConsoleSection.artifacts, + onPressed: () => onSelect(OtoConsoleSection.artifacts), + theme: theme, + ), + const Spacer(), + _RailButton( + tooltip: 'Agent', + icon: Icons.smart_toy_outlined, + selected: selected == OtoConsoleSection.agent, + onPressed: () => onSelect(OtoConsoleSection.agent), + theme: theme, + ), + _RailButton( + tooltip: 'Settings', + icon: Icons.tune, + selected: selected == OtoConsoleSection.settings, + onPressed: () => onSelect(OtoConsoleSection.settings), + 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 _OtoPlaceholder extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final OtoConsoleThemeAdapter theme; + + const _OtoPlaceholder({ + required this.icon, + required this.title, + required this.subtitle, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + return Container( + color: theme.backgroundColor, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: theme.primaryColor, size: 40), + const SizedBox(height: 16), + Text( + title, + textAlign: TextAlign.center, + style: TextStyle( + color: theme.textColor, + fontSize: 22, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + Text( + subtitle, + textAlign: TextAlign.center, + style: TextStyle( + color: theme.textSecondaryColor, + fontSize: 14, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/packages/flutter/oto_console/pubspec.yaml b/packages/flutter/oto_console/pubspec.yaml new file mode 100644 index 0000000..65884c8 --- /dev/null +++ b/packages/flutter/oto_console/pubspec.yaml @@ -0,0 +1,22 @@ +name: oto_console +description: "Embeddable Flutter console widgets for OTO build and deploy operations." +version: 0.1.0 +publish_to: 'none' + +environment: + sdk: ^3.11.3 + +dependencies: + flutter: + sdk: flutter + + agent_shell: + path: ../../../../agent-shell + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true diff --git a/packages/flutter/oto_console/test/oto_console_test.dart b/packages/flutter/oto_console/test/oto_console_test.dart new file mode 100644 index 0000000..cb3aebc --- /dev/null +++ b/packages/flutter/oto_console/test/oto_console_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:oto_console/oto_console.dart'; + +void main() { + testWidgets('renders embeddable OTO console shell', (tester) async { + const config = OtoConsoleConfig( + serverHttpUrl: 'http://localhost:8080', + serverWireUrl: 'ws://localhost:18080/runner', + ); + + await tester.pumpWidget( + const MaterialApp( + home: OtoConsoleShell( + config: config, + capabilities: OtoCapabilityPack( + capabilities: [ + OtoCapability( + name: 'Remote Run', + description: 'Dispatch remote build runs.', + ), + ], + ), + overview: OtoConsoleOverview( + config: config, + statusText: 'Disconnected', + ), + ), + ), + ); + + expect(find.text('Build Operations Overview'), findsOneWidget); + expect(find.byTooltip('Agent'), findsOneWidget); + + await tester.tap(find.byTooltip('Agent')); + await tester.pumpAndSettle(); + + expect(find.textContaining('OTO agent surface is ready'), findsOneWidget); + expect(find.textContaining('Remote Run'), findsOneWidget); + }); +}