feat(studio): clearer store + channel switcher + approvals & welcome polish
Some checks failed
Security / Security check (push) Failing after 2s

- store: repair the filter dialog crash (a Spacer lived directly in
  AlertDialog.actions, which is an OverflowBar, not a Flex — it threw
  and rendered a broken dialog). Buttons now sit in a Row.
- store: promote the module-store manager from a bare icon to a
  labelled 'Add store' button, and fully localize the dialog (DE/EN).
- store: add a curated 'Suggested stores' shelf with one-click
  add/remove (first entry: Recl∆Im). Each suggestion is probed for
  reachability and shows 'not available yet' until its index is
  published, instead of failing only on click. Fail-open on network
  errors so a transient hiccup never hides a real store.
- sidebar: the channel pill is now a one-click channel switcher
  (menu with per-channel running state + active check; switching
  writes ~/.chain/current-channel, restarts the daemon, and Studio
  repoints to the new channel).
- approvals: lead the card with the human prompt ('what am I
  releasing?') and demote the flow/step id to a metadata line; clear
  fallback when the step left the prompt empty.
- welcome: tidy the docs grid into equal-height paired rows with a
  full-width trailing card for the odd one out.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-06-18 21:29:39 +02:00
parent 62ed3951e5
commit 5220f19ff8
10 changed files with 865 additions and 148 deletions

View file

@ -3,11 +3,15 @@
// every store's modules into one searchable index, so adding a store
// (e.g. a domain app's published modules) makes them appear in the Store.
import 'dart:async';
import 'dart:io';
import 'package:chain_client_sdk/chain_client_sdk.dart';
import 'package:flutter/material.dart';
import '../data/error_presentation.dart';
import '../data/hub.dart';
import '../l10n/app_localizations.dart';
import '../theme/tokens.dart';
class ChainStoresDialog extends StatefulWidget {
@ -28,10 +32,46 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
final _url = TextEditingController();
bool _busy = false;
/// Reachability of each suggested store's index URL. `null` while the
/// probe is in flight, `true` once a response arrives that the hub
/// could parse (fail-open: network errors also count as `true`, so a
/// transient hiccup never hides a real store), `false` only on a
/// definitive 4xx/5xx i.e. "the provider hasn't published it yet".
final Map<String, bool?> _reachable = {};
@override
void initState() {
super.initState();
_reload();
for (final s in _kSuggestedStores) {
_reachable[s.url] = null;
_probe(s.url);
}
}
/// One-shot HEAD-ish probe of a suggested store's index. Fails open:
/// only a clean 4xx/5xx marks it unavailable; connection errors and
/// timeouts leave it available so the hub stays the real authority.
Future<void> _probe(String url) async {
if (!url.startsWith('http')) {
if (mounted) setState(() => _reachable[url] = true);
return;
}
bool available = true;
try {
final client = HttpClient()
..connectionTimeout = const Duration(seconds: 4);
final req = await client.getUrl(Uri.parse(url));
final resp = await req.close().timeout(const Duration(seconds: 5));
available = resp.statusCode >= 200 && resp.statusCode < 400;
await resp.drain<void>();
client.close(force: true);
} catch (_) {
// Offline / DNS / timeout fail open. The add path will surface
// any real error if the operator clicks through.
available = true;
}
if (mounted) setState(() => _reachable[url] = available);
}
void _reload() =>
@ -44,23 +84,29 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
super.dispose();
}
Future<void> _add() async {
final name = _name.text.trim();
final url = _url.text.trim();
Future<void> _add() =>
_addStore(_name.text.trim(), _url.text.trim(), fromFields: true);
/// Shared add path used by both the manual name/URL form and the
/// one-click suggested-store buttons.
Future<void> _addStore(String name, String url,
{bool fromFields = false}) async {
if (name.isEmpty || url.isEmpty) return;
setState(() => _busy = true);
try {
final r = await HubService.instance.addStore(name: name, url: url);
if (!mounted) return;
_name.clear();
_url.clear();
if (fromFields) {
_name.clear();
_url.clear();
}
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(r.message)));
_reload();
} catch (e) {
if (mounted) {
showFaiErrorSnack(context, 'store.add', e,
title: 'Could not add store');
title: AppLocalizations.of(context)!.storesManagerAddFailed);
}
} finally {
if (mounted) setState(() => _busy = false);
@ -77,7 +123,7 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
} catch (e) {
if (mounted) {
showFaiErrorSnack(context, 'store.remove', e,
title: 'Could not remove store');
title: AppLocalizations.of(context)!.storesManagerRemoveFailed);
}
}
}
@ -85,8 +131,9 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return AlertDialog(
title: const Text('Module stores'),
title: Text(l.storesManagerTitle),
content: SizedBox(
width: 540,
child: Column(
@ -94,8 +141,7 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'The hub merges modules from every store below into one index. '
'Add a store index URL to make its modules installable here.',
l.storesManagerIntro,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
@ -126,14 +172,14 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
title: Text(s.name),
subtitle: Text(
s.url.isEmpty
? '${s.moduleCount} modules · bundled'
: '${s.url}\n${s.moduleCount} modules',
? l.storesManagerBundled(s.moduleCount)
: '${s.url}\n${l.storesManagerModuleCount(s.moduleCount)}',
),
isThreeLine: s.url.isNotEmpty,
trailing: s.removable
? IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Remove',
tooltip: l.buttonRemove,
onPressed: () => _remove(s.name),
)
: const Icon(Icons.lock_outline, size: 16),
@ -144,7 +190,37 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
),
),
const Divider(),
Text('Add a store', style: theme.textTheme.titleSmall),
// One-click curated stores add or remove a known store
// (e.g. a domain app's published modules) without typing a
// URL. State follows the configured list above.
Text(l.storesManagerSuggested, style: theme.textTheme.titleSmall),
const SizedBox(height: ChainSpace.sm),
FutureBuilder<List<StoreSource>>(
future: _future,
builder: (c, snap) {
final stores = snap.data ?? const <StoreSource>[];
final configuredNameByUrl = <String, String>{
for (final s in stores)
if (s.url.isNotEmpty) s.url: s.name,
};
return Column(
children: [
for (final sg in _kSuggestedStores)
_SuggestedStoreRow(
store: sg,
configuredName: configuredNameByUrl[sg.url],
available: _reachable[sg.url],
busy: _busy,
onAdd: () => _addStore(sg.name, sg.url),
onRemove: (name) => _remove(name),
),
],
);
},
),
const SizedBox(height: ChainSpace.md),
const Divider(),
Text(l.storesManagerAddSection, style: theme.textTheme.titleSmall),
const SizedBox(height: ChainSpace.sm),
Row(
children: [
@ -152,8 +228,8 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
flex: 2,
child: TextField(
controller: _name,
decoration: const InputDecoration(
labelText: 'Name',
decoration: InputDecoration(
labelText: l.storesManagerNameLabel,
isDense: true,
),
),
@ -163,8 +239,8 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
flex: 4,
child: TextField(
controller: _url,
decoration: const InputDecoration(
labelText: 'Index URL (https:// or file://)',
decoration: InputDecoration(
labelText: l.storesManagerUrlLabel,
isDense: true,
),
onSubmitted: (_) => _add(),
@ -178,7 +254,7 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
child: Text(l.buttonClose),
),
FilledButton(
onPressed: _busy ? null : _add,
@ -188,9 +264,150 @@ class _ChainStoresDialogState extends State<ChainStoresDialog> {
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Add store'),
: Text(l.storesManagerAddAction),
),
],
);
}
}
/// A curated, one-click store the operator can add or remove without
/// typing a URL. The hub validates the index on add, so the entry
/// only succeeds once its index is actually published at [url].
class _SuggestedStore {
final String name;
final String url;
final String Function(AppLocalizations) describe;
const _SuggestedStore({
required this.name,
required this.url,
required this.describe,
});
}
/// Known stores offered as quick-adds. Keep this short and curated
/// it is the in-product shelf of trusted domain stores. The URL is the
/// canonical publish location each store owner commits to (see the
/// cross-agent handoff log for ReclIm's).
final List<_SuggestedStore> _kSuggestedStores = <_SuggestedStore>[
_SuggestedStore(
name: 'Recl∆Im',
url: 'https://releases.chain.flemming.ai/reclaim/store.yaml',
describe: (l) => l.storesSuggestedReclaimDesc,
),
];
/// One row in the suggested-stores shelf: icon, name, description, and
/// a single Add/Remove button that flips based on whether the store is
/// already configured.
class _SuggestedStoreRow extends StatelessWidget {
final _SuggestedStore store;
/// The configured store's name when this suggestion is already
/// added (matched by URL), else null.
final String? configuredName;
/// Index reachability: null = still probing, true = reachable,
/// false = a definitive 4xx/5xx (not published yet).
final bool? available;
final bool busy;
final VoidCallback onAdd;
final ValueChanged<String> onRemove;
const _SuggestedStoreRow({
required this.store,
required this.configuredName,
required this.available,
required this.busy,
required this.onAdd,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final added = configuredName != null;
// Not-yet-published: a definitive bad response and not already
// configured. Show a muted "not available yet" chip instead of an
// Add button that would only fail on click.
final notPublished = !added && available == false;
final probing = !added && available == null;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Icon(
Icons.storefront_outlined,
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: ChainSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
store.name,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
),
Text(
store.describe(l),
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
],
),
),
const SizedBox(width: ChainSpace.sm),
if (added)
OutlinedButton.icon(
icon: const Icon(Icons.check, size: 14),
label: Text(l.buttonRemove),
onPressed: busy ? null : () => onRemove(configuredName!),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: ChainColors.success,
),
)
else if (probing)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
else if (notPublished)
Tooltip(
message: l.storesManagerNotAvailableHint,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.schedule_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
l.storesManagerNotAvailable,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
],
),
)
else
FilledButton.tonalIcon(
icon: const Icon(Icons.add, size: 14),
label: Text(l.storesManagerAddShort),
onPressed: busy ? null : onAdd,
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
);
}
}