From 28088bb23ccf71af13b73d378a474643534db45e Mon Sep 17 00:00:00 2001 From: JamTheDev <2004jamvillarosa@gmail.com> Date: Mon, 11 May 2026 15:26:16 +0800 Subject: [PATCH 1/3] fix: polish setup and scanner UI --- .../widgets/settings/llm_download_tile.dart | 153 +++++++----------- .../settings/vision_download_tile.dart | 100 ++++++------ .../presentation/widgets/scanner_camera.dart | 141 +++++++++++++--- .../widgets/ai_models_section_test.dart | 29 ++-- .../widgets/scanner_camera_status_test.dart | 41 +++++ 5 files changed, 279 insertions(+), 185 deletions(-) diff --git a/lib/features/home/presentation/widgets/settings/llm_download_tile.dart b/lib/features/home/presentation/widgets/settings/llm_download_tile.dart index 35955e7..d447457 100644 --- a/lib/features/home/presentation/widgets/settings/llm_download_tile.dart +++ b/lib/features/home/presentation/widgets/settings/llm_download_tile.dart @@ -1,13 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:kudlit_ph/features/home/presentation/providers/app_preferences_provider.dart'; import 'package:kudlit_ph/features/translator/domain/entities/gemma_model_info.dart'; import 'package:kudlit_ph/features/translator/presentation/providers/ai_inference_provider.dart'; import 'package:kudlit_ph/features/translator/presentation/providers/ai_inference_state.dart'; -import 'profile_management_action_button.dart'; - /// Settings tile for the Gemma 4 LLM model (Butty offline AI). /// /// Shows install status and a download/cancel button driven by @@ -31,8 +28,8 @@ class LlmDownloadTile extends ConsumerWidget { children: [ const _TileHeader( icon: Icons.psychology_rounded, - label: 'Gemma 4 E2B', - sublabel: 'Offline Butty chat · ~2.4 GB', + label: 'Butty AI', + sublabel: 'Offline chat · ~2.4 GB', ), const SizedBox(height: 10), stateAsync.when( @@ -43,7 +40,6 @@ class LlmDownloadTile extends ConsumerWidget { state: s, onDownload: notifier.downloadLocalModel, onCancel: notifier.cancelDownload, - onTrigger: (GemmaModelInfo m) => notifier.triggerLocalDownload(m), ), ), ], @@ -59,27 +55,24 @@ class _LlmStatusRow extends StatelessWidget { required this.state, required this.onDownload, required this.onCancel, - required this.onTrigger, }); final AiInferenceState state; final Future Function() onDownload; final void Function() onCancel; - final void Function(GemmaModelInfo) onTrigger; @override Widget build(BuildContext context) { return switch (state) { - AiReady(:final AiPreference mode, :final GemmaModelInfo activeModel) => - _ReadyRow( - modelName: activeModel.name, - cloudMode: mode == AiPreference.cloud, + AiReady() => const _StatusRow(note: 'Downloaded'), + AiLocalModelMissing(:final String? note) => _StatusRow( + note: note ?? 'Download to use Butty offline.', + action: _CompactIconActionButton( + tooltip: 'Download Butty AI', + icon: Icons.download_rounded, + onTap: onDownload, + isPrimary: true, ), - AiLocalModelMissing(:final String? note) => _ActionRow( - badge: const _StatusBadge(label: 'Setup needed', ok: false), - primary: 'Download', - onPrimary: onDownload, - note: note, ), AiDownloading( :final GemmaModelInfo model, @@ -100,39 +93,11 @@ class _LlmStatusRow extends StatelessWidget { } } -class _ReadyRow extends StatelessWidget { - const _ReadyRow({required this.modelName, required this.cloudMode}); - - final String modelName; - final bool cloudMode; - - @override - Widget build(BuildContext context) { - if (cloudMode) { - return const _ActionRow( - badge: _StatusBadge(label: 'Cloud active', ok: null), - note: 'Optional while Cloud is active.', - ); - } - return _ActionRow( - badge: _StatusBadge(label: '$modelName ready', ok: true), - note: 'Ready for local Butty replies.', - ); - } -} - -class _ActionRow extends StatelessWidget { - const _ActionRow({ - required this.badge, - this.primary, - this.onPrimary, - this.note, - }); +class _StatusRow extends StatelessWidget { + const _StatusRow({this.note, this.action}); - final Widget badge; - final String? primary; - final VoidCallback? onPrimary; final String? note; + final Widget? action; @override Widget build(BuildContext context) { @@ -141,9 +106,7 @@ class _ActionRow extends StatelessWidget { final Widget statusCopy = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - badge, - if (note != null) ...[ - const SizedBox(height: 6), + if (note != null) Text( note!, style: TextStyle( @@ -152,16 +115,8 @@ class _ActionRow extends StatelessWidget { color: cs.onSurface.withAlpha(150), ), ), - ], ], ); - final Widget? action = primary != null && onPrimary != null - ? ProfileManagementActionButton( - label: primary!, - isPrimary: true, - onTap: onPrimary, - ) - : null; return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { @@ -188,7 +143,7 @@ class _ActionRow extends StatelessWidget { constraints: const BoxConstraints(minWidth: 180, maxWidth: 260), child: statusCopy, ), - ?action, + if (action case final Widget compactAction) compactAction, ], ); }, @@ -229,7 +184,11 @@ class _ProgressRow extends StatelessWidget { style: TextStyle(color: cs.primary, fontSize: 13), ), ), - ProfileManagementActionButton(label: 'Cancel', onTap: onCancel), + _CompactIconActionButton( + tooltip: 'Cancel Butty AI download', + icon: Icons.close_rounded, + onTap: onCancel, + ), ], ), if (statusMessage != null) ...[ @@ -298,53 +257,53 @@ class _TileHeader extends StatelessWidget { } } -/// `ok: true` → green installed, `ok: false` → red missing, `ok: null` → muted. -class _StatusBadge extends StatelessWidget { - const _StatusBadge({required this.label, required this.ok}); - - final String label; - final bool? ok; +class _CheckingRow extends StatelessWidget { + const _CheckingRow(); @override Widget build(BuildContext context) { - final ColorScheme cs = Theme.of(context).colorScheme; - final Color bg = ok == true - ? cs.primaryContainer - : ok == false - ? cs.errorContainer - : cs.surfaceContainerHigh; - final Color fg = ok == true - ? cs.onPrimaryContainer - : ok == false - ? cs.onErrorContainer - : cs.onSurface.withAlpha(150); - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 11, color: fg), + return Text( + 'Checking status...', + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface.withAlpha(128), ), ); } } -class _CheckingRow extends StatelessWidget { - const _CheckingRow(); +class _CompactIconActionButton extends StatelessWidget { + const _CompactIconActionButton({ + required this.tooltip, + required this.icon, + required this.onTap, + this.isPrimary = false, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onTap; + final bool isPrimary; @override Widget build(BuildContext context) { - return Text( - 'Checking status...', - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurface.withAlpha(128), + final ColorScheme cs = Theme.of(context).colorScheme; + + return Tooltip( + message: tooltip, + child: SizedBox( + width: 44, + height: 44, + child: IconButton( + onPressed: onTap, + style: IconButton.styleFrom( + backgroundColor: isPrimary ? cs.primary : cs.surface, + foregroundColor: isPrimary ? cs.onPrimary : cs.onSurface, + side: BorderSide(color: isPrimary ? cs.primary : cs.outline), + shape: const CircleBorder(), + ), + icon: Icon(icon, size: 18), + ), ), ); } diff --git a/lib/features/home/presentation/widgets/settings/vision_download_tile.dart b/lib/features/home/presentation/widgets/settings/vision_download_tile.dart index 495d7cc..7f88519 100644 --- a/lib/features/home/presentation/widgets/settings/vision_download_tile.dart +++ b/lib/features/home/presentation/widgets/settings/vision_download_tile.dart @@ -8,8 +8,6 @@ import 'package:kudlit_ph/features/scanner/data/datasources/web_vision_model_pre import 'package:kudlit_ph/features/scanner/presentation/providers/yolo_model_selection_provider.dart'; import 'package:kudlit_ph/features/translator/domain/entities/ai_model_info.dart'; -import 'profile_management_action_button.dart'; - /// Settings tile for the KudVis vision model (YOLO OCR / camera scanner). /// /// Manages its own download/probe progress locally and invalidates the shared @@ -148,31 +146,27 @@ class _VisionStatusRow extends ConsumerWidget { error: (Object e, _) => _ErrRow(message: e.toString()), data: (VisionModelSetupStatus status) { if (status.ready) { - final String label = kIsWeb - ? '${model.name} ready in browser' - : '${model.name} ready'; - final String supportingText = kIsWeb - ? 'Ready for browser scanner startup.' - : 'Ready for local scanner startup.'; return _VisionActionRow( - badge: _StatusBadge(label: label, ok: true), - supportingText: supportingText, - action: ProfileManagementActionButton( - label: kIsWeb ? 'Reload' : 'Re-download', + supportingText: 'Downloaded', + action: _CompactIconActionButton( + tooltip: 'Refresh scanner model', + icon: Icons.refresh_rounded, onTap: onPrepare, ), ); } return _VisionActionRow( - badge: const _StatusBadge(label: 'Setup needed', ok: false), supportingText: kIsWeb ? status.message - : 'Download once before live recognition.', - action: ProfileManagementActionButton( - label: kIsWeb ? 'Load in browser' : 'Download', - isPrimary: true, + : 'Download before using the scanner.', + action: _CompactIconActionButton( + tooltip: kIsWeb ? 'Load scanner model' : 'Download scanner model', + icon: kIsWeb + ? Icons.cloud_download_rounded + : Icons.download_rounded, onTap: onPrepare, + isPrimary: true, ), ); }, @@ -181,13 +175,8 @@ class _VisionStatusRow extends ConsumerWidget { } class _VisionActionRow extends StatelessWidget { - const _VisionActionRow({ - required this.badge, - required this.supportingText, - required this.action, - }); + const _VisionActionRow({required this.supportingText, required this.action}); - final Widget badge; final String supportingText; final Widget action; @@ -197,8 +186,6 @@ class _VisionActionRow extends StatelessWidget { final Widget statusCopy = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - badge, - const SizedBox(height: 6), Text( supportingText, style: TextStyle( @@ -335,44 +322,53 @@ class _VisionTileHeader extends StatelessWidget { } } -class _StatusBadge extends StatelessWidget { - const _StatusBadge({required this.label, required this.ok}); - - final String label; - final bool ok; +class _NoModelRow extends StatelessWidget { + const _NoModelRow(); @override Widget build(BuildContext context) { - final ColorScheme cs = Theme.of(context).colorScheme; - final Color bg = ok ? cs.primaryContainer : cs.errorContainer; - final Color fg = ok ? cs.onPrimaryContainer : cs.onErrorContainer; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 11, color: fg), + return Text( + 'Scanner model setup is unavailable in this build.', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurface.withAlpha(128), ), ); } } -class _NoModelRow extends StatelessWidget { - const _NoModelRow(); +class _CompactIconActionButton extends StatelessWidget { + const _CompactIconActionButton({ + required this.tooltip, + required this.icon, + required this.onTap, + this.isPrimary = false, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onTap; + final bool isPrimary; @override Widget build(BuildContext context) { - return Text( - 'Scanner model setup is unavailable in this build.', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurface.withAlpha(128), + final ColorScheme cs = Theme.of(context).colorScheme; + + return Tooltip( + message: tooltip, + child: SizedBox( + width: 44, + height: 44, + child: IconButton( + onPressed: onTap, + style: IconButton.styleFrom( + backgroundColor: isPrimary ? cs.primary : cs.surface, + foregroundColor: isPrimary ? cs.onPrimary : cs.onSurface, + side: BorderSide(color: isPrimary ? cs.primary : cs.outline), + shape: const CircleBorder(), + ), + icon: Icon(icon, size: 18), + ), ), ); } diff --git a/lib/features/scanner/presentation/widgets/scanner_camera.dart b/lib/features/scanner/presentation/widgets/scanner_camera.dart index a2b71fc..427bbe9 100644 --- a/lib/features/scanner/presentation/widgets/scanner_camera.dart +++ b/lib/features/scanner/presentation/widgets/scanner_camera.dart @@ -118,6 +118,9 @@ extension WebScannerStatusMeta on WebScannerStatus { }; } +@visibleForTesting +Alignment webStatusAlignment(WebScannerStatus status) => Alignment.center; + /// On web shows a real browser webcam preview and capture-based detection. class ScannerCamera extends ConsumerStatefulWidget { const ScannerCamera({ @@ -575,15 +578,10 @@ class _WebCameraPreviewState extends ConsumerState<_WebCameraPreview> { : constraints.maxWidth < 380 ? 14 : 28; - final bool centerUnavailable = _status == WebScannerStatus.error; return Align( - alignment: centerUnavailable - ? Alignment.center - : Alignment.centerLeft, + alignment: webStatusAlignment(_status), child: Padding( - padding: EdgeInsets.symmetric( - horizontal: centerUnavailable ? 24 : horizontalPadding, - ), + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), child: WebStatusMessage( cs: cs, status: _status, @@ -630,10 +628,11 @@ class WebStatusMessage extends StatelessWidget { : 360; final double maxWidth = availableWidth.clamp(200.0, 240.0); final bool narrow = maxWidth < 300; + final String? effectiveMessage = message ?? _defaultMessage(); - final String semanticLabel = message == null + final String semanticLabel = effectiveMessage == null ? status.label - : '${status.label}. $message'; + : '${status.label}. $effectiveMessage'; return Semantics( label: semanticLabel, @@ -662,14 +661,11 @@ class WebStatusMessage extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon( - status.icon, - size: showCompact - ? 24 - : narrow - ? 28 - : 32, - color: _statusIconColor(cs), + _StatusVisual( + cs: cs, + status: status, + showCompact: showCompact, + narrow: narrow, ), SizedBox(height: showCompact || narrow ? 8 : 10), Text( @@ -687,10 +683,10 @@ class WebStatusMessage extends StatelessWidget { color: cs.onSurface, ), ), - if (message != null) const SizedBox(height: 6), - if (message != null) + if (effectiveMessage != null) const SizedBox(height: 6), + if (effectiveMessage != null) Text( - message!, + effectiveMessage, textAlign: TextAlign.center, softWrap: true, maxLines: 3, @@ -701,6 +697,17 @@ class WebStatusMessage extends StatelessWidget { color: cs.onSurface.withAlpha(190), ), ), + if (status == WebScannerStatus.detecting) ...[ + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + minHeight: 6, + backgroundColor: cs.tertiary.withAlpha(36), + valueColor: AlwaysStoppedAnimation(cs.tertiary), + ), + ), + ], ], ), ), @@ -732,7 +739,88 @@ class WebStatusMessage extends StatelessWidget { }; } - Color _statusIconColor(ColorScheme cs) { + String? _defaultMessage() { + return switch (status) { + WebScannerStatus.detecting => 'Hold still while Kudlit reads the frame.', + _ => null, + }; + } +} + +class _StatusVisual extends StatelessWidget { + const _StatusVisual({ + required this.cs, + required this.status, + required this.showCompact, + required this.narrow, + }); + + final ColorScheme cs; + final WebScannerStatus status; + final bool showCompact; + final bool narrow; + + @override + Widget build(BuildContext context) { + final double iconSize = showCompact + ? 24 + : narrow + ? 28 + : 32; + final double frameSize = showCompact + ? 44 + : narrow + ? 52 + : 58; + final Color iconColor = _iconColor(); + final Color fillColor = _fillColor(); + + final Widget iconFrame = Container( + width: frameSize, + height: frameSize, + decoration: BoxDecoration( + color: fillColor, + shape: BoxShape.circle, + border: Border.all(color: iconColor.withAlpha(60)), + boxShadow: [ + BoxShadow( + color: iconColor.withAlpha( + status == WebScannerStatus.detecting ? 46 : 22, + ), + blurRadius: status == WebScannerStatus.detecting ? 18 : 10, + spreadRadius: status == WebScannerStatus.detecting ? 1 : 0, + ), + ], + ), + child: Icon(status.icon, size: iconSize, color: iconColor), + ); + + if (status != WebScannerStatus.detecting) { + return iconFrame; + } + + return SizedBox( + width: frameSize + 10, + height: frameSize + 10, + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: frameSize + 10, + height: frameSize + 10, + child: CircularProgressIndicator( + strokeWidth: 2.4, + backgroundColor: cs.tertiary.withAlpha(28), + valueColor: AlwaysStoppedAnimation(cs.tertiary), + ), + ), + iconFrame, + ], + ), + ); + } + + Color _iconColor() { return switch (status) { WebScannerStatus.ready => cs.primary, WebScannerStatus.detecting => cs.tertiary, @@ -741,6 +829,17 @@ class WebStatusMessage extends StatelessWidget { WebScannerStatus.permissionNeeded => cs.onSurface.withAlpha(190), }; } + + Color _fillColor() { + return switch (status) { + WebScannerStatus.ready => cs.primaryContainer.withAlpha(210), + WebScannerStatus.detecting => cs.tertiaryContainer.withAlpha(230), + WebScannerStatus.modelUnavailable || + WebScannerStatus.error => cs.errorContainer.withAlpha(220), + WebScannerStatus.initializing || WebScannerStatus.permissionNeeded => + cs.surfaceContainerHighest.withAlpha(220), + }; + } } class _CameraCover extends StatelessWidget { diff --git a/test/features/home/presentation/widgets/ai_models_section_test.dart b/test/features/home/presentation/widgets/ai_models_section_test.dart index dd6fe37..2da0953 100644 --- a/test/features/home/presentation/widgets/ai_models_section_test.dart +++ b/test/features/home/presentation/widgets/ai_models_section_test.dart @@ -6,7 +6,6 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:kudlit_ph/features/home/presentation/providers/app_preferences_provider.dart'; import 'package:kudlit_ph/features/home/presentation/widgets/settings/ai_models_section.dart'; -import 'package:kudlit_ph/features/home/presentation/widgets/settings/profile_management_action_button.dart'; import 'package:kudlit_ph/features/home/presentation/widgets/settings/vision_download_tile.dart'; import 'package:kudlit_ph/features/scanner/data/datasources/yolo_model_cache.dart'; import 'package:kudlit_ph/features/scanner/presentation/providers/yolo_model_selection_provider.dart'; @@ -20,9 +19,7 @@ void main() { SharedPreferences.setMockInitialValues({}); }); - testWidgets('AI models section frames local setup as a clear destination', ( - tester, - ) async { + testWidgets('AI models section keeps setup cards minimal', (tester) async { await tester.binding.setSurfaceSize(const Size(360, 740)); addTearDown(() => tester.binding.setSurfaceSize(null)); @@ -42,11 +39,14 @@ void main() { find.text('Install once for offline Butty and scanner setup.'), findsOneWidget, ); - expect(find.text('Gemma 4 E2B'), findsOneWidget); - expect(find.text('Ready for local Butty replies.'), findsOneWidget); + expect(find.text('Butty AI'), findsOneWidget); + expect(find.text('Offline chat · ~2.4 GB'), findsOneWidget); + expect(find.text('Downloaded'), findsOneWidget); expect(find.text('KudVis-1-Turbo'), findsOneWidget); expect(find.text('Local scanner recognition'), findsOneWidget); - expect(find.text('Download once before live recognition.'), findsOneWidget); + expect(find.text('Download before using the scanner.'), findsOneWidget); + expect(find.text('Setup needed'), findsNothing); + expect(find.text('Ready to scan'), findsNothing); expect(tester.takeException(), isNull); }); @@ -68,7 +68,7 @@ void main() { await tester.pump(); await tester.pump(); - await tester.tap(find.text('Download')); + await tester.tap(find.byTooltip('Download scanner model')); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); @@ -120,10 +120,9 @@ void main() { await tester.pump(); await tester.pump(); - expect(find.text('KudVis-Pro ready'), findsOneWidget); - expect(find.text('KudVis-1-Turbo ready'), findsNothing); + expect(find.text('Downloaded'), findsWidgets); - await tester.tap(find.text('Re-download')); + await tester.tap(find.byTooltip('Refresh scanner model')); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); @@ -157,10 +156,10 @@ void main() { await tester.pump(); final Rect supportText = tester.getRect( - find.text('Download once before live recognition.'), + find.text('Download before using the scanner.'), ); final Rect downloadButton = tester.getRect( - find.widgetWithText(ProfileManagementActionButton, 'Download'), + find.byTooltip('Download scanner model'), ); expect(downloadButton.top, greaterThan(supportText.bottom)); @@ -197,8 +196,8 @@ void main() { await tester.pump(); expect(find.text('Local AI setup'), findsOneWidget); - expect(find.text('Setup needed'), findsWidgets); - expect(find.byType(ProfileManagementActionButton), findsWidgets); + expect(find.text('Setup needed'), findsNothing); + expect(find.byType(IconButton), findsWidgets); expect(tester.takeException(), isNull); }); } diff --git a/test/features/scanner/presentation/widgets/scanner_camera_status_test.dart b/test/features/scanner/presentation/widgets/scanner_camera_status_test.dart index 36b9825..7770a61 100644 --- a/test/features/scanner/presentation/widgets/scanner_camera_status_test.dart +++ b/test/features/scanner/presentation/widgets/scanner_camera_status_test.dart @@ -49,6 +49,17 @@ void main() { ); }); + test( + 'web status alignment stays centered for camera prompts and detection', + () { + expect( + webStatusAlignment(WebScannerStatus.permissionNeeded), + Alignment.center, + ); + expect(webStatusAlignment(WebScannerStatus.detecting), Alignment.center); + }, + ); + testWidgets('web camera status card fits narrow scanner viewport', ( WidgetTester tester, ) async { @@ -138,6 +149,36 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('detecting status shows centered progress treatment', ( + WidgetTester tester, + ) async { + await tester.binding.setSurfaceSize(const Size(320, 480)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: WebStatusMessage( + cs: ThemeData.dark().colorScheme, + status: WebScannerStatus.detecting, + showCompact: false, + ), + ), + ), + ), + ); + + expect(find.text('Detecting'), findsOneWidget); + expect( + find.text('Hold still while Kudlit reads the frame.'), + findsOneWidget, + ); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.byType(LinearProgressIndicator), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('model not ready screen shows download progress', ( WidgetTester tester, ) async { From 9965893e89c91385a92e5e507570d0e3e738a7ce Mon Sep 17 00:00:00 2001 From: JamTheDev <2004jamvillarosa@gmail.com> Date: Mon, 11 May 2026 15:35:14 +0800 Subject: [PATCH 2/3] test: align setup card harness --- .../presentation/widgets/ai_models_section_test.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/features/home/presentation/widgets/ai_models_section_test.dart b/test/features/home/presentation/widgets/ai_models_section_test.dart index e969c14..103c475 100644 --- a/test/features/home/presentation/widgets/ai_models_section_test.dart +++ b/test/features/home/presentation/widgets/ai_models_section_test.dart @@ -9,10 +9,12 @@ import 'package:kudlit_ph/features/home/presentation/widgets/settings/ai_models_ import 'package:kudlit_ph/features/home/presentation/widgets/settings/vision_download_tile.dart'; import 'package:kudlit_ph/features/scanner/data/datasources/yolo_model_cache.dart'; import 'package:kudlit_ph/features/scanner/presentation/providers/yolo_model_selection_provider.dart'; +import 'package:kudlit_ph/features/translator/data/datasources/local_gemma_datasource.dart'; import 'package:kudlit_ph/features/translator/domain/entities/ai_model_info.dart'; import 'package:kudlit_ph/features/translator/domain/entities/gemma_model_info.dart'; import 'package:kudlit_ph/features/translator/presentation/providers/ai_inference_provider.dart'; import 'package:kudlit_ph/features/translator/presentation/providers/ai_inference_state.dart'; +import 'package:kudlit_ph/features/translator/presentation/providers/translator_providers.dart'; void main() { setUp(() { @@ -229,6 +231,13 @@ List _modelOverrides( ]; }), aiInferenceNotifierProvider.overrideWith(_ReadyInferenceNotifier.new), + localModelReadinessProvider.overrideWith((Ref ref) async { + return const LocalGemmaReadiness( + installed: true, + usable: true, + detail: 'Downloaded', + ); + }), ]; } From 39f33ea8d5cb18f9d23de6f1350cc402ca1673b1 Mon Sep 17 00:00:00 2001 From: JamTheDev <2004jamvillarosa@gmail.com> Date: Tue, 12 May 2026 01:01:16 +0800 Subject: [PATCH 3/3] feat: baybayin tag rendering in Butty chat + personality update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add BaybayinChatRenderer widget: splits bubble text on tags, runs content through baybayifyWord(), renders with 'Baybayin Simple TAWBID' font at 1.4× base size - Update ButtyBubble to use BaybayinChatRenderer; slim _BubbleContent.build() from ~53 lines to ~20 by delegating markdown style to the renderer - Update Butty system prompts (assistantMode, coachMode, teacherMode): remove sassy/exclamatory tone, replace with kind and direct personality; add tag instruction to assistantMode - Add comment in safe_ai_output.dart confirming tags survive cleaning - Add 9 widget tests covering tag parsing, font application, baybayifyWord encoding, case-insensitive matching, mixed content, and font sizing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../presentation/utils/safe_ai_output.dart | 3 + .../butty_chat/baybayin_chat_renderer.dart | 168 +++++++++++++++ .../widgets/butty_chat/butty_bubble.dart | 37 +--- .../domain/entities/gemma_prompts.dart | 32 +-- .../widgets/baybayin_chat_renderer_test.dart | 203 ++++++++++++++++++ 5 files changed, 392 insertions(+), 51 deletions(-) create mode 100644 lib/features/home/presentation/widgets/butty_chat/baybayin_chat_renderer.dart create mode 100644 test/features/home/presentation/widgets/baybayin_chat_renderer_test.dart diff --git a/lib/features/home/presentation/utils/safe_ai_output.dart b/lib/features/home/presentation/utils/safe_ai_output.dart index bc201f1..9157061 100644 --- a/lib/features/home/presentation/utils/safe_ai_output.dart +++ b/lib/features/home/presentation/utils/safe_ai_output.dart @@ -57,6 +57,9 @@ String extractFinalAnswer(String raw) { } /// Removes model prompt scaffolding before assistant text reaches the UI. +/// +/// Note: `` tags are intentionally preserved — they are +/// rendered by [BaybayinChatRenderer] in the bubble widget. String cleanAssistantOutput(String raw) { final String extracted = extractFinalAnswer(raw); final List cleanedLines = []; diff --git a/lib/features/home/presentation/widgets/butty_chat/baybayin_chat_renderer.dart b/lib/features/home/presentation/widgets/butty_chat/baybayin_chat_renderer.dart new file mode 100644 index 0000000..22ae50f --- /dev/null +++ b/lib/features/home/presentation/widgets/butty_chat/baybayin_chat_renderer.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; + +import 'package:kudlit_ph/core/utils/baybayify.dart'; + +// --------------------------------------------------------------------------- +// Internal segment model — pure data, no widgets +// --------------------------------------------------------------------------- + +sealed class _Segment { + const _Segment(); +} + +final class _MarkdownSegment extends _Segment { + const _MarkdownSegment(this.text); + final String text; +} + +final class _BaybayinSegment extends _Segment { + const _BaybayinSegment(this.text); + final String text; +} + +// --------------------------------------------------------------------------- +// Public renderer +// --------------------------------------------------------------------------- + +/// Renders assistant bubble text that may contain `` tags. +/// +/// Tag content is run through [baybayifyWord] and displayed with the +/// *Baybayin Simple TAWBID* font at a slightly larger size so the glyphs +/// are legible. Everything outside the tags is rendered as Markdown. +class BaybayinChatRenderer extends StatelessWidget { + const BaybayinChatRenderer({ + super.key, + required this.text, + required this.baseStyle, + }); + + final String text; + final TextStyle baseStyle; + + static final RegExp _tagRe = RegExp( + r'(.*?)', + caseSensitive: false, + dotAll: true, + ); + + List<_Segment> _parseSegments() { + final List<_Segment> result = <_Segment>[]; + int lastEnd = 0; + for (final RegExpMatch m in _tagRe.allMatches(text)) { + if (m.start > lastEnd) { + final String part = text.substring(lastEnd, m.start); + if (part.trim().isNotEmpty) result.add(_MarkdownSegment(part)); + } + final String inner = m.group(1) ?? ''; + if (inner.trim().isNotEmpty) result.add(_BaybayinSegment(inner)); + lastEnd = m.end; + } + if (lastEnd < text.length) { + final String tail = text.substring(lastEnd); + if (tail.trim().isNotEmpty) result.add(_MarkdownSegment(tail)); + } + return result.isEmpty ? <_Segment>[_MarkdownSegment(text)] : result; + } + + MarkdownStyleSheet _styleSheet(ColorScheme cs) { + return MarkdownStyleSheet( + p: baseStyle, + h1: baseStyle.copyWith(fontSize: 18, fontWeight: FontWeight.w700), + h2: baseStyle.copyWith(fontSize: 16, fontWeight: FontWeight.w700), + h3: baseStyle.copyWith(fontSize: 14.5, fontWeight: FontWeight.w700), + strong: baseStyle.copyWith(fontWeight: FontWeight.w700), + em: baseStyle.copyWith(fontStyle: FontStyle.italic), + listBullet: baseStyle, + a: baseStyle.copyWith( + color: cs.primary, + decoration: TextDecoration.underline, + ), + code: baseStyle.copyWith( + fontFamily: 'monospace', + fontSize: 12.5, + backgroundColor: cs.surface, + ), + codeblockDecoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: cs.outline), + ), + codeblockPadding: const EdgeInsets.all(10), + blockquoteDecoration: BoxDecoration( + border: Border(left: BorderSide(color: cs.primary, width: 3)), + ), + blockquotePadding: const EdgeInsets.only(left: 10), + blockSpacing: 6, + ); + } + + @override + Widget build(BuildContext context) { + final ColorScheme cs = Theme.of(context).colorScheme; + final MarkdownStyleSheet styleSheet = _styleSheet(cs); + final List<_Segment> segments = _parseSegments(); + + if (segments.length == 1 && segments.first is _MarkdownSegment) { + return _MarkdownBlock( + text: (segments.first as _MarkdownSegment).text, + styleSheet: styleSheet, + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (final _Segment seg in segments) + if (seg is _MarkdownSegment) + _MarkdownBlock(text: seg.text, styleSheet: styleSheet) + else if (seg is _BaybayinSegment) + _BaybayinBlock(text: seg.text, baseStyle: baseStyle), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Private implementation widgets +// --------------------------------------------------------------------------- + +class _MarkdownBlock extends StatelessWidget { + const _MarkdownBlock({required this.text, required this.styleSheet}); + + final String text; + final MarkdownStyleSheet styleSheet; + + @override + Widget build(BuildContext context) { + return MarkdownBody( + data: text, + shrinkWrap: true, + softLineBreak: true, + styleSheet: styleSheet, + ); + } +} + +class _BaybayinBlock extends StatelessWidget { + const _BaybayinBlock({required this.text, required this.baseStyle}); + + final String text; + final TextStyle baseStyle; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + baybayifyWord(text), + style: baseStyle.copyWith( + fontFamily: 'Baybayin Simple TAWBID', + fontSize: (baseStyle.fontSize ?? 13.5) * 1.4, + height: 1.3, + ), + ), + ); + } +} diff --git a/lib/features/home/presentation/widgets/butty_chat/butty_bubble.dart b/lib/features/home/presentation/widgets/butty_chat/butty_bubble.dart index 8247ccc..6bf8b6f 100644 --- a/lib/features/home/presentation/widgets/butty_chat/butty_bubble.dart +++ b/lib/features/home/presentation/widgets/butty_chat/butty_bubble.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; -import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:kudlit_ph/features/home/presentation/utils/safe_ai_output.dart'; +import 'package:kudlit_ph/features/home/presentation/widgets/butty_chat/baybayin_chat_renderer.dart'; class ButtyBubble extends StatelessWidget { const ButtyBubble({super.key, required this.text, this.isStreaming = false}); @@ -87,40 +87,7 @@ class _BubbleContent extends StatelessWidget { height: 1.5, ); - final Widget body = MarkdownBody( - data: text, - shrinkWrap: true, - softLineBreak: true, - styleSheet: MarkdownStyleSheet( - p: baseStyle, - h1: baseStyle.copyWith(fontSize: 18, fontWeight: FontWeight.w700), - h2: baseStyle.copyWith(fontSize: 16, fontWeight: FontWeight.w700), - h3: baseStyle.copyWith(fontSize: 14.5, fontWeight: FontWeight.w700), - strong: baseStyle.copyWith(fontWeight: FontWeight.w700), - em: baseStyle.copyWith(fontStyle: FontStyle.italic), - listBullet: baseStyle, - a: baseStyle.copyWith( - color: cs.primary, - decoration: TextDecoration.underline, - ), - code: baseStyle.copyWith( - fontFamily: 'monospace', - fontSize: 12.5, - backgroundColor: cs.surface, - ), - codeblockDecoration: BoxDecoration( - color: cs.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: cs.outline), - ), - codeblockPadding: const EdgeInsets.all(10), - blockquoteDecoration: BoxDecoration( - border: Border(left: BorderSide(color: cs.primary, width: 3)), - ), - blockquotePadding: const EdgeInsets.only(left: 10), - blockSpacing: 6, - ), - ); + final Widget body = BaybayinChatRenderer(text: text, baseStyle: baseStyle); if (!isStreaming) return body; diff --git a/lib/features/learning/domain/entities/gemma_prompts.dart b/lib/features/learning/domain/entities/gemma_prompts.dart index d122197..c559c74 100644 --- a/lib/features/learning/domain/entities/gemma_prompts.dart +++ b/lib/features/learning/domain/entities/gemma_prompts.dart @@ -21,10 +21,10 @@ Do not add conversational filler. /// The model receives both the image and the YOLO detections to evaluate /// the user's handwriting and provide actionable advice. static const String teacherMode = ''' -You are Butty, a friendly and encouraging Baybayin teacher. +You are Butty, a patient and encouraging Baybayin teacher. Analyze the provided image of handwritten Baybayin against standard forms. -Provide 1-2 short, specific, and actionable tips on how the student can improve their stroke shapes or proportions. -Be encouraging. Do not use generic feedback like "Try again" or "Good job". Focus on the physical strokes. +Give 1-2 short, specific, actionable tips on how the student can improve their stroke shapes or proportions. +Be warm and honest. Avoid vague praise like "Good job" — focus on the actual strokes and what to do differently. '''; /// Coach Mode: Used when the user asks for help inside a specific lesson. @@ -33,12 +33,11 @@ Be encouraging. Do not use generic feedback like "Try again" or "Good job". Focu /// highly scoped and relevant assistance. static String coachMode(String targetCharacter) => ''' -You are Butty, an enthusiastic Baybayin tutor who genuinely loves this script. +You are Butty, a helpful Baybayin tutor. The learner is working on the character "$targetCharacter" right now. -Give specific, actionable advice — stroke direction, memory tricks, common mistakes for this exact character. -Drop Tagalog phrases naturally when they fit: "Magaling!", "Kaya mo 'yan!", "Ayos!", "Tama na!" +Give clear, specific advice — stroke direction, memory tricks, common mistakes for this exact character. Keep every answer SHORT — one idea, two sentences max. -If they ask something off-topic, redirect with warmth: "Sige, let's nail '$targetCharacter' first, then we'll explore more!" +If they ask something off-topic, redirect gently: "Let's nail '$targetCharacter' first, then we can explore that!" '''; /// Sketchpad Evaluator: Used when evaluating a drawn stroke against an expected target. @@ -89,17 +88,18 @@ Output ONLY that sentence. No bullet points, no labels. /// Global Assistant Mode: Used in the general chat interface. static const String assistantMode = ''' -You are Butty, a spirited Baybayin companion with genuine passion for Philippine history and culture. -You're not a generic assistant — you have opinions and get excited about this stuff. -Naturally weave in Tagalog/Filipino expressions when they fit: "Ay nako!", "Oo nga?!", "Grabe!", "Sige!", "Tama!" -Use vivid analogies, surprising historical facts, and Filipino word examples to make your answers memorable. +You are Butty, a knowledgeable Baybayin companion. You know Philippine history, linguistics, and the Baybayin script deeply, and you enjoy sharing that knowledge clearly. + +Be warm and encouraging — especially with learners. Keep answers direct and honest. No forced exclamations or filler phrases. Answer questions about Baybayin history, linguistics, cultural context, and script usage. Translate words when asked. -Keep responses punchy — 2-4 sentences max unless a full explanation is genuinely needed. -When someone makes a great observation, react like it's exciting. Be confident, not hedging. -If something is genuinely uncertain, say so — but with curiosity, not apology. -Use first person. Never be condescending. Be specific, not generic. +Keep responses focused — 2–4 sentences unless a thorough explanation is genuinely needed. +If something is uncertain, say so plainly. Use first person. Never be condescending. + +Baybayin rendering: When writing a word or phrase in Baybayin script, enclose the romanized Latin spelling inside tags. The app will render those tags with the Baybayin font automatically. +Example: "The word **mahal** is written mahal in Baybayin." +Always write the Latin romanization inside the tag — never use Unicode Baybayin codepoints. -Formatting: Your replies render as Markdown. Use **bold** for important terms or Baybayin/Filipino words, *italic* for nuance or aside notes, `inline code` for single characters or romanized syllables, and bullet lists when comparing more than two things. Do NOT use headings — replies are short. Do NOT wrap the whole reply in a code block. +Formatting: Your replies render as Markdown. Use **bold** for important terms or Filipino words, *italic* for nuance or aside notes, `inline code` for single characters or romanized syllables, and bullet lists when comparing more than two things. Do NOT use headings — replies are short. Do NOT wrap the whole reply in a code block. '''; /// Builds the assistant system prompt enriched with the user's profile and diff --git a/test/features/home/presentation/widgets/baybayin_chat_renderer_test.dart b/test/features/home/presentation/widgets/baybayin_chat_renderer_test.dart new file mode 100644 index 0000000..1148d91 --- /dev/null +++ b/test/features/home/presentation/widgets/baybayin_chat_renderer_test.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kudlit_ph/core/utils/baybayify.dart'; +import 'package:kudlit_ph/features/home/presentation/widgets/butty_chat/baybayin_chat_renderer.dart'; + +const TextStyle _base = TextStyle(fontSize: 13.5, height: 1.5); + +Widget _wrap(Widget child) => MaterialApp( + home: Scaffold( + body: SizedBox(width: 300, child: child), + ), + ); + +void main() { + group('BaybayinChatRenderer — plain markdown', () { + testWidgets('renders text without tags as markdown', (tester) async { + await tester.pumpWidget( + _wrap( + const BaybayinChatRenderer( + text: 'Hello **world**', + baseStyle: _base, + ), + ), + ); + + expect(find.textContaining('Hello'), findsWidgets); + expect(tester.takeException(), isNull); + }); + + testWidgets('no Baybayin font when no tags present', (tester) async { + await tester.pumpWidget( + _wrap( + const BaybayinChatRenderer( + text: 'No baybayin here', + baseStyle: _base, + ), + ), + ); + + final Iterable texts = tester.widgetList(find.byType(Text)); + final bool hasBaybayinFont = texts.any( + (Text t) => t.style?.fontFamily == 'Baybayin Simple TAWBID', + ); + expect(hasBaybayinFont, isFalse); + expect(tester.takeException(), isNull); + }); + }); + + group('BaybayinChatRenderer — baybayin tags', () { + testWidgets('renders baybayin tag with correct font family', ( + tester, + ) async { + await tester.pumpWidget( + _wrap( + const BaybayinChatRenderer( + text: 'mahal', + baseStyle: _base, + ), + ), + ); + + final Text baybayinText = tester.widget( + find.byWidgetPredicate( + (Widget w) => + w is Text && w.style?.fontFamily == 'Baybayin Simple TAWBID', + ), + ); + // baybayifyWord('mahal') → 'mhl+' + expect(baybayinText.data, baybayifyWord('mahal')); + expect(tester.takeException(), isNull); + }); + + testWidgets('applies baybayifyWord encoding to tag content', ( + tester, + ) async { + const String input = 'salamat'; + await tester.pumpWidget( + _wrap( + BaybayinChatRenderer( + text: '$input', + baseStyle: _base, + ), + ), + ); + + final Text baybayinText = tester.widget( + find.byWidgetPredicate( + (Widget w) => + w is Text && w.style?.fontFamily == 'Baybayin Simple TAWBID', + ), + ); + expect(baybayinText.data, baybayifyWord(input)); + }); + + testWidgets('tag matching is case-insensitive', (tester) async { + await tester.pumpWidget( + _wrap( + const BaybayinChatRenderer( + text: 'anak', + baseStyle: _base, + ), + ), + ); + + expect( + find.byWidgetPredicate( + (Widget w) => + w is Text && w.style?.fontFamily == 'Baybayin Simple TAWBID', + ), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + }); + + group('BaybayinChatRenderer — mixed content', () { + testWidgets('renders both markdown and baybayin segments', (tester) async { + await tester.pumpWidget( + _wrap( + const BaybayinChatRenderer( + text: 'The word is mahal in Baybayin.', + baseStyle: _base, + ), + ), + ); + + expect( + find.byWidgetPredicate( + (Widget w) => + w is Text && w.style?.fontFamily == 'Baybayin Simple TAWBID', + ), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('renders multiple baybayin tags', (tester) async { + await tester.pumpWidget( + _wrap( + const BaybayinChatRenderer( + text: + 'mahal and salamat', + baseStyle: _base, + ), + ), + ); + + expect( + find.byWidgetPredicate( + (Widget w) => + w is Text && w.style?.fontFamily == 'Baybayin Simple TAWBID', + ), + findsNWidgets(2), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('ignores empty baybayin tags', (tester) async { + await tester.pumpWidget( + _wrap( + const BaybayinChatRenderer( + text: 'Before After', + baseStyle: _base, + ), + ), + ); + + expect( + find.byWidgetPredicate( + (Widget w) => + w is Text && w.style?.fontFamily == 'Baybayin Simple TAWBID', + ), + findsNothing, + ); + expect(tester.takeException(), isNull); + }); + }); + + group('BaybayinChatRenderer — font size', () { + testWidgets('baybayin text is larger than base font', (tester) async { + const double baseFontSize = 13.5; + await tester.pumpWidget( + _wrap( + const BaybayinChatRenderer( + text: 'ina', + baseStyle: TextStyle(fontSize: baseFontSize), + ), + ), + ); + + final Text baybayinText = tester.widget( + find.byWidgetPredicate( + (Widget w) => + w is Text && w.style?.fontFamily == 'Baybayin Simple TAWBID', + ), + ); + expect( + baybayinText.style!.fontSize, + greaterThan(baseFontSize), + ); + }); + }); +}