diff --git a/lib/data/hub.dart b/lib/data/hub.dart index cb0afd5..63a6ed5 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -106,6 +106,74 @@ class HubService { ); } + /// Active channel + per-channel daemon status snapshot. + Future channelStatus() async { + final r = await _client.channelStatus(); + return ChannelStatusSnapshot( + active: r.active, + channels: r.channels + .map( + (c) => ChannelInfo( + name: c.name, + port: c.port, + running: c.running, + endpoint: c.endpoint, + ), + ) + .toList(), + ); + } + + /// Browse the bundled store index. All filters optional; + /// empty query returns the first [limit] entries. + Future> searchStore({ + String query = '', + String category = '', + String tag = '', + String status = '', + int limit = 50, + }) async { + final entries = await _client.searchStore( + query: query, + category: category, + tag: tag, + status: status, + limit: limit, + ); + return entries + .map( + (e) => StoreItem( + name: e.name, + taglineEn: e.taglineEn, + descriptionEn: e.descriptionEn, + category: e.category, + tags: e.tags, + requiresCapabilities: e.requiresCapabilities, + requiresServices: e.requiresServices, + license: e.license, + repository: e.repository, + bestVersion: e.bestVersion, + status: e.status, + installed: e.installed, + ), + ) + .toList(); + } + + /// Install a module from a `.fai` bundle (URL or local path). + /// Returns the installed module's name + version on success; + /// throws on hub error (signature mismatch, sha256 fail, etc.). + Future<({String name, String version})> installModule({ + required String source, + String expectedSha256 = '', + }) async { + final r = await _client.installModule( + source: source, + expectedSha256: expectedSha256, + ); + return (name: r.name, version: r.version); + } + /// Saved flows known to the hub. Future> listFlows() async { final flows = await _client.listFlows(); @@ -163,8 +231,15 @@ class HubService { flowName: e.flowName.isEmpty ? null : e.flowName, stepId: e.stepId.isEmpty ? null : e.stepId, moduleName: e.moduleName.isEmpty ? null : e.moduleName, + moduleVersion: + e.moduleVersion.isEmpty ? null : e.moduleVersion, + invocationId: + e.invocationId.isEmpty ? null : e.invocationId, + flowExecution: + e.flowExecution.isEmpty ? null : e.flowExecution, durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(), error: e.error.isEmpty ? null : e.error, + detail: e.detail.isEmpty ? null : e.detail, ), ) .toList(); @@ -378,8 +453,14 @@ class AuditEvent { final String? flowName; final String? stepId; final String? moduleName; + final String? moduleVersion; + final String? invocationId; + final String? flowExecution; final int? durationMs; final String? error; + /// Free-form JSON-string carrying event-type-specific fields. + /// Surfaced verbatim in the audit drill-down dialog. + final String? detail; const AuditEvent({ required this.eventId, @@ -388,8 +469,12 @@ class AuditEvent { this.flowName, this.stepId, this.moduleName, + this.moduleVersion, + this.invocationId, + this.flowExecution, this.durationMs, this.error, + this.detail, }); } @@ -412,3 +497,59 @@ class PendingApproval { this.expiresAt, }); } + +class ChannelInfo { + final String name; + final int port; + final bool running; + final String endpoint; + + const ChannelInfo({ + required this.name, + required this.port, + required this.running, + required this.endpoint, + }); +} + +class ChannelStatusSnapshot { + final String active; + final List channels; + + const ChannelStatusSnapshot({ + required this.active, + required this.channels, + }); +} + +/// One row from the store-index search. +class StoreItem { + final String name; + final String taglineEn; + final String descriptionEn; + final String category; + final List tags; + final List requiresCapabilities; + final List requiresServices; + final String license; + final String repository; + final String bestVersion; + /// "published", "alpha", "planned", or empty when unknown. + final String status; + final bool installed; + + const StoreItem({ + required this.name, + required this.taglineEn, + required this.descriptionEn, + required this.category, + required this.tags, + required this.requiresCapabilities, + required this.requiresServices, + required this.license, + required this.repository, + required this.bestVersion, + required this.status, + required this.installed, + }); +} diff --git a/lib/main.dart b/lib/main.dart index ed54962..10ddfa7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'pages/audit.dart'; import 'pages/doctor.dart'; import 'pages/flows.dart'; import 'pages/modules.dart'; +import 'pages/store.dart'; import 'theme/theme.dart'; import 'theme/tokens.dart'; import 'widgets/widgets.dart'; @@ -22,7 +23,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.9.0'; +const String kStudioVersion = '0.10.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -124,6 +125,12 @@ class _StudioShellState extends State { selectedIcon: Icons.extension, page: ModulesPage(), ), + _NavPage( + label: 'Store', + icon: Icons.storefront_outlined, + selectedIcon: Icons.storefront, + page: StorePage(), + ), _NavPage( label: 'Flows', icon: Icons.account_tree_outlined, @@ -171,7 +178,7 @@ class _StudioShellState extends State { final theme = Theme.of(context); return Shortcuts( shortcuts: { - // Cmd+1..5 jumps to the matching destination. Numbered + // Cmd+1..6 jumps to the matching destination. Numbered // 1-based to match the visual order in the sidebar. for (var i = 0; i < _pages.length; i++) SingleActivator( diff --git a/lib/pages/approvals.dart b/lib/pages/approvals.dart index 1384e32..6816cfa 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:flutter/material.dart'; import '../data/hub.dart'; @@ -14,9 +17,17 @@ class ApprovalsPage extends StatefulWidget { class _ApprovalsPageState extends State { late Future> _future; - // Hard-coded reviewer for the MVP; Phase 1+ wires this to an - // authenticated session. - final String _reviewer = 'studio-mvp'; + // Reviewer identity recorded in the audit log. Defaults to + // the OS user (closest stable identity Studio has without an + // auth backend); operators can override it per session. + late final String _reviewer = _defaultReviewer(); + + static String _defaultReviewer() { + final user = Platform.environment['USER'] ?? + Platform.environment['USERNAME'] ?? + 'studio'; + return '$user@studio'; + } @override void initState() { @@ -199,6 +210,17 @@ class _ApprovalCard extends StatelessWidget { ), if (approval.showPreview != null) ...[ const SizedBox(height: FaiSpace.md), + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + 'PAYLOAD PREVIEW', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + fontSize: 10, + ), + ), + ), Container( width: double.infinity, padding: const EdgeInsets.all(FaiSpace.md), @@ -207,8 +229,8 @@ class _ApprovalCard extends StatelessWidget { borderRadius: BorderRadius.circular(FaiRadius.sm), border: Border.all(color: theme.colorScheme.outlineVariant), ), - child: Text( - approval.showPreview!, + child: SelectableText( + _prettyPreview(approval.showPreview!), style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurface, @@ -251,6 +273,16 @@ class _ApprovalCard extends StatelessWidget { ); } + String _prettyPreview(String raw) { + if (raw.isEmpty) return raw; + try { + final dynamic parsed = const JsonDecoder().convert(raw); + return const JsonEncoder.withIndent(' ').convert(parsed); + } catch (_) { + return raw; + } + } + String _expiresIn(DateTime t) { final remaining = t.difference(DateTime.now()); if (remaining.isNegative) return 'expired'; diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index 8f86018..8ae24d9 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; @@ -111,6 +112,10 @@ class _AuditPageState extends State { trailing: e.durationMs != null ? '${e.durationMs}ms' : null, + onTap: () => showDialog( + context: context, + builder: (_) => _EventDetailDialog(event: e), + ), ); }, ), @@ -297,3 +302,163 @@ class _LiveStatusBar extends StatelessWidget { ); } } + +class _EventDetailDialog extends StatelessWidget { + final AuditEvent event; + + const _EventDetailDialog({required this.event}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + String prettyDetail() { + final raw = event.detail; + if (raw == null || raw.isEmpty) return ''; + // Many detail strings are JSON; try to pretty-print and + // fall back to the raw value when parsing fails. + try { + final dynamic parsed = const JsonDecoder().convert(raw); + return const JsonEncoder.withIndent(' ').convert(parsed); + } catch (_) { + return raw; + } + } + + final detail = prettyDetail(); + return AlertDialog( + title: Text(event.type), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(FaiRadius.md), + ), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640, maxHeight: 560), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _Field(label: 'event_id', value: event.eventId, mono: true), + _Field( + label: 'timestamp', + value: event.timestamp.toIso8601String(), + mono: true, + ), + if (event.flowName != null) + _Field(label: 'flow', value: event.flowName!), + if (event.flowExecution != null) + _Field( + label: 'flow_execution', + value: event.flowExecution!, + mono: true, + ), + if (event.invocationId != null) + _Field( + label: 'invocation_id', + value: event.invocationId!, + mono: true, + ), + if (event.stepId != null) + _Field(label: 'step', value: event.stepId!), + if (event.moduleName != null) + _Field( + label: 'module', + value: event.moduleVersion != null + ? '${event.moduleName} @ ${event.moduleVersion}' + : event.moduleName!, + ), + if (event.durationMs != null) + _Field(label: 'duration', value: '${event.durationMs} ms'), + if (event.error != null) + _Field( + label: 'error', + value: event.error!, + valueColor: theme.colorScheme.error, + ), + if (detail.isNotEmpty) ...[ + const SizedBox(height: FaiSpace.md), + Text( + 'DETAIL', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + ), + ), + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.all(FaiSpace.md), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all( + color: theme.colorScheme.outlineVariant, + ), + ), + child: SelectableText( + detail, + style: FaiTheme.mono(size: 11), + ), + ), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ); + } +} + +class _Field extends StatelessWidget { + final String label; + final String value; + final bool mono; + final Color? valueColor; + + const _Field({ + required this.label, + required this.value, + this.mono = false, + this.valueColor, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 110, + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: SelectableText( + value, + style: mono + ? FaiTheme.mono( + size: 11, + color: valueColor ?? theme.colorScheme.onSurface, + ) + : theme.textTheme.bodySmall?.copyWith( + color: valueColor ?? theme.colorScheme.onSurface, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/store.dart b/lib/pages/store.dart new file mode 100644 index 0000000..ad56b21 --- /dev/null +++ b/lib/pages/store.dart @@ -0,0 +1,517 @@ +// Store browser. Lists modules from the hub's bundled +// store-index, with category + status filters and a one-click +// install action. Mirrors `fai store search` on the CLI side. + +import 'package:flutter/material.dart'; + +import '../data/hub.dart'; +import '../theme/theme.dart'; +import '../theme/tokens.dart'; +import '../widgets/widgets.dart'; + +class StorePage extends StatefulWidget { + const StorePage({super.key}); + + @override + State createState() => _StorePageState(); +} + +class _StorePageState extends State { + final _queryCtrl = TextEditingController(); + String _category = ''; + String _status = ''; + late Future> _future; + + @override + void initState() { + super.initState(); + _future = _load(); + } + + @override + void dispose() { + _queryCtrl.dispose(); + super.dispose(); + } + + Future> _load() { + return HubService.instance.searchStore( + query: _queryCtrl.text.trim(), + category: _category, + status: _status, + ); + } + + void _runSearch() => setState(() => _future = _load()); + + Future _install(StoreItem item) async { + // The store entry doesn't carry the bundle URL; for Phase 0.5 + // we prompt the operator for the source (URL or local path). + // Future store entries will include `download_url` per + // platform. + final source = await _SourcePromptDialog.show(context, item); + if (source == null || source.isEmpty) return; + if (!mounted) return; + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _InstallProgressDialog(item: item, source: source), + ).then((_) => _runSearch()); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + appBar: AppBar( + title: const Text('Store'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh, size: 18), + tooltip: 'Reload', + onPressed: _runSearch, + ), + const SizedBox(width: FaiSpace.sm), + ], + ), + body: Padding( + padding: const EdgeInsets.all(FaiSpace.xl), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SearchBar( + controller: _queryCtrl, + category: _category, + status: _status, + onCategoryChanged: (v) { + setState(() => _category = v); + _runSearch(); + }, + onStatusChanged: (v) { + setState(() => _status = v); + _runSearch(); + }, + onSubmit: _runSearch, + ), + const SizedBox(height: FaiSpace.lg), + Expanded( + child: FutureBuilder>( + future: _future, + builder: (context, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + return FaiEmptyState( + icon: Icons.cloud_off_outlined, + iconColor: theme.colorScheme.error, + title: 'Hub unreachable', + hint: 'Start the hub with `fai daemon start`.', + action: FilledButton.tonal( + onPressed: _runSearch, + child: const Text('Retry'), + ), + ); + } + final items = snap.data ?? const []; + if (items.isEmpty) { + return const FaiEmptyState( + icon: Icons.store_outlined, + title: 'No matches', + hint: + 'Adjust the filters or clear the search to see all entries.', + ); + } + return ListView.separated( + padding: EdgeInsets.zero, + itemCount: items.length, + separatorBuilder: (_, _) => + const SizedBox(height: FaiSpace.md), + itemBuilder: (context, i) => + _StoreCard(item: items[i], onInstall: () => _install(items[i])), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _SearchBar extends StatelessWidget { + final TextEditingController controller; + final String category; + final String status; + final void Function(String) onCategoryChanged; + final void Function(String) onStatusChanged; + final VoidCallback onSubmit; + + const _SearchBar({ + required this.controller, + required this.category, + required this.status, + required this.onCategoryChanged, + required this.onStatusChanged, + required this.onSubmit, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: TextField( + controller: controller, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.search, size: 18), + hintText: 'search name, tagline, tag…', + border: OutlineInputBorder(), + isDense: true, + ), + onSubmitted: (_) => onSubmit(), + ), + ), + const SizedBox(width: FaiSpace.md), + _DropdownFilter( + label: 'Category', + value: category, + options: const ['', 'llm', 'text', 'system', 'orchestrator'], + onChanged: onCategoryChanged, + ), + const SizedBox(width: FaiSpace.md), + _DropdownFilter( + label: 'Status', + value: status, + options: const ['', 'published', 'alpha', 'planned'], + onChanged: onStatusChanged, + ), + ], + ); + } +} + +class _DropdownFilter extends StatelessWidget { + final String label; + final String value; + final List options; + final void Function(String) onChanged; + + const _DropdownFilter({ + required this.label, + required this.value, + required this.options, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 160, + child: DropdownButtonFormField( + initialValue: value, + isDense: true, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + isDense: true, + ), + items: options + .map( + (o) => DropdownMenuItem( + value: o, + child: Text(o.isEmpty ? 'any' : o), + ), + ) + .toList(), + onChanged: (v) => onChanged(v ?? ''), + ), + ); + } +} + +class _StoreCard extends StatelessWidget { + final StoreItem item; + final VoidCallback onInstall; + + const _StoreCard({required this.item, required this.onInstall}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return FaiCard( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + item.installed ? Icons.check_circle_outline : Icons.extension_outlined, + size: 20, + color: item.installed + ? FaiColors.success + : theme.colorScheme.primary, + ), + const SizedBox(width: FaiSpace.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + item.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: FaiSpace.sm), + if (item.bestVersion.isNotEmpty) + FaiPill( + label: 'v${item.bestVersion}', + tone: FaiPillTone.neutral, + monospace: true, + ), + const SizedBox(width: FaiSpace.xs), + if (item.status.isNotEmpty) + FaiPill( + label: item.status, + tone: _toneForStatus(item.status), + ), + ], + ), + if (item.taglineEn.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + item.taglineEn, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: FaiSpace.xs), + Wrap( + spacing: FaiSpace.xs, + runSpacing: FaiSpace.xs, + children: [ + if (item.category.isNotEmpty) + FaiPill( + label: item.category, + tone: FaiPillTone.neutral, + ), + for (final t in item.tags.take(4)) + FaiPill(label: t, tone: FaiPillTone.neutral), + ], + ), + ], + ), + ), + const SizedBox(width: FaiSpace.md), + if (item.installed) + FilledButton.tonalIcon( + onPressed: null, + icon: const Icon(Icons.check, size: 16), + label: const Text('Installed'), + ) + else + FilledButton.icon( + onPressed: onInstall, + icon: const Icon(Icons.download, size: 16), + label: const Text('Install'), + ), + ], + ), + ); + } + + FaiPillTone _toneForStatus(String s) { + switch (s) { + case 'published': + return FaiPillTone.success; + case 'alpha': + return FaiPillTone.warning; + case 'planned': + return FaiPillTone.neutral; + default: + return FaiPillTone.neutral; + } + } +} + +class _SourcePromptDialog extends StatefulWidget { + final StoreItem item; + + const _SourcePromptDialog({required this.item}); + + static Future show(BuildContext context, StoreItem item) { + return showDialog( + context: context, + builder: (_) => _SourcePromptDialog(item: item), + ); + } + + @override + State<_SourcePromptDialog> createState() => _SourcePromptDialogState(); +} + +class _SourcePromptDialogState extends State<_SourcePromptDialog> { + final _ctrl = TextEditingController(); + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AlertDialog( + title: Text('Install ${widget.item.name}'), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(FaiRadius.md), + ), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'The store index does not yet ship binary URLs. Paste the ' + '`.fai` bundle source — a https URL or a local filesystem ' + 'path — and the hub will fetch + verify + unpack.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.md), + TextField( + controller: _ctrl, + autofocus: true, + style: FaiTheme.mono(size: 12), + decoration: const InputDecoration( + labelText: 'source', + hintText: 'https://… or /path/to/bundle.fai', + border: OutlineInputBorder(), + isDense: true, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, null), + child: const Text('Cancel'), + ), + FilledButton.icon( + icon: const Icon(Icons.download, size: 16), + label: const Text('Install'), + onPressed: () => Navigator.pop(context, _ctrl.text.trim()), + ), + ], + ); + } +} + +class _InstallProgressDialog extends StatefulWidget { + final StoreItem item; + final String source; + + const _InstallProgressDialog({required this.item, required this.source}); + + @override + State<_InstallProgressDialog> createState() => _InstallProgressDialogState(); +} + +class _InstallProgressDialogState extends State<_InstallProgressDialog> { + late final Future<({String name, String version})> _future; + + @override + void initState() { + super.initState(); + _future = HubService.instance.installModule(source: widget.source); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AlertDialog( + title: Text('Installing ${widget.item.name}'), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(FaiRadius.md), + ), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480, maxHeight: 360), + child: FutureBuilder<({String name, String version})>( + future: _future, + builder: (context, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: FaiSpace.md), + const CircularProgressIndicator(), + const SizedBox(height: FaiSpace.md), + Text( + 'Fetching, verifying, unpacking…', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ); + } + if (snap.hasError) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + color: theme.colorScheme.error, + size: 32, + ), + const SizedBox(height: FaiSpace.md), + SelectableText( + snap.error.toString(), + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.error, + ), + ), + ], + ), + ); + } + final r = snap.data!; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle_outline, + size: 32, + color: FaiColors.success, + ), + const SizedBox(height: FaiSpace.md), + Text( + '${r.name} v${r.version} installed.', + style: theme.textTheme.titleMedium, + ), + ], + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ); + } +} diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart index 5ca6fb5..24baa9f 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -31,6 +31,7 @@ class _FaiSettingsDialogState extends State { bool _secure = false; bool _saving = false; String? _error; + ChannelStatusSnapshot? _channels; @override void initState() { @@ -39,6 +40,28 @@ class _FaiSettingsDialogState extends State { _host = TextEditingController(text: ep.host); _port = TextEditingController(text: ep.port.toString()); _secure = ep.secure; + _loadChannels(); + } + + Future _loadChannels() async { + try { + final snap = await HubService.instance.channelStatus(); + if (!mounted) return; + setState(() => _channels = snap); + } catch (_) { + // Silent: channels block stays hidden when the hub is + // unreachable (the endpoint section is the operator's + // path back to a working connection). + } + } + + Future _connectToChannel(ChannelInfo ch) async { + setState(() { + _host.text = '127.0.0.1'; + _port.text = ch.port.toString(); + _secure = false; + }); + await _save(); } @override @@ -180,6 +203,24 @@ class _FaiSettingsDialogState extends State { ], ), ), + if (_channels != null) ...[ + const SizedBox(height: FaiSpace.lg), + Text( + 'CHANNELS', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + fontSize: 10, + ), + ), + const SizedBox(height: 4), + for (final ch in _channels!.channels) + _ChannelRow( + channel: ch, + active: ch.name == _channels!.active, + onConnect: _saving ? null : () => _connectToChannel(ch), + ), + ], ], ), ), @@ -210,3 +251,78 @@ class _FaiSettingsDialogState extends State { return '$scheme://$host:$port'; } } + +class _ChannelRow extends StatelessWidget { + final ChannelInfo channel; + final bool active; + final VoidCallback? onConnect; + + const _ChannelRow({ + required this.channel, + required this.active, + required this.onConnect, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Icon( + channel.running ? Icons.circle : Icons.circle_outlined, + size: 10, + color: channel.running + ? FaiColors.success + : theme.colorScheme.outline, + ), + const SizedBox(width: FaiSpace.sm), + SizedBox( + width: 88, + child: Text( + channel.name, + style: FaiTheme.mono( + size: 11, + weight: active ? FontWeight.w600 : FontWeight.w400, + color: theme.colorScheme.onSurface, + ), + ), + ), + SizedBox( + width: 64, + child: Text( + ':${channel.port}', + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + channel.running ? 'running' : 'stopped', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + if (active) + Padding( + padding: const EdgeInsets.only(right: FaiSpace.sm), + child: Text( + 'active', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ), + TextButton( + onPressed: channel.running ? onConnect : null, + child: const Text('Connect'), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 1ed17f0..559bd6b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -79,7 +79,7 @@ packages: path: "../fai_dart_sdk" relative: true source: path - version: "0.2.0" + version: "0.3.0" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f37763c..587220d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.9.0 +version: 0.10.0 environment: sdk: ^3.11.0-200.1.beta diff --git a/test/widget_test.dart b/test/widget_test.dart index 252d4f3..69cc9b0 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,12 +1,11 @@ -// Smoke test: app boots without crashing and shows the four -// MVP destinations in the sidebar. +// Smoke test: app boots and shows every navigation destination. import 'package:flutter_test/flutter_test.dart'; import 'package:fai_studio/data/hub.dart'; import 'package:fai_studio/main.dart'; void main() { - testWidgets('Studio shell shows the five destinations', + testWidgets('Studio shell shows every navigation destination', (tester) async { await tester.pumpWidget( const StudioApp(initialThemeMode: ThemeModeValue.system), @@ -15,6 +14,7 @@ void main() { expect(find.text('F∆I Studio'), findsOneWidget); expect(find.text('Doctor'), findsWidgets); expect(find.text('Modules'), findsWidgets); + expect(find.text('Store'), findsOneWidget); expect(find.text('Flows'), findsOneWidget); expect(find.text('Audit'), findsOneWidget); expect(find.text('Approvals'), findsOneWidget);