// Module-store manager — list the configured module stores (+ the // bundled seed), add a store by index URL, or remove one. The hub merges // 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/theme.dart'; import '../theme/tokens.dart'; class ChainStoresDialog extends StatefulWidget { const ChainStoresDialog._(); static Future show(BuildContext context) => showDialog( context: context, builder: (_) => const ChainStoresDialog._(), ); @override State createState() => _ChainStoresDialogState(); } class _ChainStoresDialogState extends State { late Future> _future; final _name = TextEditingController(); final _url = TextEditingController(); final _pubkey = 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 _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 _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(); 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() => setState(() => _future = HubService.instance.listStores()); @override void dispose() { _name.dispose(); _url.dispose(); _pubkey.dispose(); super.dispose(); } Future _add() => _addStore( _name.text.trim(), _url.text.trim(), pinnedPubkeyPem: _pubkey.text.trim(), fromFields: true, ); /// Shared add path — used by both the manual name/URL form and the /// one-click suggested-store buttons. Future _addStore(String name, String url, {String pinnedPubkeyPem = '', bool fromFields = false}) async { if (name.isEmpty || url.isEmpty) return; setState(() => _busy = true); try { final r = await HubService.instance .addStore(name: name, url: url, pinnedPubkeyPem: pinnedPubkeyPem); if (!mounted) return; if (fromFields) { _name.clear(); _url.clear(); _pubkey.clear(); } ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(r.message))); _reload(); } catch (e) { if (mounted) { showFaiErrorSnack(context, 'store.add', e, title: AppLocalizations.of(context)!.storesManagerAddFailed); } } finally { if (mounted) setState(() => _busy = false); } } Future _remove(String name) async { try { final r = await HubService.instance.removeStore(name); if (!mounted) return; ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(r.message))); _reload(); } catch (e) { if (mounted) { showFaiErrorSnack(context, 'store.remove', e, title: AppLocalizations.of(context)!.storesManagerRemoveFailed); } } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return AlertDialog( title: Text(l.storesManagerTitle), content: SizedBox( width: 540, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( l.storesManagerIntro, style: theme.textTheme.bodySmall ?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), const SizedBox(height: ChainSpace.md), Flexible( child: FutureBuilder>( future: _future, builder: (c, snap) { if (snap.connectionState == ConnectionState.waiting) { return const Padding( padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()), ); } final stores = snap.data ?? const []; return ListView( shrinkWrap: true, children: [ for (final s in stores) ListTile( dense: true, leading: Icon( s.removable ? Icons.cloud_outlined : Icons.inventory_2_outlined, size: 18, ), title: Text(s.name), subtitle: Text( s.url.isEmpty ? 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: l.buttonRemove, onPressed: () => _remove(s.name), ) : const Icon(Icons.lock_outline, size: 16), ), ], ); }, ), ), const Divider(), // 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>( future: _future, builder: (c, snap) { final stores = snap.data ?? const []; final configuredNameByUrl = { 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: [ Expanded( flex: 2, child: TextField( controller: _name, decoration: InputDecoration( labelText: l.storesManagerNameLabel, isDense: true, ), ), ), const SizedBox(width: ChainSpace.sm), Expanded( flex: 4, child: TextField( controller: _url, decoration: InputDecoration( labelText: l.storesManagerUrlLabel, isDense: true, ), onSubmitted: (_) => _add(), ), ), ], ), const SizedBox(height: ChainSpace.sm), // Optional per-store pinned publisher key (PEM). When set, // signed modules from this store verify against this key // instead of the built-in vendor anchor. TextField( controller: _pubkey, minLines: 1, maxLines: 4, style: ChainTheme.mono(size: 11), decoration: InputDecoration( labelText: l.storesManagerPinnedKeyLabel, helperText: l.storesManagerPinnedKeyHint, helperMaxLines: 3, isDense: true, ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text(l.buttonClose), ), FilledButton( onPressed: _busy ? null : _add, child: _busy ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : 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 Recl∆Im's). final List<_SuggestedStore> _kSuggestedStores = <_SuggestedStore>[ _SuggestedStore( name: 'Recl∆Im', // Raw store-index file in the public chain-modules/reclaim repo — // stable (no release-tag churn), updated by a plain git push as // reclaim adds modules. The hub fetches it via StoreIndex::from_url. url: 'https://git.flemming.ai/chain-modules/reclaim/raw/branch/main/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 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, ), ), ], ), ); } }