diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index ad9df06..07b118f 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -910,6 +910,36 @@ class _DocCard extends StatelessWidget { } } +/// Structured error type for the doc reader. Allows the +/// error UI to render distinct lines for "what we tried", +/// "what to try next", and "the raw underlying error" — and +/// to surface the Forgejo browser fallback as a one-click +/// link instead of a sentence to copy. +class _DocReaderError { + final String slug; + final String locale; + final List attempted; + final String underlying; + const _DocReaderError({ + required this.slug, + required this.locale, + required this.attempted, + required this.underlying, + }); + + String get forgejoUrl => + 'https://git.flemming.ai/fai/platform/src/branch/main/docs/architecture/$slug.md'; + + @override + String toString() => + 'Could not load the bundled doc "$slug" ($locale).\n' + 'Tried: ${attempted.join(", ")}\n' + 'Underlying: $underlying\n' + 'This typically means the Studio binary on disk is from\n' + 'before the doc bundle changed. Rebuild Studio (fai studio\n' + 'or the build pipeline) or open the Forgejo copy in browser.'; +} + /// Modal bottom-sheet that loads the markdown for a doc entry /// from `assets/docs/[_de].md` and renders it inline via /// flutter_markdown. No browser, no network — KRITIS-friendly. @@ -951,18 +981,24 @@ class _DocReaderSheetState extends State<_DocReaderSheet> { final locale = Localizations.localeOf(context).languageCode; final localised = 'assets/docs/${widget.entry.slug}_$locale.md'; final fallback = 'assets/docs/${widget.entry.slug}.md'; + // Cache-bust attempts. If the operator is running a stale + // build from before the flutter_markdown_plus migration, + // the .dart_tool asset manifest can be inconsistent with + // the compiled binary — surface "rebuild Studio" as a + // first-class hint rather than just dumping the raw + // PlatformException string into FaiErrorBox. try { return await rootBundle.loadString(localised); } catch (firstErr) { try { return await rootBundle.loadString(fallback); } catch (secondErr) { - // Surface BOTH attempted paths and the underlying - // errors so the operator can copy a useful diagnostic - // out of FaiErrorBox without us having to ship a doc - // about reading Flutter asset errors. - throw 'tried "$localised": $firstErr\n' - 'then "$fallback": $secondErr'; + throw _DocReaderError( + slug: widget.entry.slug, + locale: locale, + attempted: [localised, fallback], + underlying: '$firstErr / $secondErr', + ); } } } @@ -1028,6 +1064,9 @@ class _DocReaderSheetState extends State<_DocReaderSheet> { ); } if (snap.hasError) { + final err = snap.error; + final structured = + err is _DocReaderError ? err : null; return Padding( padding: const EdgeInsets.all(FaiSpace.xxl), child: Column( @@ -1041,10 +1080,19 @@ class _DocReaderSheetState extends State<_DocReaderSheet> { ), const SizedBox(height: FaiSpace.sm), FaiErrorBox( - error: snap.error, + error: err, isError: true, maxHeight: 200, ), + if (structured != null) ...[ + const SizedBox(height: FaiSpace.md), + TextButton.icon( + icon: const Icon(Icons.open_in_new, size: 16), + label: Text(structured.forgejoUrl), + onPressed: () => + SystemActions.openInOs(structured.forgejoUrl), + ), + ], ], ), ); diff --git a/test/load_docs_test.dart b/test/load_docs_test.dart new file mode 100644 index 0000000..f822544 --- /dev/null +++ b/test/load_docs_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:fai_studio/l10n/app_localizations.dart'; +import 'package:fai_studio/theme/theme.dart'; + +/// Recreates the exact DocReaderSheet code path that +/// welcome.dart uses, including FaiTheme.markdownStyle, so a +/// regression in either rootBundle, the markdown parser, or +/// the style sheet surfaces here instead of only at runtime +/// when an operator clicks a doc card. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('DocReaderSheet renders all four german + english docs', + (tester) async { + final theme = FaiTheme.light(); + for (final slug in ['architecture', 'security', 'audit', 'flows']) { + for (final locale in ['de', 'en']) { + final path = locale == 'de' + ? 'assets/docs/${slug}_de.md' + : 'assets/docs/$slug.md'; + final text = await rootBundle.loadString(path); + + await tester.pumpWidget(MaterialApp( + locale: Locale(locale), + supportedLocales: const [Locale('en'), Locale('de')], + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + theme: theme, + home: Scaffold( + body: Markdown( + data: text, + padding: const EdgeInsets.all(8), + selectable: true, + styleSheet: FaiTheme.markdownStyle(theme), + ), + ), + )); + await tester.pump(); + // ignore: avoid_print + print('rendered $path'); + } + } + }); +}