feat(studio): operator-mode pass — Store + audit detail + channel switcher (v0.10.0)
Turns Studio from read-only dashboard into operator console. Pieces: * **New Store page** (sidebar destination #3): browses the hub's bundled store-index with query + category + status filters. Each card has an Install button that prompts for the `.fai` bundle source (URL or local path), ships it to HubAdmin.InstallModule, shows success / error in a progress dialog, refreshes the list. * **Audit drill-down**: every event row is now tappable; opens a modal with all LoggedEvent fields including the new `detail` JSON pretty-printed in a code block. KRITIS forensic story: every audit row → full structured detail in two clicks. * **Approvals payload preview**: shipped already; now pretty-prints JSON when the preview parses as such, plus a clearer `PAYLOAD PREVIEW` label. Reviewer identity defaults to `$USER@studio` instead of the hard-coded `studio-mvp` so the audit trail records who acted. * **Channel switcher in Settings dialog**: the dialog now pulls HubAdmin.ChannelStatus and renders one row per channel (local / dev / beta / production) with port, running indicator, active marker. A "Connect" button on a running channel sets Studio's endpoint to that channel's port and reconnects in one click. Cmd+1..6 keyboard shortcuts updated for the six destinations. Widget test asserts every destination renders. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
18db6ff164
commit
060c8003d5
9 changed files with 990 additions and 12 deletions
|
|
@ -106,6 +106,74 @@ class HubService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Active channel + per-channel daemon status snapshot.
|
||||||
|
Future<ChannelStatusSnapshot> 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<List<StoreItem>> 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.
|
/// Saved flows known to the hub.
|
||||||
Future<List<SavedFlow>> listFlows() async {
|
Future<List<SavedFlow>> listFlows() async {
|
||||||
final flows = await _client.listFlows();
|
final flows = await _client.listFlows();
|
||||||
|
|
@ -163,8 +231,15 @@ class HubService {
|
||||||
flowName: e.flowName.isEmpty ? null : e.flowName,
|
flowName: e.flowName.isEmpty ? null : e.flowName,
|
||||||
stepId: e.stepId.isEmpty ? null : e.stepId,
|
stepId: e.stepId.isEmpty ? null : e.stepId,
|
||||||
moduleName: e.moduleName.isEmpty ? null : e.moduleName,
|
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(),
|
durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(),
|
||||||
error: e.error.isEmpty ? null : e.error,
|
error: e.error.isEmpty ? null : e.error,
|
||||||
|
detail: e.detail.isEmpty ? null : e.detail,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
@ -378,8 +453,14 @@ class AuditEvent {
|
||||||
final String? flowName;
|
final String? flowName;
|
||||||
final String? stepId;
|
final String? stepId;
|
||||||
final String? moduleName;
|
final String? moduleName;
|
||||||
|
final String? moduleVersion;
|
||||||
|
final String? invocationId;
|
||||||
|
final String? flowExecution;
|
||||||
final int? durationMs;
|
final int? durationMs;
|
||||||
final String? error;
|
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({
|
const AuditEvent({
|
||||||
required this.eventId,
|
required this.eventId,
|
||||||
|
|
@ -388,8 +469,12 @@ class AuditEvent {
|
||||||
this.flowName,
|
this.flowName,
|
||||||
this.stepId,
|
this.stepId,
|
||||||
this.moduleName,
|
this.moduleName,
|
||||||
|
this.moduleVersion,
|
||||||
|
this.invocationId,
|
||||||
|
this.flowExecution,
|
||||||
this.durationMs,
|
this.durationMs,
|
||||||
this.error,
|
this.error,
|
||||||
|
this.detail,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -412,3 +497,59 @@ class PendingApproval {
|
||||||
this.expiresAt,
|
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<ChannelInfo> 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<String> tags;
|
||||||
|
final List<String> requiresCapabilities;
|
||||||
|
final List<String> 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import 'pages/audit.dart';
|
||||||
import 'pages/doctor.dart';
|
import 'pages/doctor.dart';
|
||||||
import 'pages/flows.dart';
|
import 'pages/flows.dart';
|
||||||
import 'pages/modules.dart';
|
import 'pages/modules.dart';
|
||||||
|
import 'pages/store.dart';
|
||||||
import 'theme/theme.dart';
|
import 'theme/theme.dart';
|
||||||
import 'theme/tokens.dart';
|
import 'theme/tokens.dart';
|
||||||
import 'widgets/widgets.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
|
/// Studio's own build version. Bump on every UI commit so the
|
||||||
/// running app self-identifies — visible in the sidebar header
|
/// running app self-identifies — visible in the sidebar header
|
||||||
/// and quick-glance proof that you're seeing the current build.
|
/// and quick-glance proof that you're seeing the current build.
|
||||||
const String kStudioVersion = '0.9.0';
|
const String kStudioVersion = '0.10.0';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
@ -124,6 +125,12 @@ class _StudioShellState extends State<StudioShell> {
|
||||||
selectedIcon: Icons.extension,
|
selectedIcon: Icons.extension,
|
||||||
page: ModulesPage(),
|
page: ModulesPage(),
|
||||||
),
|
),
|
||||||
|
_NavPage(
|
||||||
|
label: 'Store',
|
||||||
|
icon: Icons.storefront_outlined,
|
||||||
|
selectedIcon: Icons.storefront,
|
||||||
|
page: StorePage(),
|
||||||
|
),
|
||||||
_NavPage(
|
_NavPage(
|
||||||
label: 'Flows',
|
label: 'Flows',
|
||||||
icon: Icons.account_tree_outlined,
|
icon: Icons.account_tree_outlined,
|
||||||
|
|
@ -171,7 +178,7 @@ class _StudioShellState extends State<StudioShell> {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return Shortcuts(
|
return Shortcuts(
|
||||||
shortcuts: <ShortcutActivator, Intent>{
|
shortcuts: <ShortcutActivator, Intent>{
|
||||||
// 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.
|
// 1-based to match the visual order in the sidebar.
|
||||||
for (var i = 0; i < _pages.length; i++)
|
for (var i = 0; i < _pages.length; i++)
|
||||||
SingleActivator(
|
SingleActivator(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
|
|
@ -14,9 +17,17 @@ class ApprovalsPage extends StatefulWidget {
|
||||||
|
|
||||||
class _ApprovalsPageState extends State<ApprovalsPage> {
|
class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
late Future<List<PendingApproval>> _future;
|
late Future<List<PendingApproval>> _future;
|
||||||
// Hard-coded reviewer for the MVP; Phase 1+ wires this to an
|
// Reviewer identity recorded in the audit log. Defaults to
|
||||||
// authenticated session.
|
// the OS user (closest stable identity Studio has without an
|
||||||
final String _reviewer = 'studio-mvp';
|
// 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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -199,6 +210,17 @@ class _ApprovalCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
if (approval.showPreview != null) ...[
|
if (approval.showPreview != null) ...[
|
||||||
const SizedBox(height: FaiSpace.md),
|
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(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(FaiSpace.md),
|
padding: const EdgeInsets.all(FaiSpace.md),
|
||||||
|
|
@ -207,8 +229,8 @@ class _ApprovalCard extends StatelessWidget {
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: SelectableText(
|
||||||
approval.showPreview!,
|
_prettyPreview(approval.showPreview!),
|
||||||
style: FaiTheme.mono(
|
style: FaiTheme.mono(
|
||||||
size: 11,
|
size: 11,
|
||||||
color: theme.colorScheme.onSurface,
|
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) {
|
String _expiresIn(DateTime t) {
|
||||||
final remaining = t.difference(DateTime.now());
|
final remaining = t.difference(DateTime.now());
|
||||||
if (remaining.isNegative) return 'expired';
|
if (remaining.isNegative) return 'expired';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
@ -111,6 +112,10 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
trailing: e.durationMs != null
|
trailing: e.durationMs != null
|
||||||
? '${e.durationMs}ms'
|
? '${e.durationMs}ms'
|
||||||
: null,
|
: null,
|
||||||
|
onTap: () => showDialog<void>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
517
lib/pages/store.dart
Normal file
517
lib/pages/store.dart
Normal file
|
|
@ -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<StorePage> createState() => _StorePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StorePageState extends State<StorePage> {
|
||||||
|
final _queryCtrl = TextEditingController();
|
||||||
|
String _category = '';
|
||||||
|
String _status = '';
|
||||||
|
late Future<List<StoreItem>> _future;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_future = _load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_queryCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<StoreItem>> _load() {
|
||||||
|
return HubService.instance.searchStore(
|
||||||
|
query: _queryCtrl.text.trim(),
|
||||||
|
category: _category,
|
||||||
|
status: _status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _runSearch() => setState(() => _future = _load());
|
||||||
|
|
||||||
|
Future<void> _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<void>(
|
||||||
|
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<List<StoreItem>>(
|
||||||
|
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<String> 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<String>(
|
||||||
|
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<String?> show(BuildContext context, StoreItem item) {
|
||||||
|
return showDialog<String>(
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
bool _secure = false;
|
bool _secure = false;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
ChannelStatusSnapshot? _channels;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -39,6 +40,28 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
_host = TextEditingController(text: ep.host);
|
_host = TextEditingController(text: ep.host);
|
||||||
_port = TextEditingController(text: ep.port.toString());
|
_port = TextEditingController(text: ep.port.toString());
|
||||||
_secure = ep.secure;
|
_secure = ep.secure;
|
||||||
|
_loadChannels();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _connectToChannel(ChannelInfo ch) async {
|
||||||
|
setState(() {
|
||||||
|
_host.text = '127.0.0.1';
|
||||||
|
_port.text = ch.port.toString();
|
||||||
|
_secure = false;
|
||||||
|
});
|
||||||
|
await _save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -180,6 +203,24 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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<FaiSettingsDialog> {
|
||||||
return '$scheme://$host:$port';
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ packages:
|
||||||
path: "../fai_dart_sdk"
|
path: "../fai_dart_sdk"
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.2.0"
|
version: "0.3.0"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
name: fai_studio
|
name: fai_studio
|
||||||
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.9.0
|
version: 0.10.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0-200.1.beta
|
sdk: ^3.11.0-200.1.beta
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
// Smoke test: app boots without crashing and shows the four
|
// Smoke test: app boots and shows every navigation destination.
|
||||||
// MVP destinations in the sidebar.
|
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:fai_studio/data/hub.dart';
|
import 'package:fai_studio/data/hub.dart';
|
||||||
import 'package:fai_studio/main.dart';
|
import 'package:fai_studio/main.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('Studio shell shows the five destinations',
|
testWidgets('Studio shell shows every navigation destination',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
const StudioApp(initialThemeMode: ThemeModeValue.system),
|
const StudioApp(initialThemeMode: ThemeModeValue.system),
|
||||||
|
|
@ -15,6 +14,7 @@ void main() {
|
||||||
expect(find.text('F∆I Studio'), findsOneWidget);
|
expect(find.text('F∆I Studio'), findsOneWidget);
|
||||||
expect(find.text('Doctor'), findsWidgets);
|
expect(find.text('Doctor'), findsWidgets);
|
||||||
expect(find.text('Modules'), findsWidgets);
|
expect(find.text('Modules'), findsWidgets);
|
||||||
|
expect(find.text('Store'), findsOneWidget);
|
||||||
expect(find.text('Flows'), findsOneWidget);
|
expect(find.text('Flows'), findsOneWidget);
|
||||||
expect(find.text('Audit'), findsOneWidget);
|
expect(find.text('Audit'), findsOneWidget);
|
||||||
expect(find.text('Approvals'), findsOneWidget);
|
expect(find.text('Approvals'), findsOneWidget);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue