Reubencf commited on
Commit
8dfd0f6
·
1 Parent(s): 71793e1

feat(mobile/phrases): parity with web — kanji, related words, furigana, TTS, progress bar

Browse files

- Extend PhraseAnalysis model with FuriSegment, KanjiInfo, RelatedWord
- Add FuriText widget (ruby-style readings stacked above kanji)
- Add neo-brutalist TtsButton (yellow idle, red speaking) via flutter_tts
- Render kanji breakdown grid and related words grid matching web styling
- Wire TTS into header, sentence cards, and related-word cards
- Animated progress bar replaces empty-state copy while loading
- Send level from profile alongside text in /api/phrases request

mobile/lib/features/phrases/phrases_screen.dart CHANGED
@@ -2,23 +2,34 @@ import 'package:flutter/material.dart';
2
  import 'package:flutter_riverpod/flutter_riverpod.dart';
3
  import 'package:google_fonts/google_fonts.dart';
4
 
 
5
  import '../../core/api_client.dart';
6
  import '../../theme/app_theme.dart';
 
 
7
 
8
  const _phraseFill = Color(0xFFE8F7F6);
9
  const _warningFill = Color(0xFFFEF3C7);
 
10
  const _examples = ['to find', 'I would like...', 'appointment', 'Can you help me?', 'because'];
11
 
12
  String _value(Map<String, dynamic> json, String key) => (json[key] ?? '').toString();
13
 
14
  class PhraseSentence {
15
- PhraseSentence({required this.target, required this.translation, required this.note});
 
 
 
 
 
16
  final String target;
 
17
  final String translation;
18
  final String note;
19
 
20
  factory PhraseSentence.fromJson(Map<String, dynamic> json) => PhraseSentence(
21
  target: _value(json, 'target'),
 
22
  translation: _value(json, 'translation'),
23
  note: _value(json, 'note'),
24
  );
@@ -35,27 +46,89 @@ class PhraseBreakdown {
35
  );
36
  }
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  class PhraseAnalysis {
39
  PhraseAnalysis({
40
  required this.input,
 
41
  required this.translation,
42
  required this.partOfSpeech,
43
  required this.verbInfo,
44
  required this.breakdown,
45
  required this.sentences,
46
  required this.tips,
 
 
47
  });
48
 
49
  final String input;
 
50
  final String translation;
51
  final String partOfSpeech;
52
  final String verbInfo;
53
  final List<PhraseBreakdown> breakdown;
54
  final List<PhraseSentence> sentences;
55
  final List<String> tips;
 
 
56
 
57
  factory PhraseAnalysis.fromJson(Map<String, dynamic> json) => PhraseAnalysis(
58
  input: _value(json, 'input'),
 
59
  translation: _value(json, 'translation'),
60
  partOfSpeech: _value(json, 'partOfSpeech').isEmpty ? 'phrase' : _value(json, 'partOfSpeech'),
61
  verbInfo: _value(json, 'verbInfo'),
@@ -68,6 +141,16 @@ class PhraseAnalysis {
68
  .map((item) => PhraseSentence.fromJson(Map<String, dynamic>.from(item)))
69
  .toList(),
70
  tips: ((json['tips'] as List?) ?? []).map((tip) => tip.toString()).where((tip) => tip.isNotEmpty).toList(),
 
 
 
 
 
 
 
 
 
 
71
  );
72
  }
73
 
@@ -104,8 +187,15 @@ class _PhrasesScreenState extends ConsumerState<PhrasesScreen> {
104
  });
105
 
106
  try {
 
107
  final api = ref.read(apiClientProvider);
108
- final res = await api.dio.post<Map<String, dynamic>>('/api/phrases', data: {'text': phrase});
 
 
 
 
 
 
109
  final data = res.data ?? const <String, dynamic>{};
110
  final ok = (res.statusCode ?? 500) >= 200 && (res.statusCode ?? 500) < 300;
111
  if (!mounted) return;
@@ -129,6 +219,7 @@ class _PhrasesScreenState extends ConsumerState<PhrasesScreen> {
129
 
130
  @override
131
  Widget build(BuildContext context) {
 
132
  return Scaffold(
133
  appBar: AppBar(toolbarHeight: 72, title: const Text('Phrases')),
134
  body: SafeArea(
@@ -148,7 +239,12 @@ class _PhrasesScreenState extends ConsumerState<PhrasesScreen> {
148
  onExample: _analyze,
149
  ),
150
  const SizedBox(height: 30),
151
- if (_analysis == null) const _EmptyState() else _PhraseResult(analysis: _analysis!),
 
 
 
 
 
152
  ],
153
  ),
154
  ),
@@ -244,7 +340,8 @@ class _InputPanel extends StatelessWidget {
244
  }
245
 
246
  class _EmptyState extends StatelessWidget {
247
- const _EmptyState();
 
248
 
249
  @override
250
  Widget build(BuildContext context) {
@@ -256,30 +353,106 @@ class _EmptyState extends StatelessWidget {
256
  Container(
257
  width: 62,
258
  height: 62,
259
- decoration: BoxDecoration(color: const Color(0xFFEDE9FE), borderRadius: BorderRadius.circular(20)),
260
- child: const Icon(Icons.edit_note_rounded, color: kSecondary, size: 34),
261
- ),
262
- const SizedBox(height: 16),
263
- Text(
264
- 'Enter a phrase to build a mini lesson',
265
- textAlign: TextAlign.center,
266
- style: GoogleFonts.almarai(color: kForeground, fontSize: 20, fontWeight: FontWeight.w900),
267
- ),
268
- const SizedBox(height: 6),
269
- Text(
270
- 'You will get example sentences, practical tips, and verb notes when they matter.',
271
- textAlign: TextAlign.center,
272
- style: GoogleFonts.almarai(color: kMuted, fontSize: 13, fontWeight: FontWeight.w800, height: 1.35),
273
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  ],
275
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  );
277
  }
278
  }
279
 
280
  class _PhraseResult extends StatelessWidget {
281
- const _PhraseResult({required this.analysis});
282
  final PhraseAnalysis analysis;
 
283
 
284
  @override
285
  Widget build(BuildContext context) {
@@ -293,14 +466,23 @@ class _PhraseResult extends StatelessWidget {
293
  child: Column(
294
  crossAxisAlignment: CrossAxisAlignment.start,
295
  children: [
296
- Text(
297
- analysis.partOfSpeech,
298
- style: GoogleFonts.almarai(color: kPrimary, fontSize: 13, fontWeight: FontWeight.w900),
 
 
 
 
 
 
 
299
  ),
300
- const SizedBox(height: 8),
301
- Text(
302
- analysis.input,
303
- style: GoogleFonts.almarai(color: kForeground, fontSize: 30, fontWeight: FontWeight.w900, height: 1.05),
 
 
304
  ),
305
  const SizedBox(height: 10),
306
  Text(
@@ -313,7 +495,23 @@ class _PhraseResult extends StatelessWidget {
313
  const SizedBox(height: 28),
314
  _SectionHeader(icon: Icons.forum_rounded, title: 'Example sentences'),
315
  const SizedBox(height: 12),
316
- ...analysis.sentences.map((sentence) => _SentenceCard(sentence: sentence)),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  const SizedBox(height: 28),
318
  _ResponsiveResultGrid(analysis: analysis),
319
  ],
@@ -322,8 +520,9 @@ class _PhraseResult extends StatelessWidget {
322
  }
323
 
324
  class _SentenceCard extends StatelessWidget {
325
- const _SentenceCard({required this.sentence});
326
  final PhraseSentence sentence;
 
327
 
328
  @override
329
  Widget build(BuildContext context) {
@@ -336,8 +535,22 @@ class _SentenceCard extends StatelessWidget {
336
  child: Column(
337
  crossAxisAlignment: CrossAxisAlignment.start,
338
  children: [
339
- Text(sentence.target, style: GoogleFonts.almarai(color: kForeground, fontSize: 18, fontWeight: FontWeight.w900)),
340
- const SizedBox(height: 6),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  Text(sentence.translation, style: GoogleFonts.almarai(color: kMuted, fontSize: 13, fontWeight: FontWeight.w800)),
342
  if (sentence.note.isNotEmpty) ...[
343
  const SizedBox(height: 8),
@@ -350,6 +563,197 @@ class _SentenceCard extends StatelessWidget {
350
  }
351
  }
352
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  class _ResponsiveResultGrid extends StatelessWidget {
354
  const _ResponsiveResultGrid({required this.analysis});
355
  final PhraseAnalysis analysis;
 
2
  import 'package:flutter_riverpod/flutter_riverpod.dart';
3
  import 'package:google_fonts/google_fonts.dart';
4
 
5
+ import '../../auth/auth_provider.dart';
6
  import '../../core/api_client.dart';
7
  import '../../theme/app_theme.dart';
8
+ import 'widgets/furi_text.dart';
9
+ import 'widgets/tts_button.dart';
10
 
11
  const _phraseFill = Color(0xFFE8F7F6);
12
  const _warningFill = Color(0xFFFEF3C7);
13
+ const _kanjiExampleFill = Color(0xFFE6FBFA);
14
  const _examples = ['to find', 'I would like...', 'appointment', 'Can you help me?', 'because'];
15
 
16
  String _value(Map<String, dynamic> json, String key) => (json[key] ?? '').toString();
17
 
18
  class PhraseSentence {
19
+ PhraseSentence({
20
+ required this.target,
21
+ required this.targetSegments,
22
+ required this.translation,
23
+ required this.note,
24
+ });
25
  final String target;
26
+ final List<FuriSegment>? targetSegments;
27
  final String translation;
28
  final String note;
29
 
30
  factory PhraseSentence.fromJson(Map<String, dynamic> json) => PhraseSentence(
31
  target: _value(json, 'target'),
32
+ targetSegments: parseFuriSegments(json['targetSegments']),
33
  translation: _value(json, 'translation'),
34
  note: _value(json, 'note'),
35
  );
 
46
  );
47
  }
48
 
49
+ class RelatedWord {
50
+ RelatedWord({
51
+ required this.word,
52
+ required this.wordSegments,
53
+ required this.translation,
54
+ required this.partOfSpeech,
55
+ required this.note,
56
+ });
57
+ final String word;
58
+ final List<FuriSegment>? wordSegments;
59
+ final String translation;
60
+ final String partOfSpeech;
61
+ final String note;
62
+
63
+ factory RelatedWord.fromJson(Map<String, dynamic> json) => RelatedWord(
64
+ word: _value(json, 'word'),
65
+ wordSegments: parseFuriSegments(json['wordSegments']),
66
+ translation: _value(json, 'translation'),
67
+ partOfSpeech: _value(json, 'partOfSpeech'),
68
+ note: _value(json, 'note'),
69
+ );
70
+ }
71
+
72
+ class KanjiInfo {
73
+ KanjiInfo({
74
+ required this.kanji,
75
+ required this.onyomi,
76
+ required this.kunyomi,
77
+ required this.meaning,
78
+ required this.exampleWord,
79
+ required this.exampleWordReading,
80
+ });
81
+ final String kanji;
82
+ final List<String> onyomi;
83
+ final List<String> kunyomi;
84
+ final String meaning;
85
+ final String exampleWord;
86
+ final String exampleWordReading;
87
+
88
+ factory KanjiInfo.fromJson(Map<String, dynamic> json) => KanjiInfo(
89
+ kanji: _value(json, 'kanji'),
90
+ onyomi: ((json['onyomi'] as List?) ?? [])
91
+ .map((e) => e.toString())
92
+ .where((e) => e.isNotEmpty)
93
+ .toList(),
94
+ kunyomi: ((json['kunyomi'] as List?) ?? [])
95
+ .map((e) => e.toString())
96
+ .where((e) => e.isNotEmpty)
97
+ .toList(),
98
+ meaning: _value(json, 'meaning'),
99
+ exampleWord: _value(json, 'exampleWord'),
100
+ exampleWordReading: _value(json, 'exampleWordReading'),
101
+ );
102
+ }
103
+
104
  class PhraseAnalysis {
105
  PhraseAnalysis({
106
  required this.input,
107
+ required this.inputSegments,
108
  required this.translation,
109
  required this.partOfSpeech,
110
  required this.verbInfo,
111
  required this.breakdown,
112
  required this.sentences,
113
  required this.tips,
114
+ required this.relatedWords,
115
+ required this.kanjiInfo,
116
  });
117
 
118
  final String input;
119
+ final List<FuriSegment>? inputSegments;
120
  final String translation;
121
  final String partOfSpeech;
122
  final String verbInfo;
123
  final List<PhraseBreakdown> breakdown;
124
  final List<PhraseSentence> sentences;
125
  final List<String> tips;
126
+ final List<RelatedWord> relatedWords;
127
+ final List<KanjiInfo> kanjiInfo;
128
 
129
  factory PhraseAnalysis.fromJson(Map<String, dynamic> json) => PhraseAnalysis(
130
  input: _value(json, 'input'),
131
+ inputSegments: parseFuriSegments(json['inputSegments']),
132
  translation: _value(json, 'translation'),
133
  partOfSpeech: _value(json, 'partOfSpeech').isEmpty ? 'phrase' : _value(json, 'partOfSpeech'),
134
  verbInfo: _value(json, 'verbInfo'),
 
141
  .map((item) => PhraseSentence.fromJson(Map<String, dynamic>.from(item)))
142
  .toList(),
143
  tips: ((json['tips'] as List?) ?? []).map((tip) => tip.toString()).where((tip) => tip.isNotEmpty).toList(),
144
+ relatedWords: ((json['relatedWords'] as List?) ?? [])
145
+ .whereType<Map>()
146
+ .map((item) => RelatedWord.fromJson(Map<String, dynamic>.from(item)))
147
+ .where((w) => w.word.isNotEmpty)
148
+ .toList(),
149
+ kanjiInfo: ((json['kanjiInfo'] as List?) ?? [])
150
+ .whereType<Map>()
151
+ .map((item) => KanjiInfo.fromJson(Map<String, dynamic>.from(item)))
152
+ .where((k) => k.kanji.isNotEmpty && (k.onyomi.isNotEmpty || k.kunyomi.isNotEmpty))
153
+ .toList(),
154
  );
155
  }
156
 
 
187
  });
188
 
189
  try {
190
+ final profile = ref.read(authControllerProvider).profile;
191
  final api = ref.read(apiClientProvider);
192
+ final res = await api.dio.post<Map<String, dynamic>>(
193
+ '/api/phrases',
194
+ data: {
195
+ 'text': phrase,
196
+ if (profile != null) 'level': profile.level,
197
+ },
198
+ );
199
  final data = res.data ?? const <String, dynamic>{};
200
  final ok = (res.statusCode ?? 500) >= 200 && (res.statusCode ?? 500) < 300;
201
  if (!mounted) return;
 
219
 
220
  @override
221
  Widget build(BuildContext context) {
222
+ final lang = ref.watch(authControllerProvider).profile?.targetLang ?? 'en';
223
  return Scaffold(
224
  appBar: AppBar(toolbarHeight: 72, title: const Text('Phrases')),
225
  body: SafeArea(
 
239
  onExample: _analyze,
240
  ),
241
  const SizedBox(height: 30),
242
+ if (_loading)
243
+ const _EmptyState(isPending: true)
244
+ else if (_analysis == null)
245
+ const _EmptyState()
246
+ else
247
+ _PhraseResult(analysis: _analysis!, lang: lang),
248
  ],
249
  ),
250
  ),
 
340
  }
341
 
342
  class _EmptyState extends StatelessWidget {
343
+ const _EmptyState({this.isPending = false});
344
+ final bool isPending;
345
 
346
  @override
347
  Widget build(BuildContext context) {
 
353
  Container(
354
  width: 62,
355
  height: 62,
356
+ decoration: BoxDecoration(
357
+ color: kBrutalYellow,
358
+ borderRadius: BorderRadius.circular(16),
359
+ border: Border.all(color: kBrutalBlack, width: 2),
360
+ boxShadow: const [
361
+ BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0),
362
+ ],
363
+ ),
364
+ child: const Icon(Icons.edit_note_rounded, color: kBrutalBlack, size: 34),
 
 
 
 
 
365
  ),
366
+ const SizedBox(height: 20),
367
+ if (isPending) ...[
368
+ Text(
369
+ 'Building your mini lesson...',
370
+ textAlign: TextAlign.center,
371
+ style: GoogleFonts.almarai(color: kForeground, fontSize: 18, fontWeight: FontWeight.w900),
372
+ ),
373
+ const SizedBox(height: 14),
374
+ const _BrutalProgressBar(),
375
+ ] else
376
+ Text(
377
+ 'Enter a phrase to build a mini lesson',
378
+ textAlign: TextAlign.center,
379
+ style: GoogleFonts.almarai(color: kForeground, fontSize: 20, fontWeight: FontWeight.w900),
380
+ ),
381
+ ],
382
+ ),
383
+ );
384
+ }
385
+ }
386
+
387
+ class _BrutalProgressBar extends StatefulWidget {
388
+ const _BrutalProgressBar();
389
+
390
+ @override
391
+ State<_BrutalProgressBar> createState() => _BrutalProgressBarState();
392
+ }
393
+
394
+ class _BrutalProgressBarState extends State<_BrutalProgressBar> with SingleTickerProviderStateMixin {
395
+ late final AnimationController _ctrl;
396
+
397
+ @override
398
+ void initState() {
399
+ super.initState();
400
+ _ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1400))..repeat();
401
+ }
402
+
403
+ @override
404
+ void dispose() {
405
+ _ctrl.dispose();
406
+ super.dispose();
407
+ }
408
+
409
+ @override
410
+ Widget build(BuildContext context) {
411
+ return Container(
412
+ height: 22,
413
+ decoration: BoxDecoration(
414
+ color: kBrutalWhite,
415
+ borderRadius: BorderRadius.circular(999),
416
+ border: Border.all(color: kBrutalBlack, width: 2),
417
+ boxShadow: const [
418
+ BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0),
419
  ],
420
  ),
421
+ child: ClipRRect(
422
+ borderRadius: BorderRadius.circular(999),
423
+ child: LayoutBuilder(
424
+ builder: (context, constraints) {
425
+ final width = constraints.maxWidth;
426
+ final segmentWidth = width * 0.33;
427
+ return AnimatedBuilder(
428
+ animation: _ctrl,
429
+ builder: (context, _) {
430
+ final t = _ctrl.value;
431
+ final left = -segmentWidth + (width + segmentWidth) * t;
432
+ return Stack(
433
+ children: [
434
+ Positioned(
435
+ left: left,
436
+ top: 0,
437
+ bottom: 0,
438
+ width: segmentWidth,
439
+ child: Container(color: const Color(0xFF0EA5A4)),
440
+ ),
441
+ ],
442
+ );
443
+ },
444
+ );
445
+ },
446
+ ),
447
+ ),
448
  );
449
  }
450
  }
451
 
452
  class _PhraseResult extends StatelessWidget {
453
+ const _PhraseResult({required this.analysis, required this.lang});
454
  final PhraseAnalysis analysis;
455
+ final String lang;
456
 
457
  @override
458
  Widget build(BuildContext context) {
 
466
  child: Column(
467
  crossAxisAlignment: CrossAxisAlignment.start,
468
  children: [
469
+ Row(
470
+ children: [
471
+ Expanded(
472
+ child: Text(
473
+ analysis.partOfSpeech,
474
+ style: GoogleFonts.almarai(color: kPrimary, fontSize: 13, fontWeight: FontWeight.w900),
475
+ ),
476
+ ),
477
+ TtsButton(text: analysis.input, lang: lang, size: 40),
478
+ ],
479
  ),
480
+ const SizedBox(height: 10),
481
+ FuriText(
482
+ text: analysis.input,
483
+ segments: analysis.inputSegments,
484
+ fontSize: 30,
485
+ fontWeight: FontWeight.w900,
486
  ),
487
  const SizedBox(height: 10),
488
  Text(
 
495
  const SizedBox(height: 28),
496
  _SectionHeader(icon: Icons.forum_rounded, title: 'Example sentences'),
497
  const SizedBox(height: 12),
498
+ ...analysis.sentences.map((sentence) => _SentenceCard(sentence: sentence, lang: lang)),
499
+ if (analysis.kanjiInfo.isNotEmpty) ...[
500
+ const SizedBox(height: 28),
501
+ _SectionHeader(icon: Icons.translate_rounded, title: 'Kanji breakdown'),
502
+ const SizedBox(height: 12),
503
+ _CardGrid(
504
+ children: analysis.kanjiInfo.map((k) => _KanjiCard(kanji: k)).toList(),
505
+ ),
506
+ ],
507
+ if (analysis.relatedWords.isNotEmpty) ...[
508
+ const SizedBox(height: 28),
509
+ _SectionHeader(icon: Icons.menu_book_rounded, title: 'Related words'),
510
+ const SizedBox(height: 12),
511
+ _CardGrid(
512
+ children: analysis.relatedWords.map((w) => _RelatedWordCard(word: w, lang: lang)).toList(),
513
+ ),
514
+ ],
515
  const SizedBox(height: 28),
516
  _ResponsiveResultGrid(analysis: analysis),
517
  ],
 
520
  }
521
 
522
  class _SentenceCard extends StatelessWidget {
523
+ const _SentenceCard({required this.sentence, required this.lang});
524
  final PhraseSentence sentence;
525
+ final String lang;
526
 
527
  @override
528
  Widget build(BuildContext context) {
 
535
  child: Column(
536
  crossAxisAlignment: CrossAxisAlignment.start,
537
  children: [
538
+ Row(
539
+ crossAxisAlignment: CrossAxisAlignment.start,
540
+ children: [
541
+ Expanded(
542
+ child: FuriText(
543
+ text: sentence.target,
544
+ segments: sentence.targetSegments,
545
+ fontSize: 18,
546
+ fontWeight: FontWeight.w900,
547
+ ),
548
+ ),
549
+ const SizedBox(width: 8),
550
+ TtsButton(text: sentence.target, lang: lang),
551
+ ],
552
+ ),
553
+ const SizedBox(height: 8),
554
  Text(sentence.translation, style: GoogleFonts.almarai(color: kMuted, fontSize: 13, fontWeight: FontWeight.w800)),
555
  if (sentence.note.isNotEmpty) ...[
556
  const SizedBox(height: 8),
 
563
  }
564
  }
565
 
566
+ class _CardGrid extends StatelessWidget {
567
+ const _CardGrid({required this.children});
568
+ final List<Widget> children;
569
+
570
+ @override
571
+ Widget build(BuildContext context) {
572
+ return LayoutBuilder(
573
+ builder: (context, constraints) {
574
+ final columns = constraints.maxWidth >= 900
575
+ ? 3
576
+ : constraints.maxWidth >= 560
577
+ ? 2
578
+ : 1;
579
+ const spacing = 12.0;
580
+ final totalSpacing = spacing * (columns - 1);
581
+ final colWidth = (constraints.maxWidth - totalSpacing) / columns;
582
+ return Wrap(
583
+ spacing: spacing,
584
+ runSpacing: spacing,
585
+ children: children
586
+ .map((c) => SizedBox(width: colWidth, child: c))
587
+ .toList(),
588
+ );
589
+ },
590
+ );
591
+ }
592
+ }
593
+
594
+ class _KanjiCard extends StatelessWidget {
595
+ const _KanjiCard({required this.kanji});
596
+ final KanjiInfo kanji;
597
+
598
+ @override
599
+ Widget build(BuildContext context) {
600
+ return Container(
601
+ padding: const EdgeInsets.all(16),
602
+ decoration: brutalCard(offset: 4),
603
+ child: Column(
604
+ crossAxisAlignment: CrossAxisAlignment.start,
605
+ children: [
606
+ Row(
607
+ crossAxisAlignment: CrossAxisAlignment.end,
608
+ children: [
609
+ Text(kanji.kanji, style: GoogleFonts.almarai(color: kForeground, fontSize: 40, fontWeight: FontWeight.w900, height: 1.0)),
610
+ const SizedBox(width: 12),
611
+ Expanded(
612
+ child: Padding(
613
+ padding: const EdgeInsets.only(bottom: 4),
614
+ child: Text(
615
+ kanji.meaning,
616
+ style: GoogleFonts.almarai(color: kMuted, fontSize: 13, fontWeight: FontWeight.w800),
617
+ ),
618
+ ),
619
+ ),
620
+ ],
621
+ ),
622
+ const SizedBox(height: 14),
623
+ _readingRow(label: 'On', fill: kBrutalYellow, labelColor: kBrutalBlack, readings: kanji.onyomi),
624
+ const SizedBox(height: 8),
625
+ _readingRow(label: 'Kun', fill: const Color(0xFF0EA5A4), labelColor: kBrutalWhite, readings: kanji.kunyomi),
626
+ if (kanji.exampleWord.isNotEmpty) ...[
627
+ const SizedBox(height: 14),
628
+ Container(
629
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
630
+ decoration: BoxDecoration(
631
+ color: _kanjiExampleFill,
632
+ borderRadius: BorderRadius.circular(10),
633
+ border: Border.all(color: kBrutalBlack, width: 2),
634
+ boxShadow: const [
635
+ BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0),
636
+ ],
637
+ ),
638
+ child: FuriText(
639
+ text: kanji.exampleWord,
640
+ segments: kanji.exampleWordReading.isNotEmpty
641
+ ? [FuriSegment(text: kanji.exampleWord, reading: kanji.exampleWordReading)]
642
+ : null,
643
+ fontSize: 16,
644
+ fontWeight: FontWeight.w900,
645
+ ),
646
+ ),
647
+ ],
648
+ ],
649
+ ),
650
+ );
651
+ }
652
+
653
+ Widget _readingRow({required String label, required Color fill, required Color labelColor, required List<String> readings}) {
654
+ return Row(
655
+ crossAxisAlignment: CrossAxisAlignment.center,
656
+ children: [
657
+ Container(
658
+ width: 48,
659
+ padding: const EdgeInsets.symmetric(vertical: 4),
660
+ decoration: BoxDecoration(
661
+ color: fill,
662
+ borderRadius: BorderRadius.circular(6),
663
+ border: Border.all(color: kBrutalBlack, width: 2),
664
+ boxShadow: const [
665
+ BoxShadow(color: kBrutalBlack, offset: Offset(2, 2), blurRadius: 0),
666
+ ],
667
+ ),
668
+ alignment: Alignment.center,
669
+ child: Text(
670
+ label,
671
+ style: GoogleFonts.almarai(color: labelColor, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.5),
672
+ ),
673
+ ),
674
+ const SizedBox(width: 10),
675
+ Expanded(
676
+ child: Text(
677
+ readings.isEmpty ? '—' : readings.join('、'),
678
+ style: GoogleFonts.almarai(
679
+ color: readings.isEmpty ? const Color(0xFFD1D5DB) : kBrutalBlack,
680
+ fontSize: 18,
681
+ fontWeight: FontWeight.w900,
682
+ ),
683
+ ),
684
+ ),
685
+ ],
686
+ );
687
+ }
688
+ }
689
+
690
+ class _RelatedWordCard extends StatelessWidget {
691
+ const _RelatedWordCard({required this.word, required this.lang});
692
+ final RelatedWord word;
693
+ final String lang;
694
+
695
+ @override
696
+ Widget build(BuildContext context) {
697
+ return Container(
698
+ padding: const EdgeInsets.all(16),
699
+ decoration: brutalCard(offset: 4),
700
+ child: Column(
701
+ crossAxisAlignment: CrossAxisAlignment.start,
702
+ children: [
703
+ Row(
704
+ crossAxisAlignment: CrossAxisAlignment.start,
705
+ children: [
706
+ Expanded(
707
+ child: FuriText(
708
+ text: word.word,
709
+ segments: word.wordSegments,
710
+ fontSize: 20,
711
+ fontWeight: FontWeight.w900,
712
+ ),
713
+ ),
714
+ const SizedBox(width: 8),
715
+ Column(
716
+ crossAxisAlignment: CrossAxisAlignment.end,
717
+ children: [
718
+ if (word.partOfSpeech.isNotEmpty)
719
+ Container(
720
+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
721
+ margin: const EdgeInsets.only(bottom: 6),
722
+ decoration: BoxDecoration(
723
+ color: kBrutalYellow,
724
+ borderRadius: BorderRadius.circular(6),
725
+ border: Border.all(color: kBrutalBlack, width: 1.5),
726
+ boxShadow: const [
727
+ BoxShadow(color: kBrutalBlack, offset: Offset(1, 1), blurRadius: 0),
728
+ ],
729
+ ),
730
+ child: Text(
731
+ word.partOfSpeech.toUpperCase(),
732
+ style: GoogleFonts.almarai(
733
+ color: kBrutalBlack,
734
+ fontSize: 9,
735
+ fontWeight: FontWeight.w900,
736
+ letterSpacing: 0.5,
737
+ ),
738
+ ),
739
+ ),
740
+ TtsButton(text: word.word, lang: lang, size: 30),
741
+ ],
742
+ ),
743
+ ],
744
+ ),
745
+ const SizedBox(height: 12),
746
+ Text(word.translation, style: GoogleFonts.almarai(color: const Color(0xFF374151), fontSize: 15, fontWeight: FontWeight.w800)),
747
+ if (word.note.isNotEmpty) ...[
748
+ const SizedBox(height: 8),
749
+ Text(word.note, style: GoogleFonts.almarai(color: const Color(0xFF6B7280), fontSize: 13, fontWeight: FontWeight.w900)),
750
+ ],
751
+ ],
752
+ ),
753
+ );
754
+ }
755
+ }
756
+
757
  class _ResponsiveResultGrid extends StatelessWidget {
758
  const _ResponsiveResultGrid({required this.analysis});
759
  final PhraseAnalysis analysis;
mobile/lib/features/phrases/widgets/furi_text.dart ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'package:flutter/material.dart';
2
+ import 'package:google_fonts/google_fonts.dart';
3
+
4
+ import '../../../theme/app_theme.dart';
5
+
6
+ class FuriSegment {
7
+ const FuriSegment({required this.text, this.reading});
8
+ final String text;
9
+ final String? reading;
10
+
11
+ factory FuriSegment.fromJson(Map<String, dynamic> json) => FuriSegment(
12
+ text: (json['text'] ?? '').toString(),
13
+ reading: (json['reading'] is String && (json['reading'] as String).isNotEmpty)
14
+ ? json['reading'] as String
15
+ : null,
16
+ );
17
+ }
18
+
19
+ List<FuriSegment>? parseFuriSegments(dynamic raw) {
20
+ if (raw is! List) return null;
21
+ final segments = <FuriSegment>[];
22
+ for (final item in raw) {
23
+ if (item is Map) {
24
+ final seg = FuriSegment.fromJson(Map<String, dynamic>.from(item));
25
+ if (seg.text.isNotEmpty) segments.add(seg);
26
+ }
27
+ }
28
+ return segments.isEmpty ? null : segments;
29
+ }
30
+
31
+ class FuriText extends StatelessWidget {
32
+ const FuriText({
33
+ super.key,
34
+ required this.text,
35
+ this.segments,
36
+ required this.fontSize,
37
+ this.color = kBrutalBlack,
38
+ this.fontWeight = FontWeight.w900,
39
+ this.readingColor = kBrutalMuted,
40
+ });
41
+
42
+ final String text;
43
+ final List<FuriSegment>? segments;
44
+ final double fontSize;
45
+ final Color color;
46
+ final FontWeight fontWeight;
47
+ final Color readingColor;
48
+
49
+ @override
50
+ Widget build(BuildContext context) {
51
+ final segs = segments;
52
+ final mainStyle = GoogleFonts.almarai(
53
+ fontSize: fontSize,
54
+ fontWeight: fontWeight,
55
+ color: color,
56
+ height: 1.0,
57
+ );
58
+
59
+ if (segs == null || segs.isEmpty) {
60
+ return Text(text, style: mainStyle);
61
+ }
62
+
63
+ final readingSize = (fontSize * 0.45).clamp(9.0, 18.0);
64
+ final readingStyle = GoogleFonts.almarai(
65
+ fontSize: readingSize,
66
+ fontWeight: FontWeight.w800,
67
+ color: readingColor,
68
+ height: 1.0,
69
+ );
70
+ final readingSlotHeight = readingSize + 2;
71
+
72
+ return Wrap(
73
+ crossAxisAlignment: WrapCrossAlignment.end,
74
+ children: segs
75
+ .map(
76
+ (seg) => Column(
77
+ mainAxisSize: MainAxisSize.min,
78
+ crossAxisAlignment: CrossAxisAlignment.center,
79
+ children: [
80
+ SizedBox(
81
+ height: readingSlotHeight,
82
+ child: seg.reading != null
83
+ ? Center(child: Text(seg.reading!, style: readingStyle))
84
+ : null,
85
+ ),
86
+ Text(seg.text, style: mainStyle),
87
+ ],
88
+ ),
89
+ )
90
+ .toList(),
91
+ );
92
+ }
93
+ }
mobile/lib/features/phrases/widgets/tts_button.dart ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'package:flutter/material.dart';
2
+ import 'package:flutter_tts/flutter_tts.dart';
3
+
4
+ import '../../../theme/app_theme.dart';
5
+
6
+ const _ttsLangMap = <String, String>{
7
+ 'en': 'en-US',
8
+ 'es': 'es-ES',
9
+ 'fr': 'fr-FR',
10
+ 'de': 'de-DE',
11
+ 'it': 'it-IT',
12
+ 'pt': 'pt-BR',
13
+ 'ja': 'ja-JP',
14
+ 'ko': 'ko-KR',
15
+ 'zh': 'zh-CN',
16
+ 'yue': 'zh-HK',
17
+ 'ar': 'ar-SA',
18
+ 'hi': 'hi-IN',
19
+ 'kn': 'kn-IN',
20
+ 'ml': 'ml-IN',
21
+ 'vi': 'vi-VN',
22
+ 'ru': 'ru-RU',
23
+ };
24
+
25
+ String ttsLang(String code) => _ttsLangMap[code] ?? code;
26
+
27
+ class TtsButton extends StatefulWidget {
28
+ const TtsButton({super.key, required this.text, required this.lang, this.size = 36});
29
+
30
+ final String text;
31
+ final String lang;
32
+ final double size;
33
+
34
+ @override
35
+ State<TtsButton> createState() => _TtsButtonState();
36
+ }
37
+
38
+ class _TtsButtonState extends State<TtsButton> {
39
+ late final FlutterTts _tts;
40
+ bool _speaking = false;
41
+ bool _pressed = false;
42
+
43
+ @override
44
+ void initState() {
45
+ super.initState();
46
+ _tts = FlutterTts();
47
+ _tts.setCompletionHandler(() {
48
+ if (mounted) setState(() => _speaking = false);
49
+ });
50
+ _tts.setCancelHandler(() {
51
+ if (mounted) setState(() => _speaking = false);
52
+ });
53
+ _tts.setErrorHandler((_) {
54
+ if (mounted) setState(() => _speaking = false);
55
+ });
56
+ }
57
+
58
+ @override
59
+ void dispose() {
60
+ _tts.stop();
61
+ super.dispose();
62
+ }
63
+
64
+ Future<void> _toggle() async {
65
+ if (_speaking) {
66
+ await _tts.stop();
67
+ if (mounted) setState(() => _speaking = false);
68
+ return;
69
+ }
70
+ await _tts.stop();
71
+ await _tts.setLanguage(ttsLang(widget.lang));
72
+ await _tts.setSpeechRate(0.45);
73
+ setState(() => _speaking = true);
74
+ final result = await _tts.speak(widget.text);
75
+ if (result != 1 && mounted) setState(() => _speaking = false);
76
+ }
77
+
78
+ @override
79
+ Widget build(BuildContext context) {
80
+ final offset = _pressed ? 1.0 : 2.0;
81
+ final fill = _speaking ? const Color(0xFFFF8080) : kBrutalYellow;
82
+ return GestureDetector(
83
+ onTapDown: (_) => setState(() => _pressed = true),
84
+ onTapUp: (_) => setState(() => _pressed = false),
85
+ onTapCancel: () => setState(() => _pressed = false),
86
+ onTap: _toggle,
87
+ child: AnimatedContainer(
88
+ duration: const Duration(milliseconds: 80),
89
+ width: widget.size,
90
+ height: widget.size,
91
+ transform: Matrix4.translationValues(_pressed ? 1 : 0, _pressed ? 1 : 0, 0),
92
+ decoration: BoxDecoration(
93
+ color: fill,
94
+ borderRadius: BorderRadius.circular(8),
95
+ border: Border.all(color: kBrutalBlack, width: 2),
96
+ boxShadow: [
97
+ BoxShadow(color: kBrutalBlack, offset: Offset(offset, offset), blurRadius: 0),
98
+ ],
99
+ ),
100
+ child: Icon(
101
+ _speaking ? Icons.stop_rounded : Icons.volume_up_rounded,
102
+ color: kBrutalBlack,
103
+ size: widget.size * 0.55,
104
+ ),
105
+ ),
106
+ );
107
+ }
108
+ }
mobile/pubspec.lock CHANGED
@@ -89,6 +89,14 @@ packages:
89
  url: "https://pub.dev"
90
  source: hosted
91
  version: "3.0.7"
 
 
 
 
 
 
 
 
92
  cupertino_icons:
93
  dependency: "direct main"
94
  description:
@@ -259,6 +267,14 @@ packages:
259
  description: flutter
260
  source: sdk
261
  version: "0.0.0"
 
 
 
 
 
 
 
 
262
  flutter_web_auth_2:
263
  dependency: "direct main"
264
  description:
@@ -312,6 +328,14 @@ packages:
312
  url: "https://pub.dev"
313
  source: hosted
314
  version: "1.0.3"
 
 
 
 
 
 
 
 
315
  http:
316
  dependency: transitive
317
  description:
@@ -773,6 +797,46 @@ packages:
773
  url: "https://pub.dev"
774
  source: hosted
775
  version: "2.2.0"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
776
  vm_service:
777
  dependency: transitive
778
  description:
 
89
  url: "https://pub.dev"
90
  source: hosted
91
  version: "3.0.7"
92
+ csslib:
93
+ dependency: transitive
94
+ description:
95
+ name: csslib
96
+ sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
97
+ url: "https://pub.dev"
98
+ source: hosted
99
+ version: "1.0.2"
100
  cupertino_icons:
101
  dependency: "direct main"
102
  description:
 
267
  description: flutter
268
  source: sdk
269
  version: "0.0.0"
270
+ flutter_tts:
271
+ dependency: "direct main"
272
+ description:
273
+ name: flutter_tts
274
+ sha256: ce5eb209b40e95f2f4a1397116c87ab2fcdff32257d04ed7a764e75894c03775
275
+ url: "https://pub.dev"
276
+ source: hosted
277
+ version: "4.2.5"
278
  flutter_web_auth_2:
279
  dependency: "direct main"
280
  description:
 
328
  url: "https://pub.dev"
329
  source: hosted
330
  version: "1.0.3"
331
+ html:
332
+ dependency: transitive
333
+ description:
334
+ name: html
335
+ sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
336
+ url: "https://pub.dev"
337
+ source: hosted
338
+ version: "0.15.6"
339
  http:
340
  dependency: transitive
341
  description:
 
797
  url: "https://pub.dev"
798
  source: hosted
799
  version: "2.2.0"
800
+ video_player:
801
+ dependency: "direct main"
802
+ description:
803
+ name: video_player
804
+ sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f"
805
+ url: "https://pub.dev"
806
+ source: hosted
807
+ version: "2.11.1"
808
+ video_player_android:
809
+ dependency: transitive
810
+ description:
811
+ name: video_player_android
812
+ sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0"
813
+ url: "https://pub.dev"
814
+ source: hosted
815
+ version: "2.9.5"
816
+ video_player_avfoundation:
817
+ dependency: transitive
818
+ description:
819
+ name: video_player_avfoundation
820
+ sha256: a39d6f28f8069564d8cc17396472f958dd9eaddf2d5c8e90aad4d793ac369bf3
821
+ url: "https://pub.dev"
822
+ source: hosted
823
+ version: "2.9.6"
824
+ video_player_platform_interface:
825
+ dependency: transitive
826
+ description:
827
+ name: video_player_platform_interface
828
+ sha256: "16eaed5268c571c31840dc58ef8da5f0cd4db2a98490c3b8f1cf70122546c6e0"
829
+ url: "https://pub.dev"
830
+ source: hosted
831
+ version: "6.7.0"
832
+ video_player_web:
833
+ dependency: transitive
834
+ description:
835
+ name: video_player_web
836
+ sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
837
+ url: "https://pub.dev"
838
+ source: hosted
839
+ version: "2.4.0"
840
  vm_service:
841
  dependency: transitive
842
  description:
mobile/pubspec.yaml CHANGED
@@ -18,6 +18,8 @@ dependencies:
18
  image_picker: ^1.1.2
19
  google_fonts: ^6.2.1
20
  country_flags: ^4.1.2
 
 
21
 
22
  dev_dependencies:
23
  flutter_test:
 
18
  image_picker: ^1.1.2
19
  google_fonts: ^6.2.1
20
  country_flags: ^4.1.2
21
+ video_player: ^2.9.2
22
+ flutter_tts: ^4.2.0
23
 
24
  dev_dependencies:
25
  flutter_test: