diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..aeff898 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,7 @@ +# Copilot Instructions + +## Commit authorship + +Never add yourself (Copilot) as a co-author in any commit message. +Do not include any `Co-authored-by: Copilot` trailer or any other +self-referential authorship line under any circumstance. diff --git a/lib/features/home/presentation/screens/model_setup_screen.dart b/lib/features/home/presentation/screens/model_setup_screen.dart index 390f752..9f06e27 100644 --- a/lib/features/home/presentation/screens/model_setup_screen.dart +++ b/lib/features/home/presentation/screens/model_setup_screen.dart @@ -96,6 +96,7 @@ class _SetupBackground extends StatelessWidget { @override Widget build(BuildContext context) { + final bool isDesktop = MediaQuery.sizeOf(context).width >= 900; return Stack( fit: StackFit.expand, children: [ @@ -112,28 +113,43 @@ class _SetupBackground extends StatelessWidget { ), // Faded Baybayin glyphs const BaybayinBackdrop(), - // Soft radial aura concentrated in the upper third (behind Butty) - Positioned( - top: -40, - left: 0, - right: 0, - child: Center( - child: Container( - width: 280, - height: 280, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: [ - KudlitColors.blue400.withAlpha(90), - Colors.transparent, - ], - ), - ), + // Soft radial aura — larger on desktop for a more dramatic backdrop + _AuraGlow( + size: isDesktop ? 520 : 280, + top: isDesktop ? -100 : -40, + ), + ], + ); + } +} + +class _AuraGlow extends StatelessWidget { + const _AuraGlow({required this.size, required this.top}); + + final double size; + final double top; + + @override + Widget build(BuildContext context) { + return Positioned( + top: top, + left: 0, + right: 0, + child: Center( + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + KudlitColors.blue400.withAlpha(90), + Colors.transparent, + ], ), ), ), - ], + ), ); } } @@ -156,8 +172,18 @@ class _ModelSetupBody extends StatelessWidget { return SafeArea( child: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { + final bool isDesktop = constraints.maxWidth >= 900; final bool landscape = constraints.maxWidth > constraints.maxHeight; - final bool shortPortrait = constraints.maxHeight < 680; + final bool shortPortrait = + !landscape && constraints.maxHeight < 680; + if (isDesktop) { + return _DesktopSetupLayout( + busy: busy, + onContinue: onContinue, + onSkip: onSkip, + errorMessage: errorMessage, + ); + } return landscape ? _LandscapeSetupLayout( busy: busy, @@ -184,8 +210,8 @@ class _ModelSetupBody extends StatelessWidget { } } -class _PortraitSetupLayout extends StatelessWidget { - const _PortraitSetupLayout({ +class _DesktopSetupLayout extends StatelessWidget { + const _DesktopSetupLayout({ required this.busy, required this.onContinue, required this.onSkip, @@ -197,29 +223,155 @@ class _PortraitSetupLayout extends StatelessWidget { final VoidCallback onSkip; final String? errorMessage; + @override + Widget build(BuildContext context) { + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 32), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 860), + child: Container( + padding: const EdgeInsets.fromLTRB(44, 48, 44, 48), + decoration: BoxDecoration( + color: KudlitColors.blue100, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: KudlitColors.blue300.withAlpha(90), + width: 1.5, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Expanded(flex: 5, child: _DesktopBranding()), + const _DesktopDivider(), + Expanded( + flex: 4, + child: _DesktopFormPanel( + busy: busy, + onContinue: onContinue, + onSkip: onSkip, + errorMessage: errorMessage, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _DesktopBranding extends StatelessWidget { + const _DesktopBranding(); + @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.only(right: 36), child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Spacer(flex: 2), - const _SetupHero(), - const SizedBox(height: 20), - const _SetupHeadline(), - const SizedBox(height: 18), - const _ModelDownloadsPanel(), + children: const [ + _SetupHero(height: 150), + SizedBox(height: 28), + _SetupHeadline(large: true), + ], + ), + ); + } +} + +class _DesktopFormPanel extends StatelessWidget { + const _DesktopFormPanel({ + required this.busy, + required this.onContinue, + required this.onSkip, + this.errorMessage, + }); + + final bool busy; + final VoidCallback onContinue; + final VoidCallback onSkip; + final String? errorMessage; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _ModelDownloadsPanel(), + const SizedBox(height: 14), + const _DownloadNotice(), + if (errorMessage != null) ...[ const SizedBox(height: 10), - const _DownloadNotice(), - if (errorMessage != null) ...[ - const SizedBox(height: 10), - _SetupErrorBanner(message: errorMessage!), - ], - const Spacer(flex: 3), - _SetupActions(busy: busy, onContinue: onContinue, onSkip: onSkip), - const SizedBox(height: 12), + _SetupErrorBanner(message: errorMessage!), ], + const SizedBox(height: 28), + _SetupActions(busy: busy, onContinue: onContinue, onSkip: onSkip), + ], + ); + } +} + +class _DesktopDivider extends StatelessWidget { + const _DesktopDivider(); + + @override + Widget build(BuildContext context) { + return Container( + width: 1, + height: 280, + margin: const EdgeInsets.only(right: 36), + color: KudlitColors.blue300.withAlpha(70), + ); + } +} + + +class _PortraitSetupLayout extends StatelessWidget { + const _PortraitSetupLayout({ + required this.busy, + required this.onContinue, + required this.onSkip, + this.errorMessage, + }); + + final bool busy; + final VoidCallback onContinue; + final VoidCallback onSkip; + final String? errorMessage; + + @override + Widget build(BuildContext context) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Spacer(flex: 2), + const _SetupHero(), + const SizedBox(height: 20), + const _SetupHeadline(), + const SizedBox(height: 18), + const _ModelDownloadsPanel(), + const SizedBox(height: 10), + const _DownloadNotice(), + if (errorMessage != null) ...[ + const SizedBox(height: 10), + _SetupErrorBanner(message: errorMessage!), + ], + const Spacer(flex: 3), + _SetupActions(busy: busy, onContinue: onContinue, onSkip: onSkip), + const SizedBox(height: 12), + ], + ), + ), ), ); } @@ -358,9 +510,10 @@ class _SetupHero extends StatelessWidget { } class _SetupHeadline extends StatelessWidget { - const _SetupHeadline({this.compact = false}); + const _SetupHeadline({this.compact = false, this.large = false}); final bool compact; + final bool large; @override Widget build(BuildContext context) { @@ -371,19 +524,19 @@ class _SetupHeadline extends StatelessWidget { 'Get ready to use Kudlit', style: TextStyle( color: KudlitColors.blue900, - fontSize: compact ? 24 : 28, + fontSize: large ? 32 : compact ? 24 : 28, fontWeight: FontWeight.w700, ), ), - SizedBox(height: compact ? 8 : 10), + SizedBox(height: large ? 14 : compact ? 8 : 10), Text( kIsWeb ? 'Set up the downloads Kudlit needs before you start.' : 'Download these once so key features can keep working even without internet.', style: TextStyle( color: KudlitColors.grey300, - fontSize: compact ? 13 : 15, - height: compact ? 1.35 : 1.55, + fontSize: large ? 16 : compact ? 13 : 15, + height: large ? 1.6 : compact ? 1.35 : 1.55, ), ), ], 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/home/presentation/widgets/settings/llm_download_tile.dart b/lib/features/home/presentation/widgets/settings/llm_download_tile.dart index 7031c18..4588613 100644 --- a/lib/features/home/presentation/widgets/settings/llm_download_tile.dart +++ b/lib/features/home/presentation/widgets/settings/llm_download_tile.dart @@ -8,8 +8,6 @@ import 'package:kudlit_ph/features/translator/presentation/providers/ai_inferenc import 'package:kudlit_ph/features/translator/presentation/providers/ai_inference_state.dart'; import 'package:kudlit_ph/features/translator/presentation/providers/translator_providers.dart'; -import 'profile_management_action_button.dart'; - class LlmDownloadTile extends ConsumerWidget { const LlmDownloadTile({super.key}); @@ -35,8 +33,8 @@ class LlmDownloadTile extends ConsumerWidget { children: [ const _TileHeader( icon: Icons.psychology_rounded, - label: 'Butty replies', - sublabel: 'Offline replies · large download', + label: 'Butty AI', + sublabel: 'Offline chat · large download', ), const SizedBox(height: 10), _LlmStatusRow( @@ -44,7 +42,8 @@ class LlmDownloadTile extends ConsumerWidget { prefsAsync: prefsAsync, readinessAsync: readinessAsync, onCancel: notifier.cancelDownload, - onTrigger: (GemmaModelInfo m) => notifier.triggerLocalDownload(m), + onTrigger: (GemmaModelInfo model) => + notifier.triggerLocalDownload(model), ), ], ), @@ -64,8 +63,8 @@ class _LlmStatusRow extends StatelessWidget { final AsyncValue stateAsync; final AsyncValue prefsAsync; final AsyncValue readinessAsync; - final void Function() onCancel; - final void Function(GemmaModelInfo) onTrigger; + final VoidCallback onCancel; + final void Function(GemmaModelInfo model) onTrigger; @override Widget build(BuildContext context) { @@ -98,7 +97,6 @@ class _LlmStatusRow extends StatelessWidget { ); } - final AppPreferences? prefs = prefsAsync.value; final LocalGemmaReadiness? readiness = readinessAsync.value; final GemmaModelInfo? activeModel = switch (state) { AiReady(:final GemmaModelInfo activeModel) => activeModel, @@ -106,106 +104,84 @@ class _LlmStatusRow extends StatelessWidget { _ => null, }; - if (prefs == null || readiness == null || activeModel == null) { + if (readiness == null || activeModel == null) { return const _CheckingRow(); } if (readiness.installed && readiness.usable) { - return _ReadyRow( - cloudMode: prefs.aiPreference == AiPreference.cloud, - note: prefs.aiPreference == AiPreference.cloud - ? 'Downloaded and ready whenever you switch to Offline mode.' - : 'Downloaded and ready to use without internet.', + return const _StatusRow(note: 'Downloaded'); + } + + if (readiness.installed) { + return _StatusRow( + note: 'Finishing setup…', + action: _CompactIconActionButton( + tooltip: 'Reload Butty AI', + icon: Icons.refresh_rounded, + onTap: () => onTrigger(activeModel), + ), ); } - return _ActionRow( - badge: _StatusBadge( - label: readiness.installed ? 'Almost ready' : 'Needs download', - ok: readiness.installed ? null : false, + return _StatusRow( + note: 'Download to use Butty offline.', + action: _CompactIconActionButton( + tooltip: 'Download Butty AI', + icon: Icons.download_rounded, + onTap: () => onTrigger(activeModel), + isPrimary: true, ), - primary: readiness.installed ? 'Reload' : 'Download', - onPrimary: () => onTrigger(activeModel), - note: readiness.installed - ? 'We are still getting this ready.' - : 'Download once to use Butty without internet.', ); } } -class _ReadyRow extends StatelessWidget { - const _ReadyRow({required this.cloudMode, this.note}); +class _StatusRow extends StatelessWidget { + const _StatusRow({this.note, this.action}); - final bool cloudMode; - final String? note; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - _StatusBadge( - label: cloudMode ? 'Downloaded' : 'Ready offline', - ok: true, - ), - ], - ), - if (note != null) ...[ - const SizedBox(height: 8), - Text( - note!, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurface.withAlpha(150), - ), - ), - ], - ], - ); - } -} - -class _ActionRow extends StatelessWidget { - const _ActionRow({ - required this.badge, - required this.primary, - required this.onPrimary, - this.note, - }); - - final Widget badge; - final String primary; - final VoidCallback onPrimary; final String? note; + final Widget? action; @override Widget build(BuildContext context) { final ColorScheme cs = Theme.of(context).colorScheme; + final Widget statusCopy = Text( + note ?? '', + style: TextStyle( + fontSize: 11, + height: 1.25, + color: cs.onSurface.withAlpha(150), + ), + ); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + if (constraints.maxWidth < 300) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + statusCopy, + if (action != null) ...[ + const SizedBox(height: 10), + Align(alignment: Alignment.centerLeft, child: action), + ], + ], + ); + } + + return Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 12, + runSpacing: 8, children: [ - badge, - const Spacer(), - ProfileManagementActionButton( - label: primary, - isPrimary: true, - onTap: onPrimary, + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 180, maxWidth: 260), + child: statusCopy, ), + if (action case final Widget compactAction) compactAction, ], - ), - if (note != null) ...[ - const SizedBox(height: 8), - Text( - note!, - style: TextStyle(fontSize: 11, color: cs.onSurface.withAlpha(150)), - ), - ], - ], + ); + }, ); } } @@ -228,20 +204,24 @@ class _ProgressRow extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 12, + runSpacing: 8, children: [ - Text( - 'Downloading… $progress%', - style: TextStyle(color: cs.primary, fontSize: 13), - ), - GestureDetector( - onTap: onCancel, + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 180, maxWidth: 260), child: Text( - 'Cancel', - style: TextStyle(color: cs.error, fontSize: 12), + 'Downloading… $progress%', + style: TextStyle(color: cs.primary, fontSize: 13), ), ), + _CompactIconActionButton( + tooltip: 'Cancel Butty AI download', + icon: Icons.close_rounded, + onTap: onCancel, + ), ], ), if (statusMessage != null) ...[ @@ -308,46 +288,53 @@ class _TileHeader extends StatelessWidget { } } -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 Color bg = ok == true - ? Colors.green.shade800.withAlpha(40) - : ok == false - ? Colors.red.shade800.withAlpha(40) - : Theme.of(context).colorScheme.surfaceContainerHigh; - final Color fg = ok == true - ? Colors.green.shade300 - : ok == false - ? Colors.red.shade300 - : Theme.of(context).colorScheme.onSurface.withAlpha(150); - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(20), + return Text( + 'Checking…', + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface.withAlpha(128), ), - child: Text(label, style: TextStyle(fontSize: 11, color: fg)), ); } } -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…', - 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 f56b3dc..1c8dc11 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'; - class VisionDownloadTile extends ConsumerStatefulWidget { const VisionDownloadTile({super.key}); @@ -152,24 +150,26 @@ class _VisionStatusRow extends ConsumerWidget { data: (VisionModelSetupStatus status) { if (status.ready) { return _VisionActionRow( - badge: const _StatusBadge(label: 'Ready to scan', ok: true), - supportingText: 'Downloaded and ready when you open the scanner.', - action: ProfileManagementActionButton( - label: 'Set up again', + supportingText: 'Downloaded', + action: _CompactIconActionButton( + tooltip: 'Refresh scanner model', + icon: Icons.refresh_rounded, onTap: onPrepare, ), ); } return _VisionActionRow( - badge: const _StatusBadge(label: 'Needs download', ok: false), supportingText: kIsWeb - ? 'Set this up once to use camera reading in this browser.' - : 'Download once before using camera reading.', - action: ProfileManagementActionButton( - label: 'Set up', - isPrimary: true, + ? status.message + : '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, ), ); }, @@ -178,13 +178,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; @@ -194,8 +189,6 @@ class _VisionActionRow extends StatelessWidget { final Widget statusCopy = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - badge, - const SizedBox(height: 6), Text( supportingText, style: TextStyle( @@ -334,44 +327,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/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/lib/features/scanner/presentation/widgets/scanner_camera.dart b/lib/features/scanner/presentation/widgets/scanner_camera.dart index 74e452a..f9010df 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({ @@ -574,15 +577,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, @@ -629,10 +627,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, @@ -661,14 +660,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( @@ -686,10 +682,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, @@ -700,6 +696,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), + ), + ), + ], ], ), ), @@ -731,7 +738,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, @@ -740,6 +828,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 4bdea55..103c475 100644 --- a/test/features/home/presentation/widgets/ai_models_section_test.dart +++ b/test/features/home/presentation/widgets/ai_models_section_test.dart @@ -1,26 +1,27 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/misc.dart' show Override; +import 'package:flutter_test/flutter_test.dart'; 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'; +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(() { SharedPreferences.setMockInitialValues({}); }); - testWidgets('offline downloads section frames setup in plain language', ( + testWidgets('offline downloads section keeps setup cards minimal', ( tester, ) async { await tester.binding.setSurfaceSize(const Size(360, 740)); @@ -45,15 +46,14 @@ void main() { ), findsOneWidget, ); - expect(find.text('Butty replies'), findsOneWidget); - expect(find.text('Offline replies · large download'), findsOneWidget); - expect(find.text('Needs download'), findsWidgets); + expect(find.text('Butty AI'), findsOneWidget); + expect(find.text('Offline chat · large download'), findsOneWidget); + expect(find.text('Downloaded'), findsWidgets); expect(find.text('KudVis-1-Turbo'), findsOneWidget); expect(find.text('Reads Baybayin with your camera'), findsOneWidget); - expect( - find.text('Download once before using camera reading.'), - findsOneWidget, - ); + expect(find.text('Download before using the scanner.'), findsOneWidget); + expect(find.text('Needs download'), findsNothing); + expect(find.text('Ready to scan'), findsNothing); expect(tester.takeException(), isNull); }); @@ -75,7 +75,7 @@ void main() { await tester.pump(); await tester.pump(); - await tester.tap(find.text('Set up')); + await tester.tap(find.byTooltip('Download scanner model')); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); @@ -128,9 +128,9 @@ void main() { await tester.pump(); expect(find.text('KudVis-Pro'), findsOneWidget); - expect(find.text('Ready to scan'), findsOneWidget); + expect(find.text('Downloaded'), findsWidgets); - await tester.tap(find.text('Set up again')); + await tester.tap(find.byTooltip('Refresh scanner model')); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); @@ -164,10 +164,10 @@ void main() { await tester.pump(); final Rect supportText = tester.getRect( - find.text('Download once before using camera reading.'), + find.text('Download before using the scanner.'), ); final Rect downloadButton = tester.getRect( - find.widgetWithText(ProfileManagementActionButton, 'Set up'), + find.byTooltip('Download scanner model'), ); expect(downloadButton.top, greaterThan(supportText.bottom)); @@ -204,8 +204,8 @@ void main() { await tester.pump(); expect(find.text('Use Kudlit offline'), findsOneWidget); - expect(find.text('Needs download'), findsWidgets); - expect(find.byType(ProfileManagementActionButton), findsWidgets); + expect(find.text('Needs download'), findsNothing); + expect(find.byType(IconButton), findsWidgets); expect(tester.takeException(), isNull); }); } @@ -231,6 +231,13 @@ List _modelOverrides( ]; }), aiInferenceNotifierProvider.overrideWith(_ReadyInferenceNotifier.new), + localModelReadinessProvider.overrideWith((Ref ref) async { + return const LocalGemmaReadiness( + installed: true, + usable: true, + detail: 'Downloaded', + ); + }), ]; } @@ -277,9 +284,8 @@ class _FakeYoloModelCache implements YoloModelCacheStore { void Function(int received, int total)? onProgress, }) async { downloadedIds.add(modelId); - onProgress?.call(1, 2); installed = true; - onProgress?.call(2, 2); + onProgress?.call(1, 1); return '/tmp/$modelId.tflite'; } 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), + ); + }); + }); +} 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 9200d3a..10a0428 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 {