feat(studio): Doctor + Modules localized + sparse-store federation nudge (v0.25.0)
Two threads.
Translation expansion: every visible string on the Doctor
page (section headers, status text, button labels, daemon-
control card, daemon-files panel, update banner) and the
Modules page (recent-activity strip, capabilities label,
uninstall dialog + toast) flips between DE and EN with the
sidebar toggle. Adds ~50 ARB keys split between
`app_en.arb` / `app_de.arb`. Pluralised + parametrised
strings (`{n} events verified`, `Running on {name}: {endpoint}`)
use the standard ICU placeholder syntax so future locales
slot in without code changes.
Sparse-store federation nudge: the Store page renders an
inline banner above the grid when the operator has zero
federated entries — single biggest store-fullness lever is
configuring an MCP server / n8n endpoint, so the banner
says exactly that and the button opens Settings straight
to the editor. Banner dismisses automatically the next
render after a federated entry appears.
Coverage stand for translation: Doctor + Modules + sidebar
+ page titles + nav + Cmd+K labels are bilingual. Settings
dialog, Approvals cards, Audit drilldown, MCP/n8n editors,
Store search hint + filter chips remain English-literal.
The infrastructure (ARB plumbing, generator hookup,
locale-notifier) means each follow-up surface is a
one-line ARB edit + one call-site swap.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
ee83eb851a
commit
349619d68e
11 changed files with 1091 additions and 80 deletions
|
|
@ -36,7 +36,7 @@ class _DoctorPageState extends State<DoctorPage> {
|
|||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
tooltip: 'Re-check',
|
||||
tooltip: AppLocalizations.of(context)!.doctorRecheckTooltip,
|
||||
onPressed: _refresh,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
|
|
@ -49,14 +49,15 @@ class _DoctorPageState extends State<DoctorPage> {
|
|||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return FaiEmptyState(
|
||||
icon: Icons.cloud_off_outlined,
|
||||
iconColor: Theme.of(context).colorScheme.error,
|
||||
title: 'Hub unreachable',
|
||||
hint: 'Start the hub with `fai serve`.',
|
||||
title: l.hubUnreachable,
|
||||
hint: l.hubUnreachableHint,
|
||||
action: FilledButton.tonal(
|
||||
onPressed: _refresh,
|
||||
child: const Text('Retry'),
|
||||
child: Text(l.buttonRetry),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -72,7 +73,7 @@ class _DoctorPageState extends State<DoctorPage> {
|
|||
_SummaryStrip(snapshot: s),
|
||||
const SizedBox(height: FaiSpace.xl),
|
||||
_Section(
|
||||
title: 'Event log',
|
||||
title: AppLocalizations.of(context)!.doctorEventLogSection,
|
||||
child: _EventLogPanel(
|
||||
snapshot: s,
|
||||
onRefresh: _refresh,
|
||||
|
|
@ -80,22 +81,22 @@ class _DoctorPageState extends State<DoctorPage> {
|
|||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_Section(
|
||||
title: 'Modules & approvals',
|
||||
title: AppLocalizations.of(context)!.doctorModulesApprovalsSection,
|
||||
child: _ModulesPanel(snapshot: s),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_Section(
|
||||
title: 'Host services',
|
||||
title: AppLocalizations.of(context)!.doctorHostServicesSection,
|
||||
child: _ServicesPanel(snapshot: s),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_Section(
|
||||
title: 'Daemon files',
|
||||
title: AppLocalizations.of(context)!.doctorDaemonFilesSection,
|
||||
child: _DaemonPathsPanel(paths: s.paths),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_Section(
|
||||
title: 'Daemon control',
|
||||
title: AppLocalizations.of(context)!.doctorDaemonControlSection,
|
||||
child: _DaemonActionsCard(),
|
||||
),
|
||||
],
|
||||
|
|
@ -266,6 +267,7 @@ class _EventLogPanel extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final healthy = snapshot.chainHealthy;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return FaiCard(
|
||||
child: Row(
|
||||
children: [
|
||||
|
|
@ -280,9 +282,7 @@ class _EventLogPanel extends StatelessWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
healthy
|
||||
? 'Hash chain intact'
|
||||
: 'Tampering detected',
|
||||
healthy ? l.doctorChainIntact : l.doctorChainTampered,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
|
|
@ -290,8 +290,12 @@ class _EventLogPanel extends StatelessWidget {
|
|||
const SizedBox(height: 2),
|
||||
Text(
|
||||
healthy
|
||||
? '${snapshot.eventChainTotal} events · prev_event_sha256 verified end-to-end'
|
||||
: '${snapshot.eventChainVerified} of ${snapshot.eventChainTotal} verified before mismatch at ${snapshot.eventChainTamperedAt}',
|
||||
? l.doctorChainIntactDetail(snapshot.eventChainTotal)
|
||||
: l.doctorChainTamperedDetail(
|
||||
snapshot.eventChainVerified,
|
||||
snapshot.eventChainTotal,
|
||||
snapshot.eventChainTamperedAt ?? '',
|
||||
),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -302,11 +306,11 @@ class _EventLogPanel extends StatelessWidget {
|
|||
OutlinedButton.icon(
|
||||
onPressed: onRefresh,
|
||||
icon: const Icon(Icons.fact_check_outlined, size: 16),
|
||||
label: const Text('Verify now'),
|
||||
label: Text(l.doctorVerifyNow),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
FaiPill(
|
||||
label: healthy ? 'WORM-1' : 'TAMPER',
|
||||
label: healthy ? l.doctorChainPillOk : l.doctorChainPillTamper,
|
||||
tone: healthy ? FaiPillTone.success : FaiPillTone.danger,
|
||||
),
|
||||
],
|
||||
|
|
@ -326,20 +330,20 @@ class _DaemonPathsPanel extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final entries = <(String, String, IconData)>[
|
||||
('Log', paths.logPath, Icons.description_outlined),
|
||||
('Config', paths.configPath, Icons.settings_outlined),
|
||||
('Audit DB', paths.dbPath, Icons.storage_outlined),
|
||||
('Modules dir', paths.modulesDir, Icons.extension_outlined),
|
||||
('Flows dir', paths.flowsDir, Icons.account_tree_outlined),
|
||||
('PID file', paths.pidPath, Icons.fingerprint),
|
||||
(l.doctorPathLog, paths.logPath, Icons.description_outlined),
|
||||
(l.doctorPathConfig, paths.configPath, Icons.settings_outlined),
|
||||
(l.doctorPathDb, paths.dbPath, Icons.storage_outlined),
|
||||
(l.doctorPathModules, paths.modulesDir, Icons.extension_outlined),
|
||||
(l.doctorPathFlows, paths.flowsDir, Icons.account_tree_outlined),
|
||||
(l.doctorPathPid, paths.pidPath, Icons.fingerprint),
|
||||
].where((e) => e.$2.isNotEmpty).toList();
|
||||
|
||||
if (entries.isEmpty) {
|
||||
return FaiCard(
|
||||
child: Text(
|
||||
'Daemon did not report file paths. '
|
||||
'Update the running hub via `fai daemon restart`.',
|
||||
l.doctorPathsEmpty,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
);
|
||||
|
|
@ -396,13 +400,14 @@ class _PathRow extends StatelessWidget {
|
|||
final r = await SystemActions.openInOs(path);
|
||||
if (!context.mounted) return;
|
||||
if (!r.ok) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not open: ${r.stderr}')),
|
||||
SnackBar(content: Text(l.doctorOpenError(r.stderr))),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.open_in_new, size: 14),
|
||||
label: const Text('Open'),
|
||||
label: Text(AppLocalizations.of(context)!.buttonOpen),
|
||||
style: OutlinedButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
|
|
@ -468,6 +473,7 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ChannelInfo? active;
|
||||
if (_channels != null) {
|
||||
for (final c in _channels!.channels) {
|
||||
|
|
@ -502,10 +508,10 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
|||
Expanded(
|
||||
child: Text(
|
||||
active == null
|
||||
? 'Daemon status: …'
|
||||
? l.doctorStatusUnknown
|
||||
: active.running
|
||||
? 'Running on ${active.name}: ${active.endpoint}'
|
||||
: '${active.name} daemon stopped',
|
||||
? l.doctorRunningOn(active.name, active.endpoint)
|
||||
: l.doctorStoppedOn(active.name),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
|
|
@ -514,22 +520,20 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
|||
),
|
||||
if (active != null) ...[
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Tooltip(
|
||||
message: 'PID file at the channel\'s run/<name>.pid',
|
||||
child: FaiPill(
|
||||
label: active.running ? 'running' : 'stopped',
|
||||
tone: active.running
|
||||
? FaiPillTone.success
|
||||
: FaiPillTone.neutral,
|
||||
),
|
||||
FaiPill(
|
||||
label: active.running
|
||||
? l.doctorPillRunning
|
||||
: l.doctorPillStopped,
|
||||
tone: active.running
|
||||
? FaiPillTone.success
|
||||
: FaiPillTone.neutral,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
Text(
|
||||
'Studio shells out to the platform binary — mirrors '
|
||||
'`fai daemon …` exactly so the CLI and UI stay in lockstep.',
|
||||
l.doctorDaemonControlHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -547,7 +551,7 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
|||
() => SystemActions.faiDaemon(['restart']),
|
||||
),
|
||||
icon: const Icon(Icons.restart_alt, size: 16),
|
||||
label: const Text('Restart'),
|
||||
label: Text(l.doctorRestart),
|
||||
),
|
||||
if (active?.running != true)
|
||||
FilledButton.icon(
|
||||
|
|
@ -558,7 +562,7 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
|||
() => SystemActions.faiDaemon(['start']),
|
||||
),
|
||||
icon: const Icon(Icons.play_arrow, size: 16),
|
||||
label: const Text('Start'),
|
||||
label: Text(l.doctorStart),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _busy
|
||||
|
|
@ -568,7 +572,7 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
|||
() => SystemActions.faiDaemon(['stop']),
|
||||
),
|
||||
icon: const Icon(Icons.stop_circle_outlined, size: 16),
|
||||
label: const Text('Stop'),
|
||||
label: Text(l.doctorStop),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _busy
|
||||
|
|
@ -578,7 +582,7 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
|||
() => SystemActions.faiDaemon(['status']),
|
||||
),
|
||||
icon: const Icon(Icons.health_and_safety_outlined, size: 16),
|
||||
label: const Text('Status'),
|
||||
label: Text(l.doctorStatusAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -593,7 +597,7 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
|||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Text(
|
||||
'Working…',
|
||||
l.doctorDaemonControlWorking,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -797,7 +801,7 @@ class _UpdateBannerState extends State<_UpdateBanner> {
|
|||
setState(() {
|
||||
_applying = false;
|
||||
_applyOutput = r.ok
|
||||
? 'Update applied. Restart Studio to see the new daemon version.'
|
||||
? AppLocalizations.of(context)!.doctorApplyDone
|
||||
: (r.stderr.isEmpty ? r.stdout : r.stderr).trim();
|
||||
});
|
||||
}
|
||||
|
|
@ -805,6 +809,7 @@ class _UpdateBannerState extends State<_UpdateBanner> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final status = widget.status;
|
||||
final available = status.updateAvailable;
|
||||
final iconData = available
|
||||
|
|
@ -814,13 +819,11 @@ class _UpdateBannerState extends State<_UpdateBanner> {
|
|||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurfaceVariant;
|
||||
final title = available
|
||||
? 'Update available — ${status.latestVersion}'
|
||||
: 'Release host unreachable';
|
||||
? l.doctorUpdateAvailable(status.latestVersion)
|
||||
: l.doctorUpdateUnreachable;
|
||||
final body = available
|
||||
? 'Channel ${status.channel} now offers ${status.latestVersion}. '
|
||||
'Apply via the button — Studio shells out to '
|
||||
'`fai update apply --channel ${status.channel}`.'
|
||||
: (status.reason ?? 'Could not contact the release feed for ${status.channel}.');
|
||||
? l.doctorUpdateBody(status.channel, status.latestVersion)
|
||||
: (status.reason ?? l.doctorUpdateUnreachableBody(status.channel));
|
||||
return FaiCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -851,7 +854,7 @@ class _UpdateBannerState extends State<_UpdateBanner> {
|
|||
if (available && status.releaseNotesUrl != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'notes: ${status.releaseNotesUrl}',
|
||||
l.doctorReleaseNotes(status.releaseNotesUrl!),
|
||||
style: FaiTheme.mono(
|
||||
size: 11,
|
||||
color: theme.colorScheme.primary,
|
||||
|
|
@ -871,11 +874,11 @@ class _UpdateBannerState extends State<_UpdateBanner> {
|
|||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.system_update_alt, size: 16),
|
||||
label: Text(_applying ? 'Applying…' : 'Apply update'),
|
||||
label: Text(_applying ? l.doctorApplying : l.doctorApplyUpdate),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
FaiPill(
|
||||
label: available ? 'new' : 'offline',
|
||||
label: available ? l.doctorPillNew : l.doctorPillOffline,
|
||||
tone: available ? FaiPillTone.success : FaiPillTone.neutral,
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ class _ModulesPageState extends State<ModulesPage> {
|
|||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
tooltip: 'Reload',
|
||||
tooltip: AppLocalizations.of(context)!.modulesReloadTooltip,
|
||||
onPressed: _refresh,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
|
|
@ -54,6 +54,7 @@ class _ModulesPageState extends State<ModulesPage> {
|
|||
body: FutureBuilder<List<ModuleSummary>>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
|
@ -61,22 +62,20 @@ class _ModulesPageState extends State<ModulesPage> {
|
|||
return FaiEmptyState(
|
||||
icon: Icons.cloud_off_outlined,
|
||||
iconColor: Theme.of(context).colorScheme.error,
|
||||
title: 'Hub unreachable',
|
||||
hint:
|
||||
'Start the hub with `fai serve`. Studio will retry automatically.',
|
||||
title: l.hubUnreachable,
|
||||
hint: l.hubUnreachableHint,
|
||||
action: FilledButton.tonal(
|
||||
onPressed: _refresh,
|
||||
child: const Text('Retry'),
|
||||
child: Text(l.buttonRetry),
|
||||
),
|
||||
);
|
||||
}
|
||||
final modules = snapshot.data ?? [];
|
||||
if (modules.isEmpty) {
|
||||
return const FaiEmptyState(
|
||||
return FaiEmptyState(
|
||||
icon: Icons.extension_outlined,
|
||||
title: 'No modules yet',
|
||||
hint:
|
||||
'Run `fai install <capability-name>` or check ~/.fai/modules/.',
|
||||
title: l.modulesNoneTitle,
|
||||
hint: l.modulesNoneHint,
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
|
|
@ -142,7 +141,7 @@ class _ModuleCard extends StatelessWidget {
|
|||
SizedBox(
|
||||
width: 110,
|
||||
child: Text(
|
||||
'Capabilities',
|
||||
AppLocalizations.of(context)!.modulesCapabilitiesLabel,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.4,
|
||||
|
|
@ -212,7 +211,7 @@ class _RecentActivityPanel extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'RECENT ACTIVITY',
|
||||
AppLocalizations.of(context)!.modulesRecentActivity,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
|
|
@ -221,7 +220,8 @@ class _RecentActivityPanel extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Text(
|
||||
'last ${events.length} install/uninstall events from the audit log',
|
||||
AppLocalizations.of(context)!
|
||||
.modulesRecentActivityHint(events.length),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -269,7 +269,7 @@ class _ActivityRow extends StatelessWidget {
|
|||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${installed ? "installed" : "uninstalled"} '
|
||||
'${installed ? AppLocalizations.of(context)!.modulesActivityInstalled : AppLocalizations.of(context)!.modulesActivityUninstalled} '
|
||||
'${event.moduleName ?? "(unknown)"}'
|
||||
'${event.moduleVersion != null ? " v${event.moduleVersion}" : ""}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import 'package:flutter_markdown/flutter_markdown.dart';
|
|||
|
||||
import '../data/hub.dart';
|
||||
import '../data/system_actions.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/theme.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/widgets.dart';
|
||||
|
|
@ -220,9 +221,25 @@ class _StorePageState extends State<StorePage> {
|
|||
_status.isEmpty &&
|
||||
!_installedOnly &&
|
||||
items.any((e) => e.featured);
|
||||
// Sparse-store nudge: when no filter is set
|
||||
// and the store carries no federated entries,
|
||||
// hint that adding an MCP / n8n endpoint
|
||||
// brings dozens of capabilities at once.
|
||||
// Hides the moment any federated entry shows
|
||||
// up — the nudge has done its job.
|
||||
final showFederationNudge =
|
||||
_queryCtrl.text.trim().isEmpty &&
|
||||
_category.isEmpty &&
|
||||
_status.isEmpty &&
|
||||
!_installedOnly &&
|
||||
!items.any((e) => e.isFederated);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showFederationNudge) ...[
|
||||
const _FederationNudge(),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
],
|
||||
if (showFeatured) ...[
|
||||
_FeaturedStrip(
|
||||
items: items.where((e) => e.featured).toList(),
|
||||
|
|
@ -1773,3 +1790,66 @@ class _ScreenshotPlaceholder extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sparse-store nudge: the operator's store has only the
|
||||
/// bundled native + planned modules and no federated entries.
|
||||
/// The single biggest store-fullness lever is configuring an
|
||||
/// MCP server or n8n endpoint, so the banner says exactly
|
||||
/// that. Tap → Settings dialog opens straight to the editor.
|
||||
/// Dismisses automatically the next render after a federated
|
||||
/// entry shows up — no per-user "don't show again".
|
||||
class _FederationNudge extends StatelessWidget {
|
||||
const _FederationNudge();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(FaiSpace.md),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.hub_outlined,
|
||||
size: 22,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l.storeFederationNudgeTitle,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l.storeFederationNudgeBody,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.md),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => FaiSettingsDialog.show(context),
|
||||
icon: const Icon(Icons.settings_outlined, size: 16),
|
||||
label: Text(l.storeFederationNudgeButton),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue