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 {