chain-studio/lib/pages/modules.dart
flemming-it ee83eb851a feat(studio): app-wide i18n via flutter_localizations (v0.24.0)
Replaces the Store-only DE/EN toggle with an app-wide one
parked in the sidebar footer next to the theme button.
Pressing it flips every translated string at once: nav
labels, page titles, common buttons, the bilingual
store-index content.

Implementation:
- Adds `flutter_localizations` + `intl` to pubspec, plus
  `flutter.generate: true` so `flutter gen-l10n` runs in the
  build pipeline.
- ARB sources at `lib/l10n/app_en.arb` and `app_de.arb`. The
  EN file is the template; DE carries the German strings.
  Initial coverage: navigation, common buttons, page titles,
  channels / store / audit / modules / approvals headers,
  hub-unreachable copy, MCP + n8n panel headers + hints.
  Rest of the UI strings are still English-literal — those
  fall in incrementally as we touch each surface.
- Generated `AppLocalizations` lives at
  `lib/l10n/app_localizations*.dart` (regenerated via
  `flutter gen-l10n` on every ARB edit).
- `StudioAppState` gains `localeNotifier` alongside
  `modeNotifier`; persisted via SharedPreferences key
  `locale.code`.
- Sidebar `_LanguageToggle` reads/writes through the
  notifier. The Store's per-page locale state is gone:
  `_locale` now reads `Localizations.localeOf(context)
  .languageCode`, so the bilingual store-index content
  follows the global setting without a second toggle.
- `_NavPage.label` becomes `_NavPage.id` + `labelOf(context)`;
  Cmd+K palette and Sidebar both read the localized label.

Out of scope this iteration: localizing the remaining
~80% of UI strings (Settings dialog labels, Store search
hint, error messages). Those land incrementally — the i18n
infrastructure now means each is a one-line ARB edit + one
call-site swap.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-08 01:19:32 +02:00

302 lines
9.5 KiB
Dart

import 'package:flutter/material.dart';
import '../data/hub.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
class ModulesPage extends StatefulWidget {
const ModulesPage({super.key});
@override
State<ModulesPage> createState() => _ModulesPageState();
}
class _ModulesPageState extends State<ModulesPage> {
late Future<List<ModuleSummary>> _future;
late Future<List<AuditEvent>> _historyFuture;
@override
void initState() {
super.initState();
_future = HubService.instance.listModules();
_historyFuture = _loadHistory();
}
Future<List<AuditEvent>> _loadHistory() {
return HubService.instance.recentEvents(
limit: 10,
types: const ['module.installed', 'module.uninstalled'],
);
}
void _refresh() => setState(() {
_future = HubService.instance.listModules();
_historyFuture = _loadHistory();
});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar(
title: Text(AppLocalizations.of(context)!.modulesTitle),
actions: [
IconButton(
icon: const Icon(Icons.refresh, size: 18),
tooltip: 'Reload',
onPressed: _refresh,
),
const SizedBox(width: FaiSpace.sm),
],
),
body: FutureBuilder<List<ModuleSummary>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
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.',
action: FilledButton.tonal(
onPressed: _refresh,
child: const Text('Retry'),
),
);
}
final modules = snapshot.data ?? [];
if (modules.isEmpty) {
return const FaiEmptyState(
icon: Icons.extension_outlined,
title: 'No modules yet',
hint:
'Run `fai install <capability-name>` or check ~/.fai/modules/.',
);
}
return ListView(
padding: const EdgeInsets.all(FaiSpace.xl),
children: [
_RecentActivityPanel(future: _historyFuture),
const SizedBox(height: FaiSpace.md),
for (var i = 0; i < modules.length; i++) ...[
if (i > 0) const SizedBox(height: FaiSpace.md),
GestureDetector(
onTap: () async {
final uninstalled =
await FaiModuleSheet.show(context, modules[i].name);
if (uninstalled) _refresh();
},
behavior: HitTestBehavior.opaque,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: _ModuleCard(module: modules[i]),
),
),
],
],
);
},
),
);
}
}
class _ModuleCard extends StatelessWidget {
final ModuleSummary module;
const _ModuleCard({required this.module});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FaiCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
module.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: FaiSpace.sm),
FaiPill(
label: 'v${module.version}',
tone: FaiPillTone.neutral,
monospace: true,
),
],
),
const SizedBox(height: FaiSpace.md),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 110,
child: Text(
'Capabilities',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.4,
),
),
),
Expanded(
child: Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
children: module.capabilities
.map(
(c) => FaiPill(
label: c,
tone: FaiPillTone.accent,
monospace: true,
),
)
.toList(),
),
),
],
),
],
),
);
}
}
/// Compact "Recent activity" header listing the last few
/// install / uninstall events from the audit log. Lets
/// operators trace "wait, when did that module appear?"
/// without leaving the Modules page. Hidden when the audit
/// log has no relevant events yet.
class _RecentActivityPanel extends StatelessWidget {
final Future<List<AuditEvent>> future;
const _RecentActivityPanel({required this.future});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FutureBuilder<List<AuditEvent>>(
future: future,
builder: (context, snap) {
final events = snap.data ?? const <AuditEvent>[];
if (events.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
vertical: FaiSpace.sm,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.history,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Text(
'RECENT ACTIVITY',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(width: FaiSpace.sm),
Text(
'last ${events.length} install/uninstall events from the audit log',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: FaiSpace.xs),
for (final e in events) _ActivityRow(event: e),
],
),
);
},
);
}
}
class _ActivityRow extends StatelessWidget {
final AuditEvent event;
const _ActivityRow({required this.event});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final installed = event.type == 'module.installed';
final accent = installed ? FaiColors.success : FaiColors.warning;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Icon(
installed ? Icons.add_circle_outline : Icons.remove_circle_outline,
size: 14,
color: accent,
),
const SizedBox(width: 6),
SizedBox(
width: 150,
child: Text(
_formatTimestamp(event.timestamp.toLocal()),
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Text(
'${installed ? "installed" : "uninstalled"} '
'${event.moduleName ?? "(unknown)"}'
'${event.moduleVersion != null ? " v${event.moduleVersion}" : ""}',
style: theme.textTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
/// Locale-unambiguous timestamp shared by the activity rows.
/// Same-day events get HH:mm:ss; older events get the full
/// YYYY-MM-DD HH:mm:ss so an operator never wonders what day
/// "21:25" was.
String _formatTimestamp(DateTime local) {
final now = DateTime.now();
final hh = local.hour.toString().padLeft(2, '0');
final mm = local.minute.toString().padLeft(2, '0');
final ss = local.second.toString().padLeft(2, '0');
final time = '$hh:$mm:$ss';
final sameDay = local.year == now.year &&
local.month == now.month &&
local.day == now.day;
if (sameDay) return time;
final mo = local.month.toString().padLeft(2, '0');
final dd = local.day.toString().padLeft(2, '0');
return '${local.year}-$mo-$dd $time';
}