Reubencf commited on
Commit
4ad1d9e
·
1 Parent(s): 0b8da51

need to port to IOS and fix many bugs

Browse files
.claude/settings.local.json CHANGED
@@ -38,7 +38,15 @@
38
  "mcp__claude_ai_Hugging_Face__hub_repo_search",
39
  "Bash(curl -s -o NUL -w \"practice %{http_code}\\\\n\" http://localhost:3000/practice)",
40
  "Bash(curl -s -o NUL -w \"phrases %{http_code}\\\\n\" http://localhost:3000/phrases)",
41
- "Bash(curl -s -o NUL -w \"camera %{http_code}\\\\n\" http://localhost:3000/camera)"
 
 
 
 
 
 
 
 
42
  ]
43
  },
44
  "outputStyle": "default"
 
38
  "mcp__claude_ai_Hugging_Face__hub_repo_search",
39
  "Bash(curl -s -o NUL -w \"practice %{http_code}\\\\n\" http://localhost:3000/practice)",
40
  "Bash(curl -s -o NUL -w \"phrases %{http_code}\\\\n\" http://localhost:3000/phrases)",
41
+ "Bash(curl -s -o NUL -w \"camera %{http_code}\\\\n\" http://localhost:3000/camera)",
42
+ "Bash(npx --no -- tsc --noEmit --jsx preserve --esModuleInterop --skipLibCheck app/onboarding/OnboardingForm.tsx)",
43
+ "Bash(Get-ChildItem -Path \"E:\\\\language-learner\\\\mobile\\\\lib\" -Recurse -File)",
44
+ "Bash(Sort-Object FullName)",
45
+ "Bash(Get-ChildItem -Path \"E:\\\\language-learner\\\\app\" -Recurse -Directory)",
46
+ "Bash(Select-Object -First 30 FullName)",
47
+ "Bash(grep -E \"\\\\.\\(tsx?|json|mjs\\)$\")",
48
+ "Bash(xargs wc -l)",
49
+ "Bash(flutter analyze *)"
50
  ]
51
  },
52
  "outputStyle": "default"
app/api/me/route.ts CHANGED
@@ -7,6 +7,7 @@ export async function GET() {
7
 
8
  return NextResponse.json({
9
  user: {
 
10
  hfId: session.hfId,
11
  hfUsername: session.hfUsername,
12
  email: session.email,
@@ -23,4 +24,3 @@ export async function GET() {
23
  streakCount: session.streakCount ?? 0,
24
  });
25
  }
26
-
 
7
 
8
  return NextResponse.json({
9
  user: {
10
+ id: session.hfId,
11
  hfId: session.hfId,
12
  hfUsername: session.hfUsername,
13
  email: session.email,
 
24
  streakCount: session.streakCount ?? 0,
25
  });
26
  }
 
mobile/android/app/src/main/AndroidManifest.xml CHANGED
@@ -1,13 +1,13 @@
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
 
2
  <application
3
- android:label="langlearn"
4
  android:name="${applicationName}"
5
  android:icon="@mipmap/ic_launcher">
6
  <activity
7
  android:name=".MainActivity"
8
  android:exported="true"
9
  android:launchMode="singleTop"
10
- android:taskAffinity=""
11
  android:theme="@style/LaunchTheme"
12
  android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
13
  android:hardwareAccelerated="true"
@@ -54,5 +54,15 @@
54
  <action android:name="android.intent.action.PROCESS_TEXT"/>
55
  <data android:mimeType="text/plain"/>
56
  </intent>
 
 
 
 
 
 
 
 
 
 
57
  </queries>
58
  </manifest>
 
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.INTERNET"/>
3
  <application
4
+ android:label="PraxaLing"
5
  android:name="${applicationName}"
6
  android:icon="@mipmap/ic_launcher">
7
  <activity
8
  android:name=".MainActivity"
9
  android:exported="true"
10
  android:launchMode="singleTop"
 
11
  android:theme="@style/LaunchTheme"
12
  android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
13
  android:hardwareAccelerated="true"
 
54
  <action android:name="android.intent.action.PROCESS_TEXT"/>
55
  <data android:mimeType="text/plain"/>
56
  </intent>
57
+ <!-- Required for flutter_web_auth_2 on Android 11+ so the plugin can
58
+ enumerate browsers / Custom Tabs providers when launching HF OAuth. -->
59
+ <intent>
60
+ <action android:name="android.intent.action.VIEW" />
61
+ <category android:name="android.intent.category.BROWSABLE" />
62
+ <data android:scheme="https" />
63
+ </intent>
64
+ <intent>
65
+ <action android:name="android.support.customtabs.action.CustomTabsService" />
66
+ </intent>
67
  </queries>
68
  </manifest>
mobile/ios/Runner/Info.plist CHANGED
@@ -24,7 +24,7 @@
24
  <key>CFBundleDevelopmentRegion</key>
25
  <string>$(DEVELOPMENT_LANGUAGE)</string>
26
  <key>CFBundleDisplayName</key>
27
- <string>Langlearn</string>
28
  <key>CFBundleExecutable</key>
29
  <string>$(EXECUTABLE_NAME)</string>
30
  <key>CFBundleIdentifier</key>
@@ -32,7 +32,7 @@
32
  <key>CFBundleInfoDictionaryVersion</key>
33
  <string>6.0</string>
34
  <key>CFBundleName</key>
35
- <string>langlearn</string>
36
  <key>CFBundlePackageType</key>
37
  <string>APPL</string>
38
  <key>CFBundleShortVersionString</key>
 
24
  <key>CFBundleDevelopmentRegion</key>
25
  <string>$(DEVELOPMENT_LANGUAGE)</string>
26
  <key>CFBundleDisplayName</key>
27
+ <string>PraxaLing</string>
28
  <key>CFBundleExecutable</key>
29
  <string>$(EXECUTABLE_NAME)</string>
30
  <key>CFBundleIdentifier</key>
 
32
  <key>CFBundleInfoDictionaryVersion</key>
33
  <string>6.0</string>
34
  <key>CFBundleName</key>
35
+ <string>PraxaLing</string>
36
  <key>CFBundlePackageType</key>
37
  <string>APPL</string>
38
  <key>CFBundleShortVersionString</key>
mobile/lib/auth/auth_provider.dart CHANGED
@@ -34,7 +34,7 @@ class User {
34
  final String? avatarUrl;
35
 
36
  factory User.fromJson(Map<String, dynamic> j) => User(
37
- id: j['id'] as String,
38
  hfUsername: j['hfUsername'] as String,
39
  email: j['email'] as String?,
40
  avatarUrl: j['avatarUrl'] as String?,
@@ -147,17 +147,24 @@ class AuthController extends StateNotifier<AuthState> {
147
 
148
  Future<void> signInWithHuggingFace() async {
149
  final loginUrl = '${Env.apiBaseUrl}/api/auth/login?client=mobile';
 
150
  try {
151
  final result = await FlutterWebAuth2.authenticate(
152
  url: loginUrl,
153
  callbackUrlScheme: Env.oauthCallbackScheme,
154
  );
 
155
  final token = Uri.parse(result).queryParameters['token'];
156
- if (token == null) throw Exception('no token in callback');
 
 
157
  await _store.write(token);
158
  await refresh();
159
  } on DioException catch (e) {
160
- debugPrint('auth dio error: $e');
 
 
 
161
  rethrow;
162
  }
163
  }
@@ -236,4 +243,3 @@ final authControllerProvider = StateNotifierProvider<AuthController, AuthState>(
236
  return AuthController(api, store);
237
  });
238
 
239
-
 
34
  final String? avatarUrl;
35
 
36
  factory User.fromJson(Map<String, dynamic> j) => User(
37
+ id: (j['id'] ?? j['hfId']) as String,
38
  hfUsername: j['hfUsername'] as String,
39
  email: j['email'] as String?,
40
  avatarUrl: j['avatarUrl'] as String?,
 
147
 
148
  Future<void> signInWithHuggingFace() async {
149
  final loginUrl = '${Env.apiBaseUrl}/api/auth/login?client=mobile';
150
+ debugPrint('[auth] launching OAuth via $loginUrl (callback scheme=${Env.oauthCallbackScheme})');
151
  try {
152
  final result = await FlutterWebAuth2.authenticate(
153
  url: loginUrl,
154
  callbackUrlScheme: Env.oauthCallbackScheme,
155
  );
156
+ debugPrint('[auth] received callback: $result');
157
  final token = Uri.parse(result).queryParameters['token'];
158
+ if (token == null || token.isEmpty) {
159
+ throw Exception('OAuth callback missing token: $result');
160
+ }
161
  await _store.write(token);
162
  await refresh();
163
  } on DioException catch (e) {
164
+ debugPrint('[auth] dio error during refresh: $e');
165
+ rethrow;
166
+ } catch (e, st) {
167
+ debugPrint('[auth] sign-in failed: $e\n$st');
168
  rethrow;
169
  }
170
  }
 
243
  return AuthController(api, store);
244
  });
245
 
 
mobile/lib/features/camera/camera_screen.dart CHANGED
@@ -12,7 +12,7 @@ import '../../widgets/furi_text.dart';
12
  import '../../widgets/level_picker.dart';
13
  import '../../widgets/tts_button.dart';
14
 
15
- // ── Data types ─────────────────────────────────────────────────────────────────
16
 
17
  class VisionResult {
18
  VisionResult({
@@ -57,7 +57,7 @@ class VisionSentence {
57
  final String? romanized;
58
  }
59
 
60
- // ── Screen ─────────────────────────────────────────────────────────────────────
61
 
62
  class CameraScreen extends ConsumerStatefulWidget {
63
  const CameraScreen({super.key});
@@ -65,40 +65,16 @@ class CameraScreen extends ConsumerStatefulWidget {
65
  ConsumerState<CameraScreen> createState() => _CameraScreenState();
66
  }
67
 
68
- class _CameraScreenState extends ConsumerState<CameraScreen>
69
- with TickerProviderStateMixin {
70
  File? _image;
71
  VisionResult? _result;
72
  bool _loading = false;
73
  String? _error;
74
  String? _level;
75
 
76
- // Pulsing animation for the center-point dots
77
- late final AnimationController _pulseCtrl;
78
- late final Animation<double> _pulseAnim;
79
-
80
- @override
81
- void initState() {
82
- super.initState();
83
- _pulseCtrl = AnimationController(
84
- vsync: this,
85
- duration: const Duration(milliseconds: 1600),
86
- )..repeat(reverse: true);
87
- _pulseAnim = Tween<double>(begin: 0.6, end: 1.0).animate(
88
- CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut),
89
- );
90
- }
91
-
92
- @override
93
- void dispose() {
94
- _pulseCtrl.dispose();
95
- super.dispose();
96
- }
97
-
98
  Future<void> _pick(ImageSource source) async {
99
  final picker = ImagePicker();
100
- final x = await picker.pickImage(
101
- source: source, maxWidth: 1600, imageQuality: 88);
102
  if (x == null) return;
103
  setState(() {
104
  _image = File(x.path);
@@ -118,16 +94,13 @@ class _CameraScreenState extends ConsumerState<CameraScreen>
118
  'image': await MultipartFile.fromFile(file.path),
119
  if (level != null) 'level': level,
120
  });
121
- final res = await api.dio
122
- .post<Map<String, dynamic>>('/api/vision/analyze', data: form);
123
 
124
  if (res.statusCode == 200 && res.data != null) {
125
  final data = res.data!;
126
  final objs = ((data['objects'] as List?) ?? []).map((e) {
127
  final o = e as Map<String, dynamic>;
128
- final rawBox = (o['box'] as List)
129
- .map((x) => (x as num).toDouble())
130
- .toList();
131
  return VisionObject(
132
  labelNative: (o['label'] ?? '') as String,
133
  labelTarget: (o['translation'] ?? '') as String,
@@ -174,167 +147,205 @@ class _CameraScreenState extends ConsumerState<CameraScreen>
174
  final profile = ref.watch(authControllerProvider).profile;
175
 
176
  return Scaffold(
177
- backgroundColor: kBackground,
178
- appBar: AppBar(
179
- title: const Text('Image Practice'),
180
- actions: [
181
- if (profile != null)
182
- Padding(
183
- padding: const EdgeInsets.only(right: 16),
184
- child: Center(
185
- child: Container(
186
- padding:
187
- const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
188
- decoration: BoxDecoration(
189
- color: kPrimary.withAlpha(31),
190
- borderRadius: BorderRadius.circular(8),
191
- border: Border.all(color: kPrimary.withAlpha(102)),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  ),
193
- child: Text(
194
- '${profile.targetLang.toUpperCase()} · ${profile.level}',
195
- style: GoogleFonts.almarai(
196
- color: kPrimary,
197
- fontSize: 12,
198
- fontWeight: FontWeight.w600),
199
  ),
200
- ),
201
  ),
202
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  ],
204
  ),
205
- body: ListView(
206
- padding: const EdgeInsets.all(16),
207
  children: [
208
- // ── Upload buttons ─────────────────────────────────────────────────
209
- Row(children: [
210
- Expanded(
211
- child: FilledButton.icon(
212
- onPressed: _loading ? null : () => _pick(ImageSource.camera),
213
- icon: const Icon(Icons.camera_alt, size: 18),
214
- label: const Text('Camera'),
215
- ),
216
- ),
217
- const SizedBox(width: 10),
218
- Expanded(
219
- child: OutlinedButton.icon(
220
- onPressed: _loading ? null : () => _pick(ImageSource.gallery),
221
- icon: const Icon(Icons.photo_library_outlined, size: 18),
222
- label: const Text('Gallery'),
223
- ),
224
- ),
225
- ]),
226
-
227
- const SizedBox(height: 12),
228
  Container(
229
- padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
 
230
  decoration: BoxDecoration(
231
- color: kCard,
232
- borderRadius: BorderRadius.circular(14),
233
- border: Border.all(color: kBorder, width: 2),
 
 
 
234
  ),
235
- child: Row(
 
 
 
 
 
236
  children: [
237
- Expanded(
238
- child: Text(
239
- 'Sentences match level',
240
- style: GoogleFonts.almarai(color: kForeground, fontSize: 12, fontWeight: FontWeight.w900),
 
 
 
241
  ),
242
  ),
243
- LevelPicker(
244
- value: _level ?? profile?.level ?? 'A1',
245
- onChanged: (l) => setState(() => _level = l),
 
 
 
 
 
246
  ),
 
 
 
 
 
 
 
 
 
 
 
 
247
  ],
248
  ),
249
  ),
250
-
251
- if (_error != null) ...[
252
- const SizedBox(height: 12),
253
- _ErrorBanner(message: _error!),
254
- ],
255
-
256
- const SizedBox(height: 16),
257
-
258
- // ── Image with overlays ────────────────────────────────────────────
259
- if (_image != null)
260
- _loading && _result == null
261
- ? _LoadingOverlay()
262
- : _result != null
263
- ? _AnnotatedImage(
264
- image: _image!,
265
- result: _result!,
266
- pulseAnim: _pulseAnim,
267
- )
268
- : ClipRRect(
269
- borderRadius: BorderRadius.circular(16),
270
- child: Image.file(_image!, fit: BoxFit.cover)),
271
-
272
- // ── Caption (in native language) ───────────────────────────────────
273
- if (_result != null && _result!.caption.isNotEmpty) ...[
274
- const SizedBox(height: 12),
275
- Container(
276
- padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
277
- decoration: BoxDecoration(
278
- color: kCard,
279
- borderRadius: BorderRadius.circular(12),
280
- border: Border.all(color: kBorder),
281
- ),
282
- child: Row(children: [
283
- const Icon(Icons.auto_awesome, size: 14, color: kPrimary),
284
- const SizedBox(width: 8),
285
- Expanded(
286
- child: Text(
287
- _result!.caption,
288
- style: GoogleFonts.almarai(
289
- color: kMuted,
290
- fontSize: 13,
291
- fontStyle: FontStyle.italic),
292
- ),
293
- ),
294
- ]),
295
- ),
296
- ],
297
-
298
- if (_result != null) ...[
299
- const SizedBox(height: 20),
300
- _ResultsSection(result: _result!, lang: profile?.targetLang ?? 'en'),
301
- ],
302
  ],
303
  ),
304
  );
305
  }
306
  }
307
 
308
- // ── Loading overlay ────────────────────────────────────────────────────────────
309
-
310
  class _LoadingOverlay extends StatelessWidget {
311
  @override
312
  Widget build(BuildContext context) {
313
  return Container(
314
  height: 240,
315
- decoration: BoxDecoration(
316
- color: kCard,
317
- borderRadius: BorderRadius.circular(16),
318
- border: Border.all(color: kBorder),
319
- ),
320
  child: Column(
321
  mainAxisAlignment: MainAxisAlignment.center,
322
  children: [
323
- const CircularProgressIndicator(color: kPrimary, strokeWidth: 2),
324
  const SizedBox(height: 16),
325
- Text('Gemma is analysing…',
326
- style: GoogleFonts.almarai(color: kMuted, fontSize: 13)),
 
 
 
 
 
 
 
327
  const SizedBox(height: 4),
328
- Text('This may take a moment',
329
- style: GoogleFonts.almarai(color: kMuted.withAlpha(128), fontSize: 11)),
 
 
330
  ],
331
  ),
332
  );
333
  }
334
  }
335
 
336
- // ── Error banner ───────────────────────────────────────────────────────────────
337
-
338
  class _ErrorBanner extends StatelessWidget {
339
  const _ErrorBanner({required this.message});
340
  final String message;
@@ -344,33 +355,31 @@ class _ErrorBanner extends StatelessWidget {
344
  return Container(
345
  padding: const EdgeInsets.all(12),
346
  decoration: BoxDecoration(
347
- color: Colors.red.withAlpha(20),
348
- borderRadius: BorderRadius.circular(10),
349
- border: Border.all(color: Colors.red.withAlpha(77)),
 
 
 
350
  ),
351
  child: Row(children: [
352
- const Icon(Icons.error_outline, color: Colors.redAccent, size: 16),
353
  const SizedBox(width: 8),
354
  Expanded(
355
- child: Text(message,
356
- style: const TextStyle(color: Colors.redAccent, fontSize: 12)),
 
 
357
  ),
358
  ]),
359
  );
360
  }
361
  }
362
 
363
- // ── Annotated image with pulsing center-point dots ─────────────────────────────
364
-
365
  class _AnnotatedImage extends StatelessWidget {
366
- const _AnnotatedImage({
367
- required this.image,
368
- required this.result,
369
- required this.pulseAnim,
370
- });
371
  final File image;
372
  final VisionResult result;
373
- final Animation<double> pulseAnim;
374
 
375
  @override
376
  Widget build(BuildContext context) {
@@ -379,39 +388,47 @@ class _AnnotatedImage extends StatelessWidget {
379
  final dw = constraints.maxWidth;
380
  final dh = dw / aspect;
381
 
382
- return ClipRRect(
383
- borderRadius: BorderRadius.circular(16),
384
- child: SizedBox(
385
- width: dw,
386
- height: dh,
387
- child: Stack(children: [
388
- Positioned.fill(child: Image.file(image, fit: BoxFit.cover)),
389
- ...result.objects.map((o) {
390
- final isNorm = o.box.every((v) => v <= 1.0);
391
- final x1 = isNorm ? o.box[0] * dw : (o.box[0] / result.imgW) * dw;
392
- final y1 = isNorm ? o.box[1] * dh : (o.box[1] / result.imgH) * dh;
393
- final x2 = isNorm ? o.box[2] * dw : (o.box[2] / result.imgW) * dw;
394
- final y2 = isNorm ? o.box[3] * dh : (o.box[3] / result.imgH) * dh;
395
- final cx = (x1 + x2) / 2;
396
- final cy = (y1 + y2) / 2;
397
-
398
- return Positioned(
399
- left: cx - 60, // center the label
400
- top: cy - 10,
401
- child: _PulsingLabel(object: o, pulseAnim: pulseAnim),
402
- );
403
- }),
404
- ]),
 
 
 
 
 
 
 
 
 
405
  ),
406
  );
407
  });
408
  }
409
  }
410
 
411
- class _PulsingLabel extends StatelessWidget {
412
- const _PulsingLabel({required this.object, required this.pulseAnim});
413
  final VisionObject object;
414
- final Animation<double> pulseAnim;
415
 
416
  @override
417
  Widget build(BuildContext context) {
@@ -419,59 +436,45 @@ class _PulsingLabel extends StatelessWidget {
419
  crossAxisAlignment: CrossAxisAlignment.center,
420
  mainAxisSize: MainAxisSize.min,
421
  children: [
422
- // Pulsing dot
423
- AnimatedBuilder(
424
- animation: pulseAnim,
425
- builder: (_, __) => Container(
426
- width: 10,
427
- height: 10,
428
- decoration: BoxDecoration(
429
- shape: BoxShape.circle,
430
- color: kPrimary.withAlpha((pulseAnim.value * 255).round()),
431
- boxShadow: [
432
- BoxShadow(
433
- color: kPrimary.withAlpha((pulseAnim.value * 153).round()),
434
- blurRadius: 8 * pulseAnim.value,
435
- spreadRadius: 2 * pulseAnim.value,
436
- ),
437
- ],
438
- ),
439
  ),
440
  ),
441
  const SizedBox(width: 6),
442
- // Glassmorphic label chip
443
- ClipRRect(
444
- borderRadius: BorderRadius.circular(8),
445
- child: Container(
446
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
447
- decoration: BoxDecoration(
448
- color: Colors.black.withAlpha(140),
449
- borderRadius: BorderRadius.circular(8),
450
- border: Border.all(
451
- color: kPrimary.withAlpha(128), width: 0.8),
452
- ),
453
- child: Column(
454
- crossAxisAlignment: CrossAxisAlignment.start,
455
- mainAxisSize: MainAxisSize.min,
456
- children: [
 
 
 
 
 
457
  Text(
458
- object.labelTarget,
459
  style: GoogleFonts.almarai(
460
- color: Colors.white,
461
- fontSize: 11,
462
- fontWeight: FontWeight.w600,
463
  ),
464
  ),
465
- if (object.romanized != null && object.romanized!.isNotEmpty)
466
- Text(
467
- object.romanized!,
468
- style: GoogleFonts.almarai(
469
- color: kPrimary.withAlpha(217),
470
- fontSize: 9,
471
- ),
472
- ),
473
- ],
474
- ),
475
  ),
476
  ),
477
  ],
@@ -479,8 +482,6 @@ class _PulsingLabel extends StatelessWidget {
479
  }
480
  }
481
 
482
- // ── Results section ───────────────────────────────────────────────────────────
483
-
484
  class _ResultsSection extends StatelessWidget {
485
  const _ResultsSection({required this.result, required this.lang});
486
  final VisionResult result;
@@ -488,121 +489,123 @@ class _ResultsSection extends StatelessWidget {
488
 
489
  @override
490
  Widget build(BuildContext context) {
491
- return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
492
- // ── Objects ────────────────────────────────────────────────────────────
493
- Text('OBJECTS DETECTED',
 
 
494
  style: GoogleFonts.almarai(
495
- color: kMuted, fontSize: 11, letterSpacing: 0)),
496
- const SizedBox(height: 10),
497
- if (result.objects.isEmpty)
498
- Text('No objects detected.',
499
- style: GoogleFonts.almarai(color: kMuted))
500
- else
501
- ...result.objects.map(
502
- (o) => Padding(
503
- padding: const EdgeInsets.only(bottom: 8),
504
- child: Container(
505
- padding:
506
- const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
507
- decoration: BoxDecoration(
508
- color: kCard,
509
- borderRadius: BorderRadius.circular(12),
510
- border: Border.all(color: kBorder),
511
- ),
512
- child: Row(children: [
513
- Expanded(
514
- child: Column(
515
- crossAxisAlignment: CrossAxisAlignment.start,
516
- children: [
517
- FuriText(
518
- text: o.labelTarget,
519
- segments: o.labelTargetSegments,
520
- fontSize: 18,
521
- fontWeight: FontWeight.w900,
522
- ),
523
- if (o.romanized != null && o.romanized!.isNotEmpty)
524
- Padding(
525
- padding: const EdgeInsets.only(top: 4),
526
- child: Text(o.romanized!,
527
- style: GoogleFonts.almarai(
528
- color: kPrimary.withAlpha(204),
529
- fontSize: 12)),
530
  ),
531
- const SizedBox(height: 4),
532
- Text(o.labelNative,
533
- style: GoogleFonts.almarai(
534
- color: kMuted, fontSize: 12, fontWeight: FontWeight.w800)),
535
- ],
 
 
 
 
 
 
 
 
 
 
536
  ),
537
- ),
538
- const SizedBox(width: 10),
539
- TtsButton(text: o.labelTarget, lang: lang, size: 34),
540
- ]),
541
  ),
542
  ),
543
- ),
544
-
545
- const SizedBox(height: 20),
546
-
547
- // ── Sentences ─────────────────────────────────────────────────────────
548
- Text('PRACTICE SENTENCES',
549
  style: GoogleFonts.almarai(
550
- color: kMuted, fontSize: 11, letterSpacing: 0)),
551
- const SizedBox(height: 10),
552
- if (result.sentences.isEmpty)
553
- Text('No sentences generated.',
554
- style: GoogleFonts.almarai(color: kMuted))
555
- else
556
- ...result.sentences.map(
557
- (s) => Padding(
558
- padding: const EdgeInsets.only(bottom: 8),
559
- child: Container(
560
- padding: const EdgeInsets.all(14),
561
- decoration: BoxDecoration(
562
- color: kCard,
563
- borderRadius: BorderRadius.circular(12),
564
- border: Border.all(color: kBorder),
565
- ),
566
- child: Column(
567
- crossAxisAlignment: CrossAxisAlignment.start,
568
- children: [
569
- Row(
570
- crossAxisAlignment: CrossAxisAlignment.start,
571
- children: [
572
- Expanded(
573
- child: FuriText(
574
- text: s.target,
575
- segments: s.targetSegments,
576
- fontSize: 17,
577
- fontWeight: FontWeight.w900,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
578
  ),
579
  ),
580
- const SizedBox(width: 8),
581
- TtsButton(text: s.target, lang: lang, size: 32),
582
  ],
583
- ),
584
- if (s.romanized != null && s.romanized!.isNotEmpty) ...[
585
- const SizedBox(height: 4),
586
- Text(s.romanized!,
587
- style: GoogleFonts.almarai(
588
- color: kPrimary.withAlpha(204),
589
- fontSize: 12,
590
- fontStyle: FontStyle.italic)),
591
  ],
592
- const SizedBox(height: 8),
593
- Text(s.gloss,
594
- style: GoogleFonts.almarai(
595
- color: kMuted, fontSize: 12, fontWeight: FontWeight.w800)),
596
- ],
597
  ),
598
  ),
599
  ),
600
- ),
601
- const SizedBox(height: 20),
602
- ]);
603
  }
604
  }
605
-
606
-
607
-
608
-
 
12
  import '../../widgets/level_picker.dart';
13
  import '../../widgets/tts_button.dart';
14
 
15
+ // ── Data types ──────────────────────────────────────────────────────────────
16
 
17
  class VisionResult {
18
  VisionResult({
 
57
  final String? romanized;
58
  }
59
 
60
+ // ── Screen ──────────────────────────────────────────────────────────────────
61
 
62
  class CameraScreen extends ConsumerStatefulWidget {
63
  const CameraScreen({super.key});
 
65
  ConsumerState<CameraScreen> createState() => _CameraScreenState();
66
  }
67
 
68
+ class _CameraScreenState extends ConsumerState<CameraScreen> {
 
69
  File? _image;
70
  VisionResult? _result;
71
  bool _loading = false;
72
  String? _error;
73
  String? _level;
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  Future<void> _pick(ImageSource source) async {
76
  final picker = ImagePicker();
77
+ final x = await picker.pickImage(source: source, maxWidth: 1600, imageQuality: 88);
 
78
  if (x == null) return;
79
  setState(() {
80
  _image = File(x.path);
 
94
  'image': await MultipartFile.fromFile(file.path),
95
  if (level != null) 'level': level,
96
  });
97
+ final res = await api.dio.post<Map<String, dynamic>>('/api/vision/analyze', data: form);
 
98
 
99
  if (res.statusCode == 200 && res.data != null) {
100
  final data = res.data!;
101
  final objs = ((data['objects'] as List?) ?? []).map((e) {
102
  final o = e as Map<String, dynamic>;
103
+ final rawBox = (o['box'] as List).map((x) => (x as num).toDouble()).toList();
 
 
104
  return VisionObject(
105
  labelNative: (o['label'] ?? '') as String,
106
  labelTarget: (o['translation'] ?? '') as String,
 
147
  final profile = ref.watch(authControllerProvider).profile;
148
 
149
  return Scaffold(
150
+ body: SafeArea(
151
+ child: ListView(
152
+ padding: const EdgeInsets.fromLTRB(16, 18, 16, 28),
153
+ children: [
154
+ _Banner(profile: profile),
155
+ const SizedBox(height: 18),
156
+ Row(children: [
157
+ Expanded(
158
+ child: FilledButton.icon(
159
+ onPressed: _loading ? null : () => _pick(ImageSource.camera),
160
+ icon: const Icon(Icons.camera_alt, size: 18),
161
+ label: const Text('CAMERA'),
162
+ ),
163
+ ),
164
+ const SizedBox(width: 10),
165
+ Expanded(
166
+ child: OutlinedButton.icon(
167
+ onPressed: _loading ? null : () => _pick(ImageSource.gallery),
168
+ icon: const Icon(Icons.photo_library_outlined, size: 18),
169
+ label: const Text('GALLERY'),
170
+ ),
171
+ ),
172
+ ]),
173
+ const SizedBox(height: 12),
174
+ Container(
175
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
176
+ decoration: brutalCard(),
177
+ child: Row(
178
+ children: [
179
+ Expanded(
180
+ child: Text(
181
+ 'SENTENCES MATCH LEVEL',
182
+ style: GoogleFonts.almarai(
183
+ color: kBrutalSlate,
184
+ fontSize: 11,
185
+ fontWeight: FontWeight.w900,
186
+ letterSpacing: 1.0,
187
+ ),
188
+ ),
189
  ),
190
+ LevelPicker(
191
+ value: _level ?? profile?.level ?? 'A1',
192
+ onChanged: (l) => setState(() => _level = l),
 
 
 
193
  ),
194
+ ],
195
  ),
196
  ),
197
+ if (_error != null) ...[
198
+ const SizedBox(height: 12),
199
+ _ErrorBanner(message: _error!),
200
+ ],
201
+ const SizedBox(height: 16),
202
+ if (_image != null)
203
+ _loading && _result == null
204
+ ? _LoadingOverlay()
205
+ : _result != null
206
+ ? _AnnotatedImage(image: _image!, result: _result!)
207
+ : ClipRRect(
208
+ borderRadius: BorderRadius.circular(kBrutalRadius),
209
+ child: Image.file(_image!, fit: BoxFit.cover)),
210
+ if (_result != null && _result!.caption.isNotEmpty) ...[
211
+ const SizedBox(height: 12),
212
+ Container(
213
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
214
+ decoration: brutalCard(color: kBrutalCream),
215
+ child: Row(children: [
216
+ const Icon(Icons.auto_awesome, size: 14, color: kBrutalBlack),
217
+ const SizedBox(width: 8),
218
+ Expanded(
219
+ child: Text(
220
+ _result!.caption,
221
+ style: GoogleFonts.almarai(
222
+ color: kBrutalSlate,
223
+ fontSize: 13,
224
+ fontWeight: FontWeight.w800,
225
+ fontStyle: FontStyle.italic,
226
+ ),
227
+ ),
228
+ ),
229
+ ]),
230
+ ),
231
+ ],
232
+ if (_result != null) ...[
233
+ const SizedBox(height: 20),
234
+ _ResultsSection(result: _result!, lang: profile?.targetLang ?? 'en'),
235
+ ],
236
+ ],
237
+ ),
238
+ ),
239
+ );
240
+ }
241
+ }
242
+
243
+ class _Banner extends StatelessWidget {
244
+ const _Banner({required this.profile});
245
+ final Profile? profile;
246
+
247
+ @override
248
+ Widget build(BuildContext context) {
249
+ return Container(
250
+ decoration: BoxDecoration(
251
+ color: kBrutalBlue,
252
+ borderRadius: BorderRadius.circular(kBrutalRadius),
253
+ border: Border.all(color: kBrutalBlack, width: 3),
254
+ boxShadow: const [
255
+ BoxShadow(color: kBrutalBlack, offset: Offset(5, 5), blurRadius: 0),
256
  ],
257
  ),
258
+ padding: const EdgeInsets.all(20),
259
+ child: Row(
260
  children: [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  Container(
262
+ width: 56,
263
+ height: 56,
264
  decoration: BoxDecoration(
265
+ color: kBrutalWhite,
266
+ borderRadius: BorderRadius.circular(kBrutalRadius),
267
+ border: Border.all(color: kBrutalBlack, width: 2),
268
+ boxShadow: const [
269
+ BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0),
270
+ ],
271
  ),
272
+ child: const Icon(Icons.camera_alt, color: kBrutalBlack),
273
+ ),
274
+ const SizedBox(width: 14),
275
+ Expanded(
276
+ child: Column(
277
+ crossAxisAlignment: CrossAxisAlignment.start,
278
  children: [
279
+ Text(
280
+ 'IMAGE PRACTICE',
281
+ style: GoogleFonts.almarai(
282
+ color: kBrutalWhite,
283
+ fontSize: 11,
284
+ fontWeight: FontWeight.w900,
285
+ letterSpacing: 1.4,
286
  ),
287
  ),
288
+ Text(
289
+ 'SNAP & LEARN',
290
+ style: GoogleFonts.almarai(
291
+ color: kBrutalWhite,
292
+ fontSize: 22,
293
+ fontWeight: FontWeight.w900,
294
+ height: 1.05,
295
+ ),
296
  ),
297
+ if (profile != null)
298
+ Padding(
299
+ padding: const EdgeInsets.only(top: 4),
300
+ child: Text(
301
+ '${profile!.targetLang.toUpperCase()} · ${profile!.level}',
302
+ style: GoogleFonts.almarai(
303
+ color: kBrutalWhite,
304
+ fontSize: 12,
305
+ fontWeight: FontWeight.w800,
306
+ ),
307
+ ),
308
+ ),
309
  ],
310
  ),
311
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  ],
313
  ),
314
  );
315
  }
316
  }
317
 
 
 
318
  class _LoadingOverlay extends StatelessWidget {
319
  @override
320
  Widget build(BuildContext context) {
321
  return Container(
322
  height: 240,
323
+ decoration: brutalCard(),
 
 
 
 
324
  child: Column(
325
  mainAxisAlignment: MainAxisAlignment.center,
326
  children: [
327
+ const CircularProgressIndicator(color: kBrutalTeal, strokeWidth: 3),
328
  const SizedBox(height: 16),
329
+ Text(
330
+ 'GEMMA IS ANALYSING…',
331
+ style: GoogleFonts.almarai(
332
+ color: kBrutalSlate,
333
+ fontSize: 13,
334
+ fontWeight: FontWeight.w900,
335
+ letterSpacing: 1.0,
336
+ ),
337
+ ),
338
  const SizedBox(height: 4),
339
+ Text(
340
+ 'This may take a moment',
341
+ style: GoogleFonts.almarai(color: kBrutalMuted, fontSize: 11, fontWeight: FontWeight.w800),
342
+ ),
343
  ],
344
  ),
345
  );
346
  }
347
  }
348
 
 
 
349
  class _ErrorBanner extends StatelessWidget {
350
  const _ErrorBanner({required this.message});
351
  final String message;
 
355
  return Container(
356
  padding: const EdgeInsets.all(12),
357
  decoration: BoxDecoration(
358
+ color: kBrutalRed,
359
+ borderRadius: BorderRadius.circular(kBrutalRadius),
360
+ border: Border.all(color: kBrutalBlack, width: 2),
361
+ boxShadow: const [
362
+ BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0),
363
+ ],
364
  ),
365
  child: Row(children: [
366
+ const Icon(Icons.error_outline, color: kBrutalWhite, size: 18),
367
  const SizedBox(width: 8),
368
  Expanded(
369
+ child: Text(
370
+ message,
371
+ style: GoogleFonts.almarai(color: kBrutalWhite, fontSize: 13, fontWeight: FontWeight.w900),
372
+ ),
373
  ),
374
  ]),
375
  );
376
  }
377
  }
378
 
 
 
379
  class _AnnotatedImage extends StatelessWidget {
380
+ const _AnnotatedImage({required this.image, required this.result});
 
 
 
 
381
  final File image;
382
  final VisionResult result;
 
383
 
384
  @override
385
  Widget build(BuildContext context) {
 
388
  final dw = constraints.maxWidth;
389
  final dh = dw / aspect;
390
 
391
+ return Container(
392
+ decoration: BoxDecoration(
393
+ borderRadius: BorderRadius.circular(kBrutalRadius),
394
+ border: Border.all(color: kBrutalBlack, width: 3),
395
+ boxShadow: const [
396
+ BoxShadow(color: kBrutalBlack, offset: Offset(5, 5), blurRadius: 0),
397
+ ],
398
+ ),
399
+ child: ClipRRect(
400
+ borderRadius: BorderRadius.circular(kBrutalRadius - 1),
401
+ child: SizedBox(
402
+ width: dw,
403
+ height: dh,
404
+ child: Stack(children: [
405
+ Positioned.fill(child: Image.file(image, fit: BoxFit.cover)),
406
+ ...result.objects.map((o) {
407
+ final isNorm = o.box.every((v) => v <= 1.0);
408
+ final x1 = isNorm ? o.box[0] * dw : (o.box[0] / result.imgW) * dw;
409
+ final y1 = isNorm ? o.box[1] * dh : (o.box[1] / result.imgH) * dh;
410
+ final x2 = isNorm ? o.box[2] * dw : (o.box[2] / result.imgW) * dw;
411
+ final y2 = isNorm ? o.box[3] * dh : (o.box[3] / result.imgH) * dh;
412
+ final cx = (x1 + x2) / 2;
413
+ final cy = (y1 + y2) / 2;
414
+
415
+ return Positioned(
416
+ left: cx - 60,
417
+ top: cy - 12,
418
+ child: _BrutalLabel(object: o),
419
+ );
420
+ }),
421
+ ]),
422
+ ),
423
  ),
424
  );
425
  });
426
  }
427
  }
428
 
429
+ class _BrutalLabel extends StatelessWidget {
430
+ const _BrutalLabel({required this.object});
431
  final VisionObject object;
 
432
 
433
  @override
434
  Widget build(BuildContext context) {
 
436
  crossAxisAlignment: CrossAxisAlignment.center,
437
  mainAxisSize: MainAxisSize.min,
438
  children: [
439
+ Container(
440
+ width: 12,
441
+ height: 12,
442
+ decoration: BoxDecoration(
443
+ color: kBrutalYellow,
444
+ border: Border.all(color: kBrutalBlack, width: 2),
445
+ shape: BoxShape.circle,
 
 
 
 
 
 
 
 
 
 
446
  ),
447
  ),
448
  const SizedBox(width: 6),
449
+ Container(
450
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
451
+ decoration: BoxDecoration(
452
+ color: kBrutalBlack,
453
+ borderRadius: BorderRadius.circular(kBrutalRadius),
454
+ border: Border.all(color: kBrutalYellow, width: 2),
455
+ ),
456
+ child: Column(
457
+ crossAxisAlignment: CrossAxisAlignment.start,
458
+ mainAxisSize: MainAxisSize.min,
459
+ children: [
460
+ Text(
461
+ object.labelTarget,
462
+ style: GoogleFonts.almarai(
463
+ color: kBrutalWhite,
464
+ fontSize: 11,
465
+ fontWeight: FontWeight.w900,
466
+ ),
467
+ ),
468
+ if (object.romanized != null && object.romanized!.isNotEmpty)
469
  Text(
470
+ object.romanized!,
471
  style: GoogleFonts.almarai(
472
+ color: kBrutalYellow,
473
+ fontSize: 9,
474
+ fontWeight: FontWeight.w800,
475
  ),
476
  ),
477
+ ],
 
 
 
 
 
 
 
 
 
478
  ),
479
  ),
480
  ],
 
482
  }
483
  }
484
 
 
 
485
  class _ResultsSection extends StatelessWidget {
486
  const _ResultsSection({required this.result, required this.lang});
487
  final VisionResult result;
 
489
 
490
  @override
491
  Widget build(BuildContext context) {
492
+ return Column(
493
+ crossAxisAlignment: CrossAxisAlignment.stretch,
494
+ children: [
495
+ Text(
496
+ 'OBJECTS DETECTED',
497
  style: GoogleFonts.almarai(
498
+ color: kBrutalSlate,
499
+ fontSize: 12,
500
+ fontWeight: FontWeight.w900,
501
+ letterSpacing: 1.2,
502
+ ),
503
+ ),
504
+ const SizedBox(height: 10),
505
+ if (result.objects.isEmpty)
506
+ Text('No objects detected.', style: GoogleFonts.almarai(color: kBrutalMuted, fontWeight: FontWeight.w800))
507
+ else
508
+ ...result.objects.map(
509
+ (o) => Padding(
510
+ padding: const EdgeInsets.only(bottom: 10),
511
+ child: Container(
512
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
513
+ decoration: brutalCard(),
514
+ child: Row(children: [
515
+ Expanded(
516
+ child: Column(
517
+ crossAxisAlignment: CrossAxisAlignment.start,
518
+ children: [
519
+ FuriText(
520
+ text: o.labelTarget,
521
+ segments: o.labelTargetSegments,
522
+ fontSize: 18,
523
+ fontWeight: FontWeight.w900,
 
 
 
 
 
 
 
 
 
524
  ),
525
+ if (o.romanized != null && o.romanized!.isNotEmpty)
526
+ Padding(
527
+ padding: const EdgeInsets.only(top: 4),
528
+ child: Text(
529
+ o.romanized!,
530
+ style: GoogleFonts.almarai(color: kBrutalTeal, fontSize: 12, fontWeight: FontWeight.w800),
531
+ ),
532
+ ),
533
+ const SizedBox(height: 4),
534
+ Text(
535
+ o.labelNative,
536
+ style: GoogleFonts.almarai(color: kBrutalMuted, fontSize: 12, fontWeight: FontWeight.w800),
537
+ ),
538
+ ],
539
+ ),
540
  ),
541
+ const SizedBox(width: 10),
542
+ TtsButton(text: o.labelTarget, lang: lang, size: 34),
543
+ ]),
544
+ ),
545
  ),
546
  ),
547
+ const SizedBox(height: 20),
548
+ Text(
549
+ 'PRACTICE SENTENCES',
 
 
 
550
  style: GoogleFonts.almarai(
551
+ color: kBrutalSlate,
552
+ fontSize: 12,
553
+ fontWeight: FontWeight.w900,
554
+ letterSpacing: 1.2,
555
+ ),
556
+ ),
557
+ const SizedBox(height: 10),
558
+ if (result.sentences.isEmpty)
559
+ Text('No sentences generated.', style: GoogleFonts.almarai(color: kBrutalMuted, fontWeight: FontWeight.w800))
560
+ else
561
+ ...result.sentences.map(
562
+ (s) => Padding(
563
+ padding: const EdgeInsets.only(bottom: 10),
564
+ child: Container(
565
+ padding: const EdgeInsets.all(14),
566
+ decoration: brutalCard(),
567
+ child: Column(
568
+ crossAxisAlignment: CrossAxisAlignment.start,
569
+ children: [
570
+ Row(
571
+ crossAxisAlignment: CrossAxisAlignment.start,
572
+ children: [
573
+ Expanded(
574
+ child: FuriText(
575
+ text: s.target,
576
+ segments: s.targetSegments,
577
+ fontSize: 17,
578
+ fontWeight: FontWeight.w900,
579
+ ),
580
+ ),
581
+ const SizedBox(width: 8),
582
+ TtsButton(text: s.target, lang: lang, size: 32),
583
+ ],
584
+ ),
585
+ if (s.romanized != null && s.romanized!.isNotEmpty) ...[
586
+ const SizedBox(height: 4),
587
+ Text(
588
+ s.romanized!,
589
+ style: GoogleFonts.almarai(
590
+ color: kBrutalTeal,
591
+ fontSize: 12,
592
+ fontWeight: FontWeight.w800,
593
+ fontStyle: FontStyle.italic,
594
  ),
595
  ),
 
 
596
  ],
597
+ const SizedBox(height: 8),
598
+ Text(
599
+ s.gloss,
600
+ style: GoogleFonts.almarai(color: kBrutalMuted, fontSize: 12, fontWeight: FontWeight.w800),
601
+ ),
 
 
 
602
  ],
603
+ ),
 
 
 
 
604
  ),
605
  ),
606
  ),
607
+ const SizedBox(height: 20),
608
+ ],
609
+ );
610
  }
611
  }
 
 
 
 
mobile/lib/features/home/home_shell.dart CHANGED
@@ -1,29 +1,20 @@
1
  import 'package:flutter/material.dart';
2
  import 'package:flutter_riverpod/flutter_riverpod.dart';
 
 
3
 
4
  import '../../auth/auth_provider.dart';
5
- import '../camera/camera_screen.dart';
6
- import '../phrases/phrases_screen.dart';
7
- import '../practice/dialogues_list_screen.dart';
8
- import '../profile/profile_screen.dart';
9
  import '../../theme/app_theme.dart';
10
 
11
  class HomeShell extends ConsumerStatefulWidget {
12
- const HomeShell({super.key});
 
 
13
  @override
14
  ConsumerState<HomeShell> createState() => _HomeShellState();
15
  }
16
 
17
  class _HomeShellState extends ConsumerState<HomeShell> {
18
- int _index = 0;
19
-
20
- static const _pages = [
21
- DialoguesListScreen(),
22
- CameraScreen(),
23
- PhrasesScreen(),
24
- ProfileScreen(),
25
- ];
26
-
27
  @override
28
  void initState() {
29
  super.initState();
@@ -35,35 +26,118 @@ class _HomeShellState extends ConsumerState<HomeShell> {
35
  @override
36
  Widget build(BuildContext context) {
37
  return Scaffold(
38
- body: IndexedStack(index: _index, children: _pages),
39
- bottomNavigationBar: DecoratedBox(
40
- decoration: const BoxDecoration(
41
- color: kCard,
42
- border: Border(top: BorderSide(color: kBorder, width: 2)),
 
43
  ),
44
- child: NavigationBar(
45
- selectedIndex: _index,
46
- onDestinationSelected: (i) => setState(() => _index = i),
47
- destinations: const [
48
- NavigationDestination(
49
- icon: Icon(Icons.chat_bubble_outline),
50
- selectedIcon: Icon(Icons.chat_bubble),
51
- label: 'Practice',
52
- ),
53
- NavigationDestination(
54
- icon: Icon(Icons.camera_alt_outlined),
55
- selectedIcon: Icon(Icons.camera_alt),
56
- label: 'Camera',
57
- ),
58
- NavigationDestination(
59
- icon: Icon(Icons.translate_rounded),
60
- selectedIcon: Icon(Icons.translate),
61
- label: 'Phrases',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  ),
63
- NavigationDestination(
64
- icon: Icon(Icons.person_outline),
65
- selectedIcon: Icon(Icons.person),
66
- label: 'Profile',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  ),
68
  ],
69
  ),
@@ -71,5 +145,3 @@ class _HomeShellState extends ConsumerState<HomeShell> {
71
  );
72
  }
73
  }
74
-
75
-
 
1
  import 'package:flutter/material.dart';
2
  import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+ import 'package:go_router/go_router.dart';
4
+ import 'package:google_fonts/google_fonts.dart';
5
 
6
  import '../../auth/auth_provider.dart';
 
 
 
 
7
  import '../../theme/app_theme.dart';
8
 
9
  class HomeShell extends ConsumerStatefulWidget {
10
+ const HomeShell({super.key, required this.navigationShell});
11
+ final StatefulNavigationShell navigationShell;
12
+
13
  @override
14
  ConsumerState<HomeShell> createState() => _HomeShellState();
15
  }
16
 
17
  class _HomeShellState extends ConsumerState<HomeShell> {
 
 
 
 
 
 
 
 
 
18
  @override
19
  void initState() {
20
  super.initState();
 
26
  @override
27
  Widget build(BuildContext context) {
28
  return Scaffold(
29
+ body: widget.navigationShell,
30
+ bottomNavigationBar: _BrutalNavBar(
31
+ index: widget.navigationShell.currentIndex,
32
+ onTap: (i) => widget.navigationShell.goBranch(
33
+ i,
34
+ initialLocation: i == widget.navigationShell.currentIndex,
35
  ),
36
+ ),
37
+ );
38
+ }
39
+ }
40
+
41
+ class _BrutalNavBar extends StatelessWidget {
42
+ const _BrutalNavBar({required this.index, required this.onTap});
43
+ final int index;
44
+ final ValueChanged<int> onTap;
45
+
46
+ static const _tabs = <_TabItem>[
47
+ // Web order: Phrases, Camera, Practice, Profile (app/(app)/layout.tsx).
48
+ _TabItem(icon: Icons.translate_rounded, label: 'Phrases'),
49
+ _TabItem(icon: Icons.photo_camera_rounded, label: 'Camera'),
50
+ _TabItem(icon: Icons.home_rounded, label: 'Practice'),
51
+ _TabItem(icon: Icons.person_rounded, label: 'Profile'),
52
+ ];
53
+
54
+ @override
55
+ Widget build(BuildContext context) {
56
+ final bottomInset = MediaQuery.of(context).padding.bottom;
57
+ return Container(
58
+ decoration: const BoxDecoration(
59
+ color: kBrutalWhite,
60
+ border: Border(top: BorderSide(color: kBrutalBlack, width: 2)),
61
+ ),
62
+ padding: EdgeInsets.fromLTRB(10, 10, 10, 10 + bottomInset),
63
+ child: Row(
64
+ children: [
65
+ for (var i = 0; i < _tabs.length; i++)
66
+ Expanded(
67
+ child: _NavCell(
68
+ tab: _tabs[i],
69
+ active: i == index,
70
+ onTap: () => onTap(i),
71
+ ),
72
  ),
73
+ ],
74
+ ),
75
+ );
76
+ }
77
+ }
78
+
79
+ class _TabItem {
80
+ const _TabItem({required this.icon, required this.label});
81
+ final IconData icon;
82
+ final String label;
83
+ }
84
+
85
+ class _NavCell extends StatefulWidget {
86
+ const _NavCell({required this.tab, required this.active, required this.onTap});
87
+ final _TabItem tab;
88
+ final bool active;
89
+ final VoidCallback onTap;
90
+
91
+ @override
92
+ State<_NavCell> createState() => _NavCellState();
93
+ }
94
+
95
+ class _NavCellState extends State<_NavCell> {
96
+ bool _pressed = false;
97
+
98
+ @override
99
+ Widget build(BuildContext context) {
100
+ final active = widget.active;
101
+ final fg = active ? kBrutalWhite : kBrutalSlate;
102
+ final bg = active ? kBrutalTeal : Colors.transparent;
103
+ final showShadow = active;
104
+ final offset = _pressed ? 0.0 : 3.0;
105
+
106
+ return GestureDetector(
107
+ onTapDown: (_) => setState(() => _pressed = true),
108
+ onTapUp: (_) => setState(() => _pressed = false),
109
+ onTapCancel: () => setState(() => _pressed = false),
110
+ onTap: widget.onTap,
111
+ behavior: HitTestBehavior.opaque,
112
+ child: AnimatedContainer(
113
+ duration: const Duration(milliseconds: 80),
114
+ margin: const EdgeInsets.symmetric(horizontal: 4),
115
+ padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
116
+ transform: Matrix4.translationValues(
117
+ active && _pressed ? 3 : 0,
118
+ active && _pressed ? 3 : 0,
119
+ 0,
120
+ ),
121
+ decoration: BoxDecoration(
122
+ color: bg,
123
+ borderRadius: BorderRadius.circular(kBrutalRadius),
124
+ border: active ? Border.all(color: kBrutalBlack, width: 2) : null,
125
+ boxShadow: showShadow
126
+ ? [BoxShadow(color: kBrutalBlack, offset: Offset(offset, offset), blurRadius: 0)]
127
+ : null,
128
+ ),
129
+ child: Column(
130
+ mainAxisSize: MainAxisSize.min,
131
+ children: [
132
+ Icon(widget.tab.icon, color: fg, size: 22),
133
+ const SizedBox(height: 2),
134
+ Text(
135
+ widget.tab.label,
136
+ style: GoogleFonts.almarai(
137
+ color: fg,
138
+ fontSize: 11,
139
+ fontWeight: FontWeight.w900,
140
+ ),
141
  ),
142
  ],
143
  ),
 
145
  );
146
  }
147
  }
 
 
mobile/lib/features/login/login_screen.dart CHANGED
@@ -1,10 +1,14 @@
1
  import 'package:flutter/material.dart';
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 '../../theme/app_theme.dart';
7
 
 
 
 
8
  class LoginScreen extends ConsumerStatefulWidget {
9
  const LoginScreen({super.key});
10
 
@@ -15,20 +19,34 @@ class LoginScreen extends ConsumerStatefulWidget {
15
  class _LoginScreenState extends ConsumerState<LoginScreen> with SingleTickerProviderStateMixin {
16
  bool _loading = false;
17
  String? _error;
 
 
 
18
  late final AnimationController _ctrl;
19
- late final Animation<double> _fade;
20
 
21
  @override
22
  void initState() {
23
  super.initState();
24
- _ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 650));
25
- _fade = CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic);
26
  _ctrl.forward();
 
 
 
 
 
 
 
 
 
 
27
  }
28
 
29
  @override
30
  void dispose() {
31
  _ctrl.dispose();
 
32
  super.dispose();
33
  }
34
 
@@ -39,140 +57,222 @@ class _LoginScreenState extends ConsumerState<LoginScreen> with SingleTickerProv
39
  });
40
  try {
41
  await ref.read(authControllerProvider.notifier).signInWithHuggingFace();
42
- } catch (_) {
43
- setState(() => _error = 'Sign in failed. Please try again.');
 
 
44
  } finally {
45
  if (mounted) setState(() => _loading = false);
46
  }
47
  }
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  @override
50
  Widget build(BuildContext context) {
 
 
 
51
  return Scaffold(
52
- body: SafeArea(
53
- child: Center(
54
- child: ConstrainedBox(
55
- constraints: const BoxConstraints(maxWidth: 520),
56
- child: FadeTransition(
57
- opacity: _fade,
58
- child: ListView(
59
- padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
60
- children: [
61
- const SizedBox(height: 24),
62
- const Center(child: _BrandMark()),
63
- const SizedBox(height: 28),
64
- Text(
65
- 'PraxaLing',
66
- textAlign: TextAlign.center,
67
- style: GoogleFonts.almarai(
68
- color: kForeground,
69
- fontSize: 46,
70
- fontWeight: FontWeight.w900,
71
- height: 1,
72
- ),
73
- ),
74
- const SizedBox(height: 14),
75
- Text(
76
- 'Quick dialogues, phrase breakdowns, and photo vocabulary in one daily lesson flow.',
77
- textAlign: TextAlign.center,
78
- style: GoogleFonts.almarai(
79
- color: kMuted,
80
- fontSize: 16,
81
- fontWeight: FontWeight.w800,
82
- height: 1.35,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  ),
84
- ),
85
- const SizedBox(height: 40),
86
- FilledButton.icon(
87
- onPressed: _loading ? null : _signIn,
88
- icon: _loading
89
- ? const SizedBox(
90
- width: 18,
91
- height: 18,
92
- child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
93
- )
94
- : const Icon(Icons.play_arrow_rounded),
95
- label: Text(_loading ? 'Opening browser...' : 'Start learning'),
96
- ),
97
- const SizedBox(height: 14),
98
- OutlinedButton(
99
- onPressed: _loading ? null : _signIn,
100
- child: const Text('I already have an account'),
101
- ),
102
- if (_error != null) ...[
103
- const SizedBox(height: 16),
104
- Container(
105
- padding: const EdgeInsets.all(12),
106
- decoration: BoxDecoration(
107
- color: const Color(0xFFFFE4E6),
108
- borderRadius: BorderRadius.circular(16),
109
- border: Border.all(color: const Color(0xFFFECDD3), width: 2),
110
  ),
111
- child: Text(
112
- _error!,
113
- textAlign: TextAlign.center,
114
- style: GoogleFonts.almarai(color: kDangerShadow, fontWeight: FontWeight.w900),
 
 
 
 
 
 
 
 
 
 
 
115
  ),
116
  ),
117
  ],
118
- const SizedBox(height: 18),
119
- Text(
120
- 'We use your Hugging Face account for sign in.',
121
- textAlign: TextAlign.center,
122
- style: GoogleFonts.almarai(color: kMuted, fontSize: 12, fontWeight: FontWeight.w700),
123
- ),
124
- ],
125
  ),
126
  ),
127
  ),
128
- ),
129
  ),
130
  );
131
  }
132
  }
133
 
134
- class _BrandMark extends StatelessWidget {
135
- const _BrandMark();
 
 
 
 
 
 
 
 
 
136
 
137
  @override
138
  Widget build(BuildContext context) {
139
- return Container(
140
- width: 112,
141
- height: 112,
142
- decoration: BoxDecoration(
143
- color: kPrimary,
144
- borderRadius: BorderRadius.circular(30),
145
- boxShadow: const [BoxShadow(color: kPrimaryShadow, offset: Offset(0, 6), blurRadius: 0)],
146
- ),
147
- child: Stack(
148
- alignment: Alignment.center,
149
- children: [
150
- Container(
151
- width: 68,
152
- height: 68,
153
- decoration: BoxDecoration(
154
- color: Colors.white,
155
- borderRadius: BorderRadius.circular(22),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  ),
157
- child: const Icon(Icons.explore_rounded, color: kSecondary, size: 42),
158
- ),
159
- Positioned(
160
- right: 19,
161
- top: 21,
162
- child: Container(
163
- width: 14,
164
- height: 14,
165
- decoration: const BoxDecoration(
166
- color: kWarning,
167
- shape: BoxShape.circle,
168
  ),
 
 
 
 
 
 
 
 
169
  ),
170
- ),
171
- ],
172
  ),
173
  );
174
  }
175
  }
176
 
177
-
178
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import 'package:flutter/material.dart';
2
  import 'package:flutter_riverpod/flutter_riverpod.dart';
3
  import 'package:google_fonts/google_fonts.dart';
4
+ import 'package:video_player/video_player.dart';
5
 
6
  import '../../auth/auth_provider.dart';
7
  import '../../theme/app_theme.dart';
8
 
9
+ const _kHeroVideoUrl =
10
+ 'https://d8j0ntlcm91z4.cloudfront.net/user_34DpnLwtmxkLgtVe8psPn1j2G8i/hf_20260505_151902_782a6426-d5ef-40eb-b482-d807701834c1.mp4';
11
+
12
  class LoginScreen extends ConsumerStatefulWidget {
13
  const LoginScreen({super.key});
14
 
 
19
  class _LoginScreenState extends ConsumerState<LoginScreen> with SingleTickerProviderStateMixin {
20
  bool _loading = false;
21
  String? _error;
22
+ VideoPlayerController? _video;
23
+ bool _videoReady = false;
24
+
25
  late final AnimationController _ctrl;
26
+ late final Animation<double> _fadeContent;
27
 
28
  @override
29
  void initState() {
30
  super.initState();
31
+ _ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 900));
32
+ _fadeContent = CurvedAnimation(parent: _ctrl, curve: const Interval(0.25, 1.0, curve: Curves.easeOutCubic));
33
  _ctrl.forward();
34
+
35
+ final controller = VideoPlayerController.networkUrl(Uri.parse(_kHeroVideoUrl));
36
+ _video = controller;
37
+ controller.initialize().then((_) async {
38
+ if (!mounted) return;
39
+ await controller.setLooping(true);
40
+ await controller.setVolume(0);
41
+ await controller.play();
42
+ if (mounted) setState(() => _videoReady = true);
43
+ }).catchError((_) {});
44
  }
45
 
46
  @override
47
  void dispose() {
48
  _ctrl.dispose();
49
+ _video?.dispose();
50
  super.dispose();
51
  }
52
 
 
57
  });
58
  try {
59
  await ref.read(authControllerProvider.notifier).signInWithHuggingFace();
60
+ } catch (e) {
61
+ debugPrint('[login] sign-in failed: $e');
62
+ if (!mounted) return;
63
+ setState(() => _error = _humanError(e));
64
  } finally {
65
  if (mounted) setState(() => _loading = false);
66
  }
67
  }
68
 
69
+ String _humanError(Object e) {
70
+ final s = e.toString();
71
+ if (s.contains('CANCELED') || s.contains('canceled')) {
72
+ return 'Sign in canceled.';
73
+ }
74
+ if (s.contains('Failed host lookup') || s.contains('SocketException')) {
75
+ return 'No internet connection.';
76
+ }
77
+ if (s.contains('callback missing token')) {
78
+ return 'Sign in failed: callback did not include a token.';
79
+ }
80
+ return 'Sign in failed.';
81
+ }
82
+
83
  @override
84
  Widget build(BuildContext context) {
85
+ final size = MediaQuery.of(context).size;
86
+ final wordmarkSize = (size.width * 0.16).clamp(44.0, 88.0);
87
+
88
  return Scaffold(
89
+ backgroundColor: Colors.black,
90
+ body: Stack(
91
+ fit: StackFit.expand,
92
+ children: [
93
+ if (_videoReady && _video != null)
94
+ FittedBox(
95
+ fit: BoxFit.cover,
96
+ child: SizedBox(
97
+ width: _video!.value.size.width,
98
+ height: _video!.value.size.height,
99
+ child: VideoPlayer(_video!),
100
+ ),
101
+ ),
102
+ const DecoratedBox(
103
+ decoration: BoxDecoration(
104
+ gradient: LinearGradient(
105
+ begin: Alignment.topCenter,
106
+ end: Alignment.bottomCenter,
107
+ colors: [Color(0x4D000000), Colors.transparent, Color(0x99000000)],
108
+ stops: [0.0, 0.45, 1.0],
109
+ ),
110
+ ),
111
+ child: SizedBox.expand(),
112
+ ),
113
+ SafeArea(
114
+ child: Padding(
115
+ padding: const EdgeInsets.fromLTRB(20, 20, 20, 28),
116
+ child: FadeTransition(
117
+ opacity: _fadeContent,
118
+ child: Column(
119
+ crossAxisAlignment: CrossAxisAlignment.start,
120
+ mainAxisAlignment: MainAxisAlignment.end,
121
+ children: [
122
+ RichText(
123
+ text: TextSpan(
124
+ style: GoogleFonts.almarai(
125
+ color: const Color(0xFFE1E0CC),
126
+ fontWeight: FontWeight.w500,
127
+ height: 0.85,
128
+ fontSize: wordmarkSize,
129
+ letterSpacing: 0,
130
+ ),
131
+ children: [
132
+ const TextSpan(text: 'PraxaLing'),
133
+ TextSpan(
134
+ text: '*',
135
+ style: TextStyle(
136
+ fontSize: wordmarkSize * 0.31,
137
+ height: 1,
138
+ ),
139
+ ),
140
+ ],
141
+ ),
142
  ),
143
+ const SizedBox(height: 18),
144
+ Text(
145
+ 'Practicing languages using PraxaLing.',
146
+ style: GoogleFonts.almarai(
147
+ color: Colors.white,
148
+ fontSize: 14,
149
+ fontWeight: FontWeight.w700,
150
+ height: 1.3,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  ),
152
+ ),
153
+ const SizedBox(height: 22),
154
+ _BrutalHFButton(loading: _loading, onTap: _loading ? null : _signIn),
155
+ if (_error != null) ...[
156
+ const SizedBox(height: 16),
157
+ _BrutalErrorBanner(message: _error!),
158
+ ],
159
+ const SizedBox(height: 14),
160
+ Text(
161
+ 'WE USE YOUR HUGGING FACE ACCOUNT FOR SIGN IN.',
162
+ style: GoogleFonts.almarai(
163
+ color: const Color(0xFFE1E0CC),
164
+ fontSize: 10,
165
+ fontWeight: FontWeight.w900,
166
+ letterSpacing: 1.2,
167
  ),
168
  ),
169
  ],
170
+ ),
 
 
 
 
 
 
171
  ),
172
  ),
173
  ),
174
+ ],
175
  ),
176
  );
177
  }
178
  }
179
 
180
+ class _BrutalHFButton extends StatefulWidget {
181
+ const _BrutalHFButton({required this.loading, required this.onTap});
182
+ final bool loading;
183
+ final VoidCallback? onTap;
184
+
185
+ @override
186
+ State<_BrutalHFButton> createState() => _BrutalHFButtonState();
187
+ }
188
+
189
+ class _BrutalHFButtonState extends State<_BrutalHFButton> {
190
+ bool _pressed = false;
191
 
192
  @override
193
  Widget build(BuildContext context) {
194
+ final disabled = widget.onTap == null;
195
+ final offset = _pressed ? 0.0 : 4.0;
196
+
197
+ return GestureDetector(
198
+ onTapDown: disabled ? null : (_) => setState(() => _pressed = true),
199
+ onTapUp: disabled ? null : (_) => setState(() => _pressed = false),
200
+ onTapCancel: disabled ? null : () => setState(() => _pressed = false),
201
+ onTap: widget.onTap,
202
+ child: AnimatedContainer(
203
+ duration: const Duration(milliseconds: 80),
204
+ transform: Matrix4.translationValues(_pressed ? 4 : 0, _pressed ? 4 : 0, 0),
205
+ padding: const EdgeInsets.fromLTRB(20, 10, 10, 10),
206
+ decoration: BoxDecoration(
207
+ color: kBrutalWhite,
208
+ borderRadius: BorderRadius.circular(kBrutalRadius),
209
+ border: Border.all(color: kBrutalBlack, width: 3),
210
+ boxShadow: [
211
+ BoxShadow(color: kBrutalBlack, offset: Offset(offset, offset), blurRadius: 0),
212
+ ],
213
+ ),
214
+ child: Row(
215
+ mainAxisSize: MainAxisSize.min,
216
+ children: [
217
+ const Text('🤗', style: TextStyle(fontSize: 22)),
218
+ const SizedBox(width: 10),
219
+ Text(
220
+ widget.loading ? 'OPENING BROWSER…' : 'START WITH HUGGING FACE',
221
+ style: GoogleFonts.almarai(
222
+ color: kBrutalBlack,
223
+ fontWeight: FontWeight.w900,
224
+ fontSize: 14,
225
+ letterSpacing: 0.5,
226
+ ),
227
  ),
228
+ const SizedBox(width: 14),
229
+ Container(
230
+ width: 38,
231
+ height: 38,
232
+ decoration: BoxDecoration(
233
+ color: kBrutalBlack,
234
+ borderRadius: BorderRadius.circular(kBrutalRadius),
 
 
 
 
235
  ),
236
+ alignment: Alignment.center,
237
+ child: widget.loading
238
+ ? const SizedBox(
239
+ width: 16,
240
+ height: 16,
241
+ child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
242
+ )
243
+ : const Icon(Icons.arrow_forward_rounded, color: Colors.white, size: 20),
244
  ),
245
+ ],
246
+ ),
247
  ),
248
  );
249
  }
250
  }
251
 
252
+ class _BrutalErrorBanner extends StatelessWidget {
253
+ const _BrutalErrorBanner({required this.message});
254
+ final String message;
255
+
256
+ @override
257
+ Widget build(BuildContext context) {
258
+ return Container(
259
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
260
+ decoration: BoxDecoration(
261
+ color: kBrutalRed,
262
+ borderRadius: BorderRadius.circular(kBrutalRadius),
263
+ border: Border.all(color: kBrutalBlack, width: 2),
264
+ boxShadow: const [
265
+ BoxShadow(color: kBrutalBlack, offset: Offset(4, 4), blurRadius: 0),
266
+ ],
267
+ ),
268
+ child: Text(
269
+ message,
270
+ style: GoogleFonts.almarai(
271
+ color: kBrutalWhite,
272
+ fontWeight: FontWeight.w900,
273
+ fontSize: 13,
274
+ ),
275
+ ),
276
+ );
277
+ }
278
+ }
mobile/lib/features/onboarding/onboarding_screen.dart CHANGED
@@ -1,3 +1,6 @@
 
 
 
1
  import 'package:flutter/material.dart';
2
  import 'package:flutter_riverpod/flutter_riverpod.dart';
3
  import 'package:google_fonts/google_fonts.dart';
@@ -5,6 +8,7 @@ import 'package:google_fonts/google_fonts.dart';
5
  import '../../auth/auth_provider.dart';
6
  import '../../core/languages.dart';
7
  import '../../theme/app_theme.dart';
 
8
 
9
  class OnboardingScreen extends ConsumerStatefulWidget {
10
  const OnboardingScreen({super.key});
@@ -14,15 +18,38 @@ class OnboardingScreen extends ConsumerStatefulWidget {
14
  }
15
 
16
  class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
17
- String _native = 'en';
18
- String _target = 'es';
 
19
  String _level = 'A1';
20
  bool _saving = false;
21
  String? _error;
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  Future<void> _save() async {
24
- if (_native == _target) {
25
- setState(() => _error = 'Native and target languages must differ.');
26
  return;
27
  }
28
  setState(() {
@@ -32,7 +59,8 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
32
  try {
33
  await ref.read(authControllerProvider.notifier).saveProfile(
34
  nativeLang: _native,
35
- targetLang: _target,
 
36
  level: _level,
37
  );
38
  } catch (e) {
@@ -45,121 +73,81 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
45
  @override
46
  Widget build(BuildContext context) {
47
  return Scaffold(
 
48
  body: SafeArea(
49
  child: Center(
50
  child: ConstrainedBox(
51
  constraints: const BoxConstraints(maxWidth: 720),
52
  child: ListView(
53
- padding: const EdgeInsets.all(20),
54
  children: [
55
- Container(
56
- decoration: duoCardDecoration(),
57
- clipBehavior: Clip.antiAlias,
58
  child: Column(
59
  crossAxisAlignment: CrossAxisAlignment.stretch,
60
  children: [
61
- Container(
62
- color: kPrimary,
63
- padding: const EdgeInsets.all(22),
64
- child: Row(
65
- children: [
66
- Container(
67
- width: 54,
68
- height: 54,
69
- decoration: BoxDecoration(
70
- color: Colors.white,
71
- borderRadius: BorderRadius.circular(18),
72
- boxShadow: const [
73
- BoxShadow(color: kPrimaryShadow, offset: Offset(0, 4), blurRadius: 0),
74
- ],
75
- ),
76
- child: const Icon(Icons.auto_awesome, color: kPrimaryShadow),
77
- ),
78
- const SizedBox(width: 14),
79
- Expanded(
80
- child: Column(
81
- crossAxisAlignment: CrossAxisAlignment.start,
82
- children: [
83
- Text(
84
- 'SET UP YOUR COURSE',
85
- style: GoogleFonts.almarai(
86
- color: Colors.white.withAlpha(210),
87
- fontSize: 11,
88
- fontWeight: FontWeight.w900,
89
- letterSpacing: 0,
90
- ),
91
- ),
92
- Text(
93
- 'Pick your path',
94
- style: GoogleFonts.almarai(
95
- color: Colors.white,
96
- fontSize: 28,
97
- fontWeight: FontWeight.w900,
98
- height: 1.1,
99
- ),
100
- ),
101
- ],
102
- ),
103
- ),
104
- ],
105
- ),
106
- ),
107
  Padding(
108
- padding: const EdgeInsets.all(20),
109
  child: Column(
110
  crossAxisAlignment: CrossAxisAlignment.stretch,
111
  children: [
112
- _SectionLabel('I speak'),
113
- const SizedBox(height: 8),
114
- _langDropdown(_native, (v) => setState(() => _native = v)),
115
- const SizedBox(height: 18),
116
- _SectionLabel('I want to learn'),
117
- const SizedBox(height: 8),
118
- _langDropdown(_target, (v) => setState(() => _target = v), exclude: _native),
119
- const SizedBox(height: 22),
120
- _SectionLabel('My current level'),
121
  const SizedBox(height: 10),
122
- LayoutBuilder(
123
- builder: (context, constraints) {
124
- final twoColumns = constraints.maxWidth >= 520;
125
- return GridView.count(
126
- crossAxisCount: twoColumns ? 5 : 2,
127
- shrinkWrap: true,
128
- mainAxisSpacing: 10,
129
- crossAxisSpacing: 10,
130
- physics: const NeverScrollableScrollPhysics(),
131
- childAspectRatio: twoColumns ? 1.05 : 2.1,
132
- children: kLevels.map((level) {
133
- final selected = _level == level;
134
- return _LevelTile(
135
- level: level,
136
- description: kLevelDescriptions[level] ?? '',
137
- selected: selected,
138
- onTap: () => setState(() => _level = level),
139
- );
140
- }).toList(),
141
- );
142
  },
143
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  if (_error != null) ...[
145
  const SizedBox(height: 18),
146
  Container(
147
  padding: const EdgeInsets.all(12),
148
  decoration: BoxDecoration(
149
- color: const Color(0xFFFFE4E6),
150
- borderRadius: BorderRadius.circular(16),
151
- border: Border.all(color: const Color(0xFFFECDD3), width: 2),
 
 
 
152
  ),
153
  child: Text(
154
  _error!,
155
- style: GoogleFonts.almarai(color: kDangerShadow, fontWeight: FontWeight.w900),
156
  ),
157
  ),
158
  ],
159
- const SizedBox(height: 22),
160
  FilledButton(
161
  onPressed: _saving ? null : _save,
162
- child: Text(_saving ? 'Saving...' : 'Start learning'),
163
  ),
164
  ],
165
  ),
@@ -174,17 +162,80 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
174
  ),
175
  );
176
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
 
178
- Widget _langDropdown(String value, ValueChanged<String> onChanged, {String? exclude}) {
179
- final items = kLanguages.where((language) => language.code != exclude).toList();
180
- return DropdownButtonFormField<String>(
181
- initialValue: value,
182
- icon: const Icon(Icons.keyboard_arrow_down_rounded, color: kMuted),
183
- decoration: const InputDecoration(),
184
- items: items.map((language) {
185
- return DropdownMenuItem(value: language.code, child: Text(language.name));
186
- }).toList(),
187
- onChanged: (v) => v == null ? null : onChanged(v),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  );
189
  }
190
  }
@@ -197,57 +248,201 @@ class _SectionLabel extends StatelessWidget {
197
  Widget build(BuildContext context) {
198
  return Text(
199
  text,
200
- style: GoogleFonts.almarai(color: kForeground, fontWeight: FontWeight.w900, fontSize: 15),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  );
202
  }
203
  }
204
 
205
- class _LevelTile extends StatelessWidget {
206
- const _LevelTile({
207
- required this.level,
208
- required this.description,
209
  required this.selected,
 
210
  required this.onTap,
211
  });
212
 
213
- final String level;
214
- final String description;
215
  final bool selected;
 
216
  final VoidCallback onTap;
217
 
 
 
 
 
 
 
 
218
  @override
219
  Widget build(BuildContext context) {
220
- return InkWell(
221
- borderRadius: BorderRadius.circular(18),
222
- onTap: onTap,
 
 
 
 
 
 
 
 
 
 
 
223
  child: AnimatedContainer(
224
- duration: const Duration(milliseconds: 180),
225
- padding: const EdgeInsets.all(12),
 
226
  decoration: BoxDecoration(
227
- color: selected ? const Color(0xFFEDE9FE) : kCard,
228
- borderRadius: BorderRadius.circular(18),
229
- border: Border.all(color: selected ? kSecondary : kBorder, width: 2),
230
- boxShadow: [BoxShadow(color: selected ? kSecondaryShadow : kBorder, offset: const Offset(0, 4), blurRadius: 0)],
 
 
231
  ),
232
- child: Column(
233
- mainAxisAlignment: MainAxisAlignment.center,
234
- crossAxisAlignment: CrossAxisAlignment.start,
235
  children: [
236
- Text(level, style: GoogleFonts.almarai(color: kForeground, fontWeight: FontWeight.w900, fontSize: 18)),
237
- const SizedBox(height: 3),
238
- Flexible(
239
- child: Text(
240
- description,
241
- maxLines: 2,
242
- overflow: TextOverflow.ellipsis,
243
- style: GoogleFonts.almarai(color: kMuted, fontWeight: FontWeight.w800, fontSize: 11, height: 1.15),
 
 
 
 
 
 
 
 
 
244
  ),
245
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  ],
247
  ),
248
  ),
249
  );
250
  }
251
  }
252
-
253
-
 
1
+ import 'dart:ui' as ui;
2
+
3
+ import 'package:country_flags/country_flags.dart';
4
  import 'package:flutter/material.dart';
5
  import 'package:flutter_riverpod/flutter_riverpod.dart';
6
  import 'package:google_fonts/google_fonts.dart';
 
8
  import '../../auth/auth_provider.dart';
9
  import '../../core/languages.dart';
10
  import '../../theme/app_theme.dart';
11
+ import '../../widgets/level_picker.dart';
12
 
13
  class OnboardingScreen extends ConsumerStatefulWidget {
14
  const OnboardingScreen({super.key});
 
18
  }
19
 
20
  class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
21
+ late String _native;
22
+ // Ordered list of selected target languages. First entry = primary target.
23
+ final List<String> _targets = [];
24
  String _level = 'A1';
25
  bool _saving = false;
26
  String? _error;
27
 
28
+ @override
29
+ void initState() {
30
+ super.initState();
31
+ final deviceLocale = ui.PlatformDispatcher.instance.locale.languageCode;
32
+ _native = kLanguages.any((l) => l.code == deviceLocale) ? deviceLocale : 'en';
33
+ }
34
+
35
+ void _toggleTarget(String code) {
36
+ if (code == _native) {
37
+ setState(() => _error = 'You already speak that one.');
38
+ return;
39
+ }
40
+ setState(() {
41
+ _error = null;
42
+ if (_targets.contains(code)) {
43
+ _targets.remove(code);
44
+ } else {
45
+ _targets.add(code);
46
+ }
47
+ });
48
+ }
49
+
50
  Future<void> _save() async {
51
+ if (_targets.isEmpty) {
52
+ setState(() => _error = 'Pick at least one language to learn.');
53
  return;
54
  }
55
  setState(() {
 
59
  try {
60
  await ref.read(authControllerProvider.notifier).saveProfile(
61
  nativeLang: _native,
62
+ targetLang: _targets.first,
63
+ targetLangs: _targets,
64
  level: _level,
65
  );
66
  } catch (e) {
 
73
  @override
74
  Widget build(BuildContext context) {
75
  return Scaffold(
76
+ backgroundColor: kBrutalCream,
77
  body: SafeArea(
78
  child: Center(
79
  child: ConstrainedBox(
80
  constraints: const BoxConstraints(maxWidth: 720),
81
  child: ListView(
82
+ padding: const EdgeInsets.fromLTRB(16, 24, 16, 32),
83
  children: [
84
+ _BrutalCard(
 
 
85
  child: Column(
86
  crossAxisAlignment: CrossAxisAlignment.stretch,
87
  children: [
88
+ _Header(),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  Padding(
90
+ padding: const EdgeInsets.fromLTRB(20, 22, 20, 22),
91
  child: Column(
92
  crossAxisAlignment: CrossAxisAlignment.stretch,
93
  children: [
94
+ const _SectionLabel('I SPEAK'),
 
 
 
 
 
 
 
 
95
  const SizedBox(height: 10),
96
+ _NativeLanguageDropdown(
97
+ value: _native,
98
+ onChanged: (v) {
99
+ setState(() {
100
+ _native = v;
101
+ _targets.remove(v);
102
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  },
104
  ),
105
+ const SizedBox(height: 24),
106
+ const _SectionLabel('I WANT TO LEARN'),
107
+ const SizedBox(height: 6),
108
+ Text(
109
+ 'Tap to add. The first you pick is your primary.',
110
+ style: GoogleFonts.almarai(
111
+ color: kBrutalMuted,
112
+ fontWeight: FontWeight.w800,
113
+ fontSize: 12,
114
+ ),
115
+ ),
116
+ const SizedBox(height: 12),
117
+ _TargetLanguageGrid(
118
+ languages: kLanguages.where((l) => l.code != _native).toList(),
119
+ selected: _targets,
120
+ onToggle: _toggleTarget,
121
+ ),
122
+ const SizedBox(height: 24),
123
+ const _SectionLabel('MY CURRENT LEVEL'),
124
+ const SizedBox(height: 12),
125
+ LevelPicker(
126
+ value: _level,
127
+ onChanged: (v) => setState(() => _level = v),
128
+ ),
129
  if (_error != null) ...[
130
  const SizedBox(height: 18),
131
  Container(
132
  padding: const EdgeInsets.all(12),
133
  decoration: BoxDecoration(
134
+ color: kBrutalRed,
135
+ borderRadius: BorderRadius.circular(kBrutalRadius),
136
+ border: Border.all(color: kBrutalBlack, width: 2),
137
+ boxShadow: const [
138
+ BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0),
139
+ ],
140
  ),
141
  child: Text(
142
  _error!,
143
+ style: GoogleFonts.almarai(color: kBrutalWhite, fontWeight: FontWeight.w900),
144
  ),
145
  ),
146
  ],
147
+ const SizedBox(height: 24),
148
  FilledButton(
149
  onPressed: _saving ? null : _save,
150
+ child: Text(_saving ? 'SAVING…' : 'START LEARNING'),
151
  ),
152
  ],
153
  ),
 
162
  ),
163
  );
164
  }
165
+ }
166
+
167
+ class _BrutalCard extends StatelessWidget {
168
+ const _BrutalCard({required this.child});
169
+ final Widget child;
170
+
171
+ @override
172
+ Widget build(BuildContext context) {
173
+ return Container(
174
+ decoration: BoxDecoration(
175
+ color: kBrutalWhite,
176
+ borderRadius: BorderRadius.circular(kBrutalRadius),
177
+ border: Border.all(color: kBrutalBlack, width: 3),
178
+ boxShadow: const [
179
+ BoxShadow(color: kBrutalBlack, offset: Offset(6, 6), blurRadius: 0),
180
+ ],
181
+ ),
182
+ clipBehavior: Clip.antiAlias,
183
+ child: child,
184
+ );
185
+ }
186
+ }
187
 
188
+ class _Header extends StatelessWidget {
189
+ @override
190
+ Widget build(BuildContext context) {
191
+ return Container(
192
+ color: kBrutalTeal,
193
+ padding: const EdgeInsets.fromLTRB(20, 22, 20, 22),
194
+ child: Row(
195
+ children: [
196
+ Container(
197
+ width: 56,
198
+ height: 56,
199
+ decoration: BoxDecoration(
200
+ color: kBrutalWhite,
201
+ borderRadius: BorderRadius.circular(kBrutalRadius),
202
+ border: Border.all(color: kBrutalBlack, width: 2),
203
+ boxShadow: const [
204
+ BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0),
205
+ ],
206
+ ),
207
+ child: const Icon(Icons.translate_rounded, color: kBrutalBlack),
208
+ ),
209
+ const SizedBox(width: 14),
210
+ Expanded(
211
+ child: Column(
212
+ crossAxisAlignment: CrossAxisAlignment.start,
213
+ children: [
214
+ Text(
215
+ 'WELCOME TO PRAXALING',
216
+ style: GoogleFonts.almarai(
217
+ color: kBrutalWhite,
218
+ fontSize: 11,
219
+ fontWeight: FontWeight.w900,
220
+ letterSpacing: 1.4,
221
+ ),
222
+ ),
223
+ const SizedBox(height: 2),
224
+ Text(
225
+ 'SET UP YOUR COURSE',
226
+ style: GoogleFonts.almarai(
227
+ color: kBrutalWhite,
228
+ fontSize: 24,
229
+ fontWeight: FontWeight.w900,
230
+ height: 1.05,
231
+ letterSpacing: 0,
232
+ ),
233
+ ),
234
+ ],
235
+ ),
236
+ ),
237
+ ],
238
+ ),
239
  );
240
  }
241
  }
 
248
  Widget build(BuildContext context) {
249
  return Text(
250
  text,
251
+ style: GoogleFonts.almarai(
252
+ color: kBrutalSlate,
253
+ fontWeight: FontWeight.w900,
254
+ fontSize: 12,
255
+ letterSpacing: 1.2,
256
+ ),
257
+ );
258
+ }
259
+ }
260
+
261
+ class _NativeLanguageDropdown extends StatelessWidget {
262
+ const _NativeLanguageDropdown({required this.value, required this.onChanged});
263
+ final String value;
264
+ final ValueChanged<String> onChanged;
265
+
266
+ @override
267
+ Widget build(BuildContext context) {
268
+ return Container(
269
+ decoration: BoxDecoration(
270
+ color: kBrutalWhite,
271
+ borderRadius: BorderRadius.circular(kBrutalRadius),
272
+ border: Border.all(color: kBrutalBlack, width: 2),
273
+ boxShadow: const [
274
+ BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0),
275
+ ],
276
+ ),
277
+ padding: const EdgeInsets.symmetric(horizontal: 14),
278
+ child: DropdownButtonHideUnderline(
279
+ child: DropdownButton<String>(
280
+ value: value,
281
+ isExpanded: true,
282
+ icon: const Icon(Icons.keyboard_arrow_down_rounded, color: kBrutalBlack),
283
+ style: GoogleFonts.almarai(color: kBrutalBlack, fontWeight: FontWeight.w900, fontSize: 16),
284
+ items: kLanguages.map((language) {
285
+ return DropdownMenuItem(
286
+ value: language.code,
287
+ child: Row(
288
+ children: [
289
+ SizedBox(width: 24, height: 18, child: CountryFlag.fromCountryCode(language.flagCode)),
290
+ const SizedBox(width: 12),
291
+ Text(language.name),
292
+ ],
293
+ ),
294
+ );
295
+ }).toList(),
296
+ onChanged: (v) => v == null ? null : onChanged(v),
297
+ ),
298
+ ),
299
+ );
300
+ }
301
+ }
302
+
303
+ class _TargetLanguageGrid extends StatelessWidget {
304
+ const _TargetLanguageGrid({
305
+ required this.languages,
306
+ required this.selected,
307
+ required this.onToggle,
308
+ });
309
+
310
+ final List<LanguageOption> languages;
311
+ final List<String> selected;
312
+ final ValueChanged<String> onToggle;
313
+
314
+ @override
315
+ Widget build(BuildContext context) {
316
+ return LayoutBuilder(
317
+ builder: (context, constraints) {
318
+ final cols = constraints.maxWidth >= 480 ? 3 : 2;
319
+ return GridView.builder(
320
+ shrinkWrap: true,
321
+ physics: const NeverScrollableScrollPhysics(),
322
+ itemCount: languages.length,
323
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
324
+ crossAxisCount: cols,
325
+ mainAxisExtent: 88,
326
+ crossAxisSpacing: 10,
327
+ mainAxisSpacing: 10,
328
+ ),
329
+ itemBuilder: (context, i) {
330
+ final lang = languages[i];
331
+ final idx = selected.indexOf(lang.code);
332
+ final isSelected = idx >= 0;
333
+ final isPrimary = idx == 0;
334
+ return _LanguageChip(
335
+ language: lang,
336
+ selected: isSelected,
337
+ primary: isPrimary,
338
+ onTap: () => onToggle(lang.code),
339
+ );
340
+ },
341
+ );
342
+ },
343
  );
344
  }
345
  }
346
 
347
+ class _LanguageChip extends StatefulWidget {
348
+ const _LanguageChip({
349
+ required this.language,
 
350
  required this.selected,
351
+ required this.primary,
352
  required this.onTap,
353
  });
354
 
355
+ final LanguageOption language;
 
356
  final bool selected;
357
+ final bool primary;
358
  final VoidCallback onTap;
359
 
360
+ @override
361
+ State<_LanguageChip> createState() => _LanguageChipState();
362
+ }
363
+
364
+ class _LanguageChipState extends State<_LanguageChip> {
365
+ bool _pressed = false;
366
+
367
  @override
368
  Widget build(BuildContext context) {
369
+ final selected = widget.selected;
370
+ final fill = widget.primary
371
+ ? kBrutalTeal
372
+ : selected
373
+ ? kBrutalYellow
374
+ : kBrutalWhite;
375
+ final fg = widget.primary ? kBrutalWhite : kBrutalBlack;
376
+ final offset = _pressed ? 0.0 : (selected ? 4.0 : 3.0);
377
+
378
+ return GestureDetector(
379
+ onTapDown: (_) => setState(() => _pressed = true),
380
+ onTapUp: (_) => setState(() => _pressed = false),
381
+ onTapCancel: () => setState(() => _pressed = false),
382
+ onTap: widget.onTap,
383
  child: AnimatedContainer(
384
+ duration: const Duration(milliseconds: 80),
385
+ transform: Matrix4.translationValues(_pressed ? offset : 0, _pressed ? offset : 0, 0),
386
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
387
  decoration: BoxDecoration(
388
+ color: fill,
389
+ borderRadius: BorderRadius.circular(kBrutalRadius),
390
+ border: Border.all(color: kBrutalBlack, width: 2),
391
+ boxShadow: [
392
+ BoxShadow(color: kBrutalBlack, offset: Offset(offset, offset), blurRadius: 0),
393
+ ],
394
  ),
395
+ child: Stack(
 
 
396
  children: [
397
+ Center(
398
+ child: Column(
399
+ mainAxisAlignment: MainAxisAlignment.center,
400
+ children: [
401
+ SizedBox(width: 28, height: 20, child: CountryFlag.fromCountryCode(widget.language.flagCode)),
402
+ const SizedBox(height: 6),
403
+ Text(
404
+ widget.language.name,
405
+ maxLines: 1,
406
+ overflow: TextOverflow.ellipsis,
407
+ style: GoogleFonts.almarai(
408
+ color: fg,
409
+ fontWeight: FontWeight.w900,
410
+ fontSize: 12,
411
+ ),
412
+ ),
413
+ ],
414
  ),
415
  ),
416
+ if (widget.primary)
417
+ Positioned(
418
+ top: 0,
419
+ right: 0,
420
+ child: Container(
421
+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
422
+ decoration: BoxDecoration(
423
+ color: kBrutalBlack,
424
+ borderRadius: BorderRadius.circular(2),
425
+ ),
426
+ child: Text(
427
+ 'PRIMARY',
428
+ style: GoogleFonts.almarai(
429
+ color: kBrutalWhite,
430
+ fontSize: 8,
431
+ fontWeight: FontWeight.w900,
432
+ letterSpacing: 0.5,
433
+ ),
434
+ ),
435
+ ),
436
+ )
437
+ else if (selected)
438
+ const Positioned(
439
+ top: 2,
440
+ right: 2,
441
+ child: Icon(Icons.check_circle_rounded, color: kBrutalBlack, size: 16),
442
+ ),
443
  ],
444
  ),
445
  ),
446
  );
447
  }
448
  }
 
 
mobile/lib/features/phrases/phrases_screen.dart CHANGED
@@ -9,9 +9,6 @@ import '../../widgets/level_picker.dart';
9
  import 'widgets/furi_text.dart';
10
  import 'widgets/tts_button.dart';
11
 
12
- const _phraseFill = Color(0xFFE8F7F6);
13
- const _warningFill = Color(0xFFFEF3C7);
14
- const _kanjiExampleFill = Color(0xFFE6FBFA);
15
  const _examples = ['to find', 'I would like...', 'appointment', 'Can you help me?', 'because'];
16
 
17
  String _value(Map<String, dynamic> json, String key) => (json[key] ?? '').toString();
@@ -226,16 +223,15 @@ class _PhrasesScreenState extends ConsumerState<PhrasesScreen> {
226
  final lang = profile?.targetLang ?? 'en';
227
  final activeLevel = _level ?? profile?.level ?? 'A1';
228
  return Scaffold(
229
- appBar: AppBar(toolbarHeight: 72, title: const Text('Phrases')),
230
  body: SafeArea(
231
  child: Center(
232
  child: ConstrainedBox(
233
  constraints: const BoxConstraints(maxWidth: 980),
234
  child: ListView(
235
- padding: const EdgeInsets.fromLTRB(16, 20, 16, 28),
236
  children: [
237
- _Header(),
238
- const SizedBox(height: 28),
239
  _InputPanel(
240
  controller: _controller,
241
  loading: _loading,
@@ -261,22 +257,75 @@ class _PhrasesScreenState extends ConsumerState<PhrasesScreen> {
261
  }
262
  }
263
 
264
- class _Header extends StatelessWidget {
 
 
 
265
  @override
266
  Widget build(BuildContext context) {
267
- return RichText(
268
- text: TextSpan(
269
- style: GoogleFonts.almarai(
270
- color: kForeground,
271
- fontSize: 34,
272
- fontWeight: FontWeight.w900,
273
- height: 0.98,
274
- letterSpacing: 0,
275
- ),
276
- children: const [
277
- TextSpan(text: 'Break down '),
278
- TextSpan(text: 'phrases', style: TextStyle(color: kPrimary)),
279
- TextSpan(text: ' into sentences, grammar, and tips'),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  ],
281
  ),
282
  );
@@ -305,12 +354,20 @@ class _InputPanel extends StatelessWidget {
305
  @override
306
  Widget build(BuildContext context) {
307
  return Container(
308
- padding: const EdgeInsets.all(20),
309
- decoration: BoxDecoration(color: _phraseFill, borderRadius: BorderRadius.circular(24)),
310
  child: Column(
311
  crossAxisAlignment: CrossAxisAlignment.start,
312
  children: [
313
- Text('Phrase or word', style: GoogleFonts.almarai(color: kPrimary, fontSize: 13, fontWeight: FontWeight.w900)),
 
 
 
 
 
 
 
 
314
  const SizedBox(height: 10),
315
  LevelPicker(value: level, onChanged: onLevelChange),
316
  const SizedBox(height: 12),
@@ -326,25 +383,18 @@ class _InputPanel extends StatelessWidget {
326
  child: FilledButton.icon(
327
  onPressed: loading ? null : onAnalyze,
328
  icon: const Icon(Icons.auto_awesome_rounded),
329
- label: Text(loading ? 'Analyzing...' : 'Analyze'),
330
  ),
331
  ),
332
  if (error != null) ...[
333
  const SizedBox(height: 10),
334
- Text(error!, style: GoogleFonts.almarai(color: kDangerShadow, fontSize: 13, fontWeight: FontWeight.w900)),
335
  ],
336
  const SizedBox(height: 16),
337
  Wrap(
338
  spacing: 8,
339
  runSpacing: 8,
340
- children: _examples
341
- .map(
342
- (example) => ActionChip(
343
- label: Text(example),
344
- onPressed: loading ? null : () => onExample(example),
345
- ),
346
- )
347
- .toList(),
348
  ),
349
  ],
350
  ),
@@ -352,6 +402,31 @@ class _InputPanel extends StatelessWidget {
352
  }
353
  }
354
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
  class _EmptyState extends StatelessWidget {
356
  const _EmptyState({this.isPending = false});
357
  final bool isPending;
@@ -359,29 +434,29 @@ class _EmptyState extends StatelessWidget {
359
  @override
360
  Widget build(BuildContext context) {
361
  return Container(
362
- padding: const EdgeInsets.all(28),
363
- decoration: duoCardDecoration(),
364
  child: Column(
365
  children: [
366
  Container(
367
- width: 62,
368
- height: 62,
369
  decoration: BoxDecoration(
370
  color: kBrutalYellow,
371
- borderRadius: BorderRadius.circular(16),
372
  border: Border.all(color: kBrutalBlack, width: 2),
373
  boxShadow: const [
374
  BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0),
375
  ],
376
  ),
377
- child: const Icon(Icons.edit_note_rounded, color: kBrutalBlack, size: 34),
378
  ),
379
- const SizedBox(height: 20),
380
  if (isPending) ...[
381
  Text(
382
- 'Building your mini lesson...',
383
  textAlign: TextAlign.center,
384
- style: GoogleFonts.almarai(color: kForeground, fontSize: 18, fontWeight: FontWeight.w900),
385
  ),
386
  const SizedBox(height: 14),
387
  const _BrutalProgressBar(),
@@ -389,7 +464,7 @@ class _EmptyState extends StatelessWidget {
389
  Text(
390
  'Enter a phrase to build a mini lesson',
391
  textAlign: TextAlign.center,
392
- style: GoogleFonts.almarai(color: kForeground, fontSize: 20, fontWeight: FontWeight.w900),
393
  ),
394
  ],
395
  ),
@@ -449,7 +524,7 @@ class _BrutalProgressBarState extends State<_BrutalProgressBar> with SingleTicke
449
  top: 0,
450
  bottom: 0,
451
  width: segmentWidth,
452
- child: Container(color: const Color(0xFF0EA5A4)),
453
  ),
454
  ],
455
  );
@@ -474,8 +549,8 @@ class _PhraseResult extends StatelessWidget {
474
  children: [
475
  Container(
476
  width: double.infinity,
477
- padding: const EdgeInsets.all(22),
478
- decoration: BoxDecoration(color: _phraseFill, borderRadius: BorderRadius.circular(24)),
479
  child: Column(
480
  crossAxisAlignment: CrossAxisAlignment.start,
481
  children: [
@@ -483,8 +558,13 @@ class _PhraseResult extends StatelessWidget {
483
  children: [
484
  Expanded(
485
  child: Text(
486
- analysis.partOfSpeech,
487
- style: GoogleFonts.almarai(color: kPrimary, fontSize: 13, fontWeight: FontWeight.w900),
 
 
 
 
 
488
  ),
489
  ),
490
  TtsButton(text: analysis.input, lang: lang, size: 40),
@@ -500,7 +580,7 @@ class _PhraseResult extends StatelessWidget {
500
  const SizedBox(height: 10),
501
  Text(
502
  analysis.translation,
503
- style: GoogleFonts.almarai(color: kMuted, fontSize: 17, fontWeight: FontWeight.w800, height: 1.3),
504
  ),
505
  ],
506
  ),
@@ -543,8 +623,8 @@ class _SentenceCard extends StatelessWidget {
543
  padding: const EdgeInsets.only(bottom: 12),
544
  child: Container(
545
  width: double.infinity,
546
- padding: const EdgeInsets.all(18),
547
- decoration: BoxDecoration(color: _phraseFill, borderRadius: BorderRadius.circular(22)),
548
  child: Column(
549
  crossAxisAlignment: CrossAxisAlignment.start,
550
  children: [
@@ -564,10 +644,10 @@ class _SentenceCard extends StatelessWidget {
564
  ],
565
  ),
566
  const SizedBox(height: 8),
567
- Text(sentence.translation, style: GoogleFonts.almarai(color: kMuted, fontSize: 13, fontWeight: FontWeight.w800)),
568
  if (sentence.note.isNotEmpty) ...[
569
  const SizedBox(height: 8),
570
- Text(sentence.note, style: GoogleFonts.almarai(color: kPrimary, fontSize: 13, fontWeight: FontWeight.w900)),
571
  ],
572
  ],
573
  ),
@@ -635,19 +715,12 @@ class _KanjiCard extends StatelessWidget {
635
  const SizedBox(height: 14),
636
  _readingRow(label: 'On', fill: kBrutalYellow, labelColor: kBrutalBlack, readings: kanji.onyomi),
637
  const SizedBox(height: 8),
638
- _readingRow(label: 'Kun', fill: const Color(0xFF0EA5A4), labelColor: kBrutalWhite, readings: kanji.kunyomi),
639
  if (kanji.exampleWord.isNotEmpty) ...[
640
  const SizedBox(height: 14),
641
  Container(
642
  padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
643
- decoration: BoxDecoration(
644
- color: _kanjiExampleFill,
645
- borderRadius: BorderRadius.circular(10),
646
- border: Border.all(color: kBrutalBlack, width: 2),
647
- boxShadow: const [
648
- BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0),
649
- ],
650
- ),
651
  child: FuriText(
652
  text: kanji.exampleWord,
653
  segments: kanji.exampleWordReading.isNotEmpty
@@ -672,7 +745,7 @@ class _KanjiCard extends StatelessWidget {
672
  padding: const EdgeInsets.symmetric(vertical: 4),
673
  decoration: BoxDecoration(
674
  color: fill,
675
- borderRadius: BorderRadius.circular(6),
676
  border: Border.all(color: kBrutalBlack, width: 2),
677
  boxShadow: const [
678
  BoxShadow(color: kBrutalBlack, offset: Offset(2, 2), blurRadius: 0),
@@ -734,7 +807,7 @@ class _RelatedWordCard extends StatelessWidget {
734
  margin: const EdgeInsets.only(bottom: 6),
735
  decoration: BoxDecoration(
736
  color: kBrutalYellow,
737
- borderRadius: BorderRadius.circular(6),
738
  border: Border.all(color: kBrutalBlack, width: 1.5),
739
  boxShadow: const [
740
  BoxShadow(color: kBrutalBlack, offset: Offset(1, 1), blurRadius: 0),
@@ -756,10 +829,10 @@ class _RelatedWordCard extends StatelessWidget {
756
  ],
757
  ),
758
  const SizedBox(height: 12),
759
- Text(word.translation, style: GoogleFonts.almarai(color: const Color(0xFF374151), fontSize: 15, fontWeight: FontWeight.w800)),
760
  if (word.note.isNotEmpty) ...[
761
  const SizedBox(height: 8),
762
- Text(word.note, style: GoogleFonts.almarai(color: const Color(0xFF6B7280), fontSize: 13, fontWeight: FontWeight.w900)),
763
  ],
764
  ],
765
  ),
@@ -822,7 +895,7 @@ class _BreakdownSection extends StatelessWidget {
822
  child: Container(
823
  width: double.infinity,
824
  padding: const EdgeInsets.all(18),
825
- decoration: duoCardDecoration(),
826
  child: Column(
827
  crossAxisAlignment: CrossAxisAlignment.start,
828
  children: [
@@ -858,14 +931,22 @@ class _TipsSection extends StatelessWidget {
858
  Container(
859
  width: double.infinity,
860
  margin: const EdgeInsets.only(bottom: 12),
861
- padding: const EdgeInsets.all(18),
862
- decoration: BoxDecoration(color: _warningFill, borderRadius: BorderRadius.circular(22)),
863
  child: Column(
864
  crossAxisAlignment: CrossAxisAlignment.start,
865
  children: [
866
- Text('Verb info', style: GoogleFonts.almarai(color: Color(0xFF92400E), fontSize: 13, fontWeight: FontWeight.w900)),
 
 
 
 
 
 
 
 
867
  const SizedBox(height: 6),
868
- Text(verbInfo, style: GoogleFonts.almarai(color: kForeground, fontSize: 13, fontWeight: FontWeight.w800, height: 1.35)),
869
  ],
870
  ),
871
  ),
@@ -875,7 +956,7 @@ class _TipsSection extends StatelessWidget {
875
  child: Container(
876
  width: double.infinity,
877
  padding: const EdgeInsets.all(18),
878
- decoration: duoCardDecoration(),
879
  child: Text(tip, style: GoogleFonts.almarai(color: kMuted, fontSize: 13, fontWeight: FontWeight.w800, height: 1.35)),
880
  ),
881
  ),
@@ -894,12 +975,17 @@ class _SectionHeader extends StatelessWidget {
894
  Widget build(BuildContext context) {
895
  return Row(
896
  children: [
897
- Icon(icon, color: kPrimary, size: 24),
898
- const SizedBox(width: 10),
899
  Expanded(
900
  child: Text(
901
- title,
902
- style: GoogleFonts.almarai(color: kForeground, fontSize: 24, fontWeight: FontWeight.w900),
 
 
 
 
 
903
  ),
904
  ),
905
  ],
 
9
  import 'widgets/furi_text.dart';
10
  import 'widgets/tts_button.dart';
11
 
 
 
 
12
  const _examples = ['to find', 'I would like...', 'appointment', 'Can you help me?', 'because'];
13
 
14
  String _value(Map<String, dynamic> json, String key) => (json[key] ?? '').toString();
 
223
  final lang = profile?.targetLang ?? 'en';
224
  final activeLevel = _level ?? profile?.level ?? 'A1';
225
  return Scaffold(
 
226
  body: SafeArea(
227
  child: Center(
228
  child: ConstrainedBox(
229
  constraints: const BoxConstraints(maxWidth: 980),
230
  child: ListView(
231
+ padding: const EdgeInsets.fromLTRB(16, 18, 16, 28),
232
  children: [
233
+ _Banner(profile: profile),
234
+ const SizedBox(height: 22),
235
  _InputPanel(
236
  controller: _controller,
237
  loading: _loading,
 
257
  }
258
  }
259
 
260
+ class _Banner extends StatelessWidget {
261
+ const _Banner({required this.profile});
262
+ final Profile? profile;
263
+
264
  @override
265
  Widget build(BuildContext context) {
266
+ return Container(
267
+ decoration: BoxDecoration(
268
+ color: kBrutalRed,
269
+ borderRadius: BorderRadius.circular(kBrutalRadius),
270
+ border: Border.all(color: kBrutalBlack, width: 3),
271
+ boxShadow: const [
272
+ BoxShadow(color: kBrutalBlack, offset: Offset(5, 5), blurRadius: 0),
273
+ ],
274
+ ),
275
+ padding: const EdgeInsets.all(20),
276
+ child: Row(
277
+ children: [
278
+ Container(
279
+ width: 56,
280
+ height: 56,
281
+ decoration: BoxDecoration(
282
+ color: kBrutalWhite,
283
+ borderRadius: BorderRadius.circular(kBrutalRadius),
284
+ border: Border.all(color: kBrutalBlack, width: 2),
285
+ boxShadow: const [
286
+ BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0),
287
+ ],
288
+ ),
289
+ child: const Icon(Icons.translate_rounded, color: kBrutalBlack),
290
+ ),
291
+ const SizedBox(width: 14),
292
+ Expanded(
293
+ child: Column(
294
+ crossAxisAlignment: CrossAxisAlignment.start,
295
+ children: [
296
+ Text(
297
+ 'PHRASE COACH',
298
+ style: GoogleFonts.almarai(
299
+ color: kBrutalWhite,
300
+ fontSize: 11,
301
+ fontWeight: FontWeight.w900,
302
+ letterSpacing: 1.4,
303
+ ),
304
+ ),
305
+ Text(
306
+ 'BREAK IT DOWN',
307
+ style: GoogleFonts.almarai(
308
+ color: kBrutalWhite,
309
+ fontSize: 22,
310
+ fontWeight: FontWeight.w900,
311
+ height: 1.05,
312
+ ),
313
+ ),
314
+ if (profile != null)
315
+ Padding(
316
+ padding: const EdgeInsets.only(top: 4),
317
+ child: Text(
318
+ '${profile!.targetLang.toUpperCase()} · ${profile!.level}',
319
+ style: GoogleFonts.almarai(
320
+ color: kBrutalWhite,
321
+ fontSize: 12,
322
+ fontWeight: FontWeight.w800,
323
+ ),
324
+ ),
325
+ ),
326
+ ],
327
+ ),
328
+ ),
329
  ],
330
  ),
331
  );
 
354
  @override
355
  Widget build(BuildContext context) {
356
  return Container(
357
+ padding: const EdgeInsets.all(18),
358
+ decoration: brutalCard(),
359
  child: Column(
360
  crossAxisAlignment: CrossAxisAlignment.start,
361
  children: [
362
+ Text(
363
+ 'PHRASE OR WORD',
364
+ style: GoogleFonts.almarai(
365
+ color: kBrutalTeal,
366
+ fontSize: 11,
367
+ fontWeight: FontWeight.w900,
368
+ letterSpacing: 1.2,
369
+ ),
370
+ ),
371
  const SizedBox(height: 10),
372
  LevelPicker(value: level, onChanged: onLevelChange),
373
  const SizedBox(height: 12),
 
383
  child: FilledButton.icon(
384
  onPressed: loading ? null : onAnalyze,
385
  icon: const Icon(Icons.auto_awesome_rounded),
386
+ label: Text(loading ? 'ANALYZING…' : 'ANALYZE'),
387
  ),
388
  ),
389
  if (error != null) ...[
390
  const SizedBox(height: 10),
391
+ Text(error!, style: GoogleFonts.almarai(color: kBrutalRed, fontSize: 13, fontWeight: FontWeight.w900)),
392
  ],
393
  const SizedBox(height: 16),
394
  Wrap(
395
  spacing: 8,
396
  runSpacing: 8,
397
+ children: _examples.map((example) => _BrutalChip(label: example, onTap: loading ? null : () => onExample(example))).toList(),
 
 
 
 
 
 
 
398
  ),
399
  ],
400
  ),
 
402
  }
403
  }
404
 
405
+ class _BrutalChip extends StatelessWidget {
406
+ const _BrutalChip({required this.label, required this.onTap});
407
+ final String label;
408
+ final VoidCallback? onTap;
409
+
410
+ @override
411
+ Widget build(BuildContext context) {
412
+ return GestureDetector(
413
+ onTap: onTap,
414
+ child: Container(
415
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
416
+ decoration: BoxDecoration(
417
+ color: kBrutalWhite,
418
+ borderRadius: BorderRadius.circular(999),
419
+ border: Border.all(color: kBrutalBlack, width: 2),
420
+ ),
421
+ child: Text(
422
+ label,
423
+ style: GoogleFonts.almarai(color: kBrutalBlack, fontSize: 12, fontWeight: FontWeight.w900),
424
+ ),
425
+ ),
426
+ );
427
+ }
428
+ }
429
+
430
  class _EmptyState extends StatelessWidget {
431
  const _EmptyState({this.isPending = false});
432
  final bool isPending;
 
434
  @override
435
  Widget build(BuildContext context) {
436
  return Container(
437
+ padding: const EdgeInsets.all(24),
438
+ decoration: brutalCard(),
439
  child: Column(
440
  children: [
441
  Container(
442
+ width: 60,
443
+ height: 60,
444
  decoration: BoxDecoration(
445
  color: kBrutalYellow,
446
+ borderRadius: BorderRadius.circular(kBrutalRadius),
447
  border: Border.all(color: kBrutalBlack, width: 2),
448
  boxShadow: const [
449
  BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0),
450
  ],
451
  ),
452
+ child: const Icon(Icons.edit_note_rounded, color: kBrutalBlack, size: 30),
453
  ),
454
+ const SizedBox(height: 18),
455
  if (isPending) ...[
456
  Text(
457
+ 'BUILDING YOUR MINI LESSON…',
458
  textAlign: TextAlign.center,
459
+ style: GoogleFonts.almarai(color: kBrutalSlate, fontSize: 13, fontWeight: FontWeight.w900, letterSpacing: 1.0),
460
  ),
461
  const SizedBox(height: 14),
462
  const _BrutalProgressBar(),
 
464
  Text(
465
  'Enter a phrase to build a mini lesson',
466
  textAlign: TextAlign.center,
467
+ style: GoogleFonts.almarai(color: kBrutalSlate, fontSize: 18, fontWeight: FontWeight.w900),
468
  ),
469
  ],
470
  ),
 
524
  top: 0,
525
  bottom: 0,
526
  width: segmentWidth,
527
+ child: Container(color: kBrutalTeal),
528
  ),
529
  ],
530
  );
 
549
  children: [
550
  Container(
551
  width: double.infinity,
552
+ padding: const EdgeInsets.all(20),
553
+ decoration: brutalCard(color: kBrutalYellow, offset: 5),
554
  child: Column(
555
  crossAxisAlignment: CrossAxisAlignment.start,
556
  children: [
 
558
  children: [
559
  Expanded(
560
  child: Text(
561
+ analysis.partOfSpeech.toUpperCase(),
562
+ style: GoogleFonts.almarai(
563
+ color: kBrutalBlack,
564
+ fontSize: 11,
565
+ fontWeight: FontWeight.w900,
566
+ letterSpacing: 1.2,
567
+ ),
568
  ),
569
  ),
570
  TtsButton(text: analysis.input, lang: lang, size: 40),
 
580
  const SizedBox(height: 10),
581
  Text(
582
  analysis.translation,
583
+ style: GoogleFonts.almarai(color: kBrutalSlate, fontSize: 16, fontWeight: FontWeight.w800, height: 1.3),
584
  ),
585
  ],
586
  ),
 
623
  padding: const EdgeInsets.only(bottom: 12),
624
  child: Container(
625
  width: double.infinity,
626
+ padding: const EdgeInsets.all(16),
627
+ decoration: brutalCard(),
628
  child: Column(
629
  crossAxisAlignment: CrossAxisAlignment.start,
630
  children: [
 
644
  ],
645
  ),
646
  const SizedBox(height: 8),
647
+ Text(sentence.translation, style: GoogleFonts.almarai(color: kBrutalMuted, fontSize: 13, fontWeight: FontWeight.w800)),
648
  if (sentence.note.isNotEmpty) ...[
649
  const SizedBox(height: 8),
650
+ Text(sentence.note, style: GoogleFonts.almarai(color: kBrutalTeal, fontSize: 13, fontWeight: FontWeight.w900)),
651
  ],
652
  ],
653
  ),
 
715
  const SizedBox(height: 14),
716
  _readingRow(label: 'On', fill: kBrutalYellow, labelColor: kBrutalBlack, readings: kanji.onyomi),
717
  const SizedBox(height: 8),
718
+ _readingRow(label: 'Kun', fill: kBrutalTeal, labelColor: kBrutalWhite, readings: kanji.kunyomi),
719
  if (kanji.exampleWord.isNotEmpty) ...[
720
  const SizedBox(height: 14),
721
  Container(
722
  padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
723
+ decoration: brutalCard(color: kBrutalCream, offset: 3),
 
 
 
 
 
 
 
724
  child: FuriText(
725
  text: kanji.exampleWord,
726
  segments: kanji.exampleWordReading.isNotEmpty
 
745
  padding: const EdgeInsets.symmetric(vertical: 4),
746
  decoration: BoxDecoration(
747
  color: fill,
748
+ borderRadius: BorderRadius.circular(kBrutalRadius),
749
  border: Border.all(color: kBrutalBlack, width: 2),
750
  boxShadow: const [
751
  BoxShadow(color: kBrutalBlack, offset: Offset(2, 2), blurRadius: 0),
 
807
  margin: const EdgeInsets.only(bottom: 6),
808
  decoration: BoxDecoration(
809
  color: kBrutalYellow,
810
+ borderRadius: BorderRadius.circular(kBrutalRadius),
811
  border: Border.all(color: kBrutalBlack, width: 1.5),
812
  boxShadow: const [
813
  BoxShadow(color: kBrutalBlack, offset: Offset(1, 1), blurRadius: 0),
 
829
  ],
830
  ),
831
  const SizedBox(height: 12),
832
+ Text(word.translation, style: GoogleFonts.almarai(color: kBrutalSlate, fontSize: 15, fontWeight: FontWeight.w800)),
833
  if (word.note.isNotEmpty) ...[
834
  const SizedBox(height: 8),
835
+ Text(word.note, style: GoogleFonts.almarai(color: kBrutalMuted, fontSize: 13, fontWeight: FontWeight.w900)),
836
  ],
837
  ],
838
  ),
 
895
  child: Container(
896
  width: double.infinity,
897
  padding: const EdgeInsets.all(18),
898
+ decoration: brutalCard(),
899
  child: Column(
900
  crossAxisAlignment: CrossAxisAlignment.start,
901
  children: [
 
931
  Container(
932
  width: double.infinity,
933
  margin: const EdgeInsets.only(bottom: 12),
934
+ padding: const EdgeInsets.all(16),
935
+ decoration: brutalCard(color: kBrutalYellow),
936
  child: Column(
937
  crossAxisAlignment: CrossAxisAlignment.start,
938
  children: [
939
+ Text(
940
+ 'VERB INFO',
941
+ style: GoogleFonts.almarai(
942
+ color: kBrutalBlack,
943
+ fontSize: 11,
944
+ fontWeight: FontWeight.w900,
945
+ letterSpacing: 1.2,
946
+ ),
947
+ ),
948
  const SizedBox(height: 6),
949
+ Text(verbInfo, style: GoogleFonts.almarai(color: kBrutalSlate, fontSize: 13, fontWeight: FontWeight.w800, height: 1.35)),
950
  ],
951
  ),
952
  ),
 
956
  child: Container(
957
  width: double.infinity,
958
  padding: const EdgeInsets.all(18),
959
+ decoration: brutalCard(),
960
  child: Text(tip, style: GoogleFonts.almarai(color: kMuted, fontSize: 13, fontWeight: FontWeight.w800, height: 1.35)),
961
  ),
962
  ),
 
975
  Widget build(BuildContext context) {
976
  return Row(
977
  children: [
978
+ Icon(icon, color: kBrutalSlate, size: 22),
979
+ const SizedBox(width: 8),
980
  Expanded(
981
  child: Text(
982
+ title.toUpperCase(),
983
+ style: GoogleFonts.almarai(
984
+ color: kBrutalSlate,
985
+ fontSize: 14,
986
+ fontWeight: FontWeight.w900,
987
+ letterSpacing: 1.2,
988
+ ),
989
  ),
990
  ),
991
  ],
mobile/lib/features/practice/dialogue_screen.dart CHANGED
@@ -78,45 +78,6 @@ class DialogueFull {
78
  );
79
  }
80
 
81
- class DialogueScreen extends StatelessWidget {
82
- const DialogueScreen({super.key, required this.dialogueId});
83
- final String dialogueId;
84
-
85
- @override
86
- Widget build(BuildContext context) {
87
- return Scaffold(
88
- appBar: AppBar(title: const Text('Dialogue')),
89
- body: Center(
90
- child: Padding(
91
- padding: const EdgeInsets.all(24),
92
- child: Container(
93
- padding: const EdgeInsets.all(24),
94
- decoration: duoCardDecoration(),
95
- child: Column(
96
- mainAxisSize: MainAxisSize.min,
97
- children: [
98
- Text(
99
- 'Dialogue no longer available',
100
- textAlign: TextAlign.center,
101
- style: GoogleFonts.almarai(color: kForeground, fontSize: 20, fontWeight: FontWeight.w900),
102
- ),
103
- const SizedBox(height: 8),
104
- Text(
105
- 'Generated dialogues are temporary and are not stored after you leave the practice screen.',
106
- textAlign: TextAlign.center,
107
- style: GoogleFonts.almarai(color: kMuted, fontSize: 13, fontWeight: FontWeight.w800, height: 1.35),
108
- ),
109
- const SizedBox(height: 18),
110
- OutlinedButton(onPressed: () => Navigator.of(context).maybePop(), child: const Text('Back')),
111
- ],
112
- ),
113
- ),
114
- ),
115
- ),
116
- );
117
- }
118
- }
119
-
120
  class DialogueLessonView extends StatefulWidget {
121
  const DialogueLessonView({super.key, required this.dialogue, required this.lang, this.onMoreDialogues});
122
  final DialogueFull dialogue;
@@ -193,15 +154,15 @@ class _DialogueLessonViewState extends State<DialogueLessonView> {
193
  children: [
194
  Container(
195
  padding: const EdgeInsets.all(20),
196
- decoration: duoCardDecoration(),
197
  child: Column(
198
  crossAxisAlignment: CrossAxisAlignment.start,
199
  children: [
200
- _Eyebrow('SCENARIO', color: kSecondary),
201
  const SizedBox(height: 8),
202
  Text(
203
  widget.dialogue.scenario,
204
- style: GoogleFonts.almarai(color: kForeground, fontSize: 20, fontWeight: FontWeight.w900, height: 1.35),
205
  ),
206
  const SizedBox(height: 18),
207
  Wrap(
@@ -220,7 +181,7 @@ class _DialogueLessonViewState extends State<DialogueLessonView> {
220
  FilledButton.icon(
221
  onPressed: () => setState(() => _started = true),
222
  icon: const Icon(Icons.play_arrow_rounded),
223
- label: const Text('Start dialogue'),
224
  ),
225
  ],
226
  );
@@ -234,22 +195,32 @@ class _DialogueLessonViewState extends State<DialogueLessonView> {
234
  children: [
235
  Container(
236
  padding: const EdgeInsets.all(28),
237
- decoration: duoCardDecoration(),
238
  child: Column(
239
  children: [
240
  Container(
241
  width: 78,
242
  height: 78,
243
- decoration: BoxDecoration(color: const Color(0xFFFEF3C7), borderRadius: BorderRadius.circular(24)),
244
- child: const Icon(Icons.emoji_events_rounded, color: Color(0xFF92400E), size: 44),
 
 
 
 
 
 
 
245
  ),
246
  const SizedBox(height: 18),
247
- Text('$pct%', style: GoogleFonts.almarai(color: kPrimary, fontSize: 48, fontWeight: FontWeight.w900)),
248
- Text('$_score of $_totalChoices correct', style: GoogleFonts.almarai(color: kMuted, fontWeight: FontWeight.w900)),
249
  const SizedBox(height: 24),
250
- FilledButton.icon(onPressed: _restart, icon: const Icon(Icons.refresh_rounded), label: const Text('Try again')),
251
  const SizedBox(height: 12),
252
- OutlinedButton(onPressed: widget.onMoreDialogues ?? () => Navigator.of(context).maybePop(), child: const Text('More dialogues')),
 
 
 
253
  ],
254
  ),
255
  ),
@@ -266,17 +237,17 @@ class _DialogueLessonViewState extends State<DialogueLessonView> {
266
  padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
267
  child: Container(
268
  padding: const EdgeInsets.all(12),
269
- decoration: duoCardDecoration(),
270
  child: Column(
271
  crossAxisAlignment: CrossAxisAlignment.start,
272
  children: [
273
  ClipRRect(
274
- borderRadius: BorderRadius.circular(99),
275
  child: LinearProgressIndicator(
276
  value: progress,
277
  minHeight: 12,
278
- backgroundColor: kBorder,
279
- color: kPrimary,
280
  ),
281
  ),
282
  const SizedBox(height: 8),
@@ -307,18 +278,18 @@ class _DialogueLessonViewState extends State<DialogueLessonView> {
307
  final isCurrent = idx == _turnIndex;
308
  return Container(
309
  padding: const EdgeInsets.all(16),
310
- decoration: duoCardDecoration(color: kCardFeature),
311
  child: Column(
312
  crossAxisAlignment: CrossAxisAlignment.stretch,
313
  children: [
314
  Text(
315
  turn.text,
316
  textAlign: TextAlign.center,
317
- style: GoogleFonts.almarai(color: kMuted, fontSize: 14, fontWeight: FontWeight.w900, fontStyle: FontStyle.italic),
318
  ),
319
  if (isCurrent) ...[
320
  const SizedBox(height: 12),
321
- FilledButton(onPressed: _advance, child: const Text('Continue')),
322
  ],
323
  ],
324
  ),
@@ -335,23 +306,26 @@ class _DialogueLessonViewState extends State<DialogueLessonView> {
335
  width: 54,
336
  height: 54,
337
  decoration: BoxDecoration(
338
- color: const Color(0xFFEDE9FE),
339
- borderRadius: BorderRadius.circular(18),
340
- border: Border.all(color: kBorder, width: 2),
 
 
 
341
  ),
342
  child: Center(
343
- child: Text(initial, style: GoogleFonts.almarai(color: kSecondary, fontWeight: FontWeight.w900, fontSize: 22)),
344
  ),
345
  ),
346
  const SizedBox(width: 10),
347
  Expanded(
348
  child: Container(
349
  padding: const EdgeInsets.all(16),
350
- decoration: duoCardDecoration(),
351
  child: Column(
352
  crossAxisAlignment: CrossAxisAlignment.start,
353
  children: [
354
- if (turn.speakerName != null) _Eyebrow(turn.speakerName!.toUpperCase(), color: kSecondary),
355
  if (turn.speakerName != null) const SizedBox(height: 8),
356
  Row(
357
  crossAxisAlignment: CrossAxisAlignment.start,
@@ -370,16 +344,16 @@ class _DialogueLessonViewState extends State<DialogueLessonView> {
370
  ),
371
  if (turn.translation != null) ...[
372
  const SizedBox(height: 12),
373
- InkWell(
374
  onTap: () => setState(() => _showTranslation[idx] = !(_showTranslation[idx] ?? false)),
375
  child: Row(
376
  mainAxisSize: MainAxisSize.min,
377
  children: [
378
- Icon((_showTranslation[idx] ?? false) ? Icons.visibility_off : Icons.visibility, color: kSecondary, size: 16),
379
  const SizedBox(width: 6),
380
  Text(
381
  (_showTranslation[idx] ?? false) ? turn.translation! : 'Show translation',
382
- style: GoogleFonts.almarai(color: kSecondary, fontWeight: FontWeight.w900, fontSize: 13),
383
  ),
384
  ],
385
  ),
@@ -387,7 +361,7 @@ class _DialogueLessonViewState extends State<DialogueLessonView> {
387
  ],
388
  if (isCurrent && !_chosen.containsKey(idx)) ...[
389
  const SizedBox(height: 14),
390
- FilledButton(onPressed: _advance, child: const Text('Continue')),
391
  ],
392
  ],
393
  ),
@@ -400,13 +374,13 @@ class _DialogueLessonViewState extends State<DialogueLessonView> {
400
  if (turn.type == 'user_choice') {
401
  return Container(
402
  padding: const EdgeInsets.all(16),
403
- decoration: duoCardDecoration(),
404
  child: Column(
405
  crossAxisAlignment: CrossAxisAlignment.start,
406
  children: [
407
  Text(
408
  turn.text.isNotEmpty ? turn.text : 'What do you say?',
409
- style: GoogleFonts.almarai(color: kForeground, fontSize: 18, fontWeight: FontWeight.w900),
410
  ),
411
  const SizedBox(height: 12),
412
  ...List.generate(turn.options?.length ?? 0, (optionIndex) {
@@ -414,37 +388,34 @@ class _DialogueLessonViewState extends State<DialogueLessonView> {
414
  final made = _chosen[idx];
415
  final isChosen = made == optionIndex;
416
  final isCorrect = option.isCorrect;
417
- Color borderColor = kBorder;
418
- Color bg = kCard;
419
- Color textColor = kForeground;
420
 
421
  if (made != null) {
422
  if (isCorrect) {
423
- borderColor = kPrimary;
424
- bg = const Color(0xFFCCFBF1);
425
- textColor = kPrimaryShadow;
426
  } else if (isChosen) {
427
- borderColor = kDanger;
428
- bg = const Color(0xFFFFE4E6);
429
- textColor = kDangerShadow;
430
  } else {
431
- textColor = const Color(0xFFAFAFAF);
432
  }
433
  }
434
 
435
  return Padding(
436
  padding: const EdgeInsets.only(bottom: 10),
437
- child: InkWell(
438
- borderRadius: BorderRadius.circular(18),
439
  onTap: made == null ? () => _handleChoice(idx, optionIndex) : null,
440
  child: Container(
441
  width: double.infinity,
442
  padding: const EdgeInsets.all(14),
443
  decoration: BoxDecoration(
444
  color: bg,
445
- borderRadius: BorderRadius.circular(18),
446
  border: Border.all(color: borderColor, width: 2),
447
- boxShadow: const [BoxShadow(color: kBorder, offset: Offset(0, 4), blurRadius: 0)],
448
  ),
449
  child: Column(
450
  crossAxisAlignment: CrossAxisAlignment.start,
@@ -484,17 +455,17 @@ class _Chip extends StatelessWidget {
484
  return Container(
485
  padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
486
  decoration: BoxDecoration(
487
- color: kCard,
488
  borderRadius: BorderRadius.circular(99),
489
- border: Border.all(color: kBorder, width: 2),
490
  ),
491
- child: Text(label, style: GoogleFonts.almarai(color: kMuted, fontSize: 12, fontWeight: FontWeight.w900)),
492
  );
493
  }
494
  }
495
 
496
  class _Eyebrow extends StatelessWidget {
497
- const _Eyebrow(this.text, {this.color = kMuted});
498
  final String text;
499
  final Color color;
500
 
@@ -502,9 +473,7 @@ class _Eyebrow extends StatelessWidget {
502
  Widget build(BuildContext context) {
503
  return Text(
504
  text,
505
- style: GoogleFonts.almarai(color: color, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0),
506
  );
507
  }
508
  }
509
-
510
-
 
78
  );
79
  }
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  class DialogueLessonView extends StatefulWidget {
82
  const DialogueLessonView({super.key, required this.dialogue, required this.lang, this.onMoreDialogues});
83
  final DialogueFull dialogue;
 
154
  children: [
155
  Container(
156
  padding: const EdgeInsets.all(20),
157
+ decoration: brutalCard(),
158
  child: Column(
159
  crossAxisAlignment: CrossAxisAlignment.start,
160
  children: [
161
+ _Eyebrow('SCENARIO', color: kBrutalTeal),
162
  const SizedBox(height: 8),
163
  Text(
164
  widget.dialogue.scenario,
165
+ style: GoogleFonts.almarai(color: kBrutalSlate, fontSize: 20, fontWeight: FontWeight.w900, height: 1.35),
166
  ),
167
  const SizedBox(height: 18),
168
  Wrap(
 
181
  FilledButton.icon(
182
  onPressed: () => setState(() => _started = true),
183
  icon: const Icon(Icons.play_arrow_rounded),
184
+ label: const Text('START DIALOGUE'),
185
  ),
186
  ],
187
  );
 
195
  children: [
196
  Container(
197
  padding: const EdgeInsets.all(28),
198
+ decoration: brutalCard(),
199
  child: Column(
200
  children: [
201
  Container(
202
  width: 78,
203
  height: 78,
204
+ decoration: BoxDecoration(
205
+ color: kBrutalYellow,
206
+ borderRadius: BorderRadius.circular(kBrutalRadius),
207
+ border: Border.all(color: kBrutalBlack, width: 2),
208
+ boxShadow: const [
209
+ BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0),
210
+ ],
211
+ ),
212
+ child: const Icon(Icons.emoji_events_rounded, color: kBrutalBlack, size: 44),
213
  ),
214
  const SizedBox(height: 18),
215
+ Text('$pct%', style: GoogleFonts.almarai(color: kBrutalTeal, fontSize: 48, fontWeight: FontWeight.w900)),
216
+ Text('$_score of $_totalChoices correct', style: GoogleFonts.almarai(color: kBrutalMuted, fontWeight: FontWeight.w900)),
217
  const SizedBox(height: 24),
218
+ FilledButton.icon(onPressed: _restart, icon: const Icon(Icons.refresh_rounded), label: const Text('TRY AGAIN')),
219
  const SizedBox(height: 12),
220
+ OutlinedButton(
221
+ onPressed: widget.onMoreDialogues ?? () => Navigator.of(context).maybePop(),
222
+ child: const Text('MORE DIALOGUES'),
223
+ ),
224
  ],
225
  ),
226
  ),
 
237
  padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
238
  child: Container(
239
  padding: const EdgeInsets.all(12),
240
+ decoration: brutalCard(),
241
  child: Column(
242
  crossAxisAlignment: CrossAxisAlignment.start,
243
  children: [
244
  ClipRRect(
245
+ borderRadius: BorderRadius.circular(kBrutalRadius),
246
  child: LinearProgressIndicator(
247
  value: progress,
248
  minHeight: 12,
249
+ backgroundColor: const Color(0xFFE5E5E5),
250
+ color: kBrutalTeal,
251
  ),
252
  ),
253
  const SizedBox(height: 8),
 
278
  final isCurrent = idx == _turnIndex;
279
  return Container(
280
  padding: const EdgeInsets.all(16),
281
+ decoration: brutalCard(color: kBrutalCream),
282
  child: Column(
283
  crossAxisAlignment: CrossAxisAlignment.stretch,
284
  children: [
285
  Text(
286
  turn.text,
287
  textAlign: TextAlign.center,
288
+ style: GoogleFonts.almarai(color: kBrutalSlate, fontSize: 14, fontWeight: FontWeight.w800, fontStyle: FontStyle.italic),
289
  ),
290
  if (isCurrent) ...[
291
  const SizedBox(height: 12),
292
+ FilledButton(onPressed: _advance, child: const Text('CONTINUE')),
293
  ],
294
  ],
295
  ),
 
306
  width: 54,
307
  height: 54,
308
  decoration: BoxDecoration(
309
+ color: kBrutalBlue,
310
+ borderRadius: BorderRadius.circular(kBrutalRadius),
311
+ border: Border.all(color: kBrutalBlack, width: 2),
312
+ boxShadow: const [
313
+ BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0),
314
+ ],
315
  ),
316
  child: Center(
317
+ child: Text(initial, style: GoogleFonts.almarai(color: kBrutalWhite, fontWeight: FontWeight.w900, fontSize: 22)),
318
  ),
319
  ),
320
  const SizedBox(width: 10),
321
  Expanded(
322
  child: Container(
323
  padding: const EdgeInsets.all(16),
324
+ decoration: brutalCard(),
325
  child: Column(
326
  crossAxisAlignment: CrossAxisAlignment.start,
327
  children: [
328
+ if (turn.speakerName != null) _Eyebrow(turn.speakerName!.toUpperCase(), color: kBrutalBlue),
329
  if (turn.speakerName != null) const SizedBox(height: 8),
330
  Row(
331
  crossAxisAlignment: CrossAxisAlignment.start,
 
344
  ),
345
  if (turn.translation != null) ...[
346
  const SizedBox(height: 12),
347
+ GestureDetector(
348
  onTap: () => setState(() => _showTranslation[idx] = !(_showTranslation[idx] ?? false)),
349
  child: Row(
350
  mainAxisSize: MainAxisSize.min,
351
  children: [
352
+ Icon((_showTranslation[idx] ?? false) ? Icons.visibility_off : Icons.visibility, color: kBrutalBlue, size: 16),
353
  const SizedBox(width: 6),
354
  Text(
355
  (_showTranslation[idx] ?? false) ? turn.translation! : 'Show translation',
356
+ style: GoogleFonts.almarai(color: kBrutalBlue, fontWeight: FontWeight.w900, fontSize: 13),
357
  ),
358
  ],
359
  ),
 
361
  ],
362
  if (isCurrent && !_chosen.containsKey(idx)) ...[
363
  const SizedBox(height: 14),
364
+ FilledButton(onPressed: _advance, child: const Text('CONTINUE')),
365
  ],
366
  ],
367
  ),
 
374
  if (turn.type == 'user_choice') {
375
  return Container(
376
  padding: const EdgeInsets.all(16),
377
+ decoration: brutalCard(),
378
  child: Column(
379
  crossAxisAlignment: CrossAxisAlignment.start,
380
  children: [
381
  Text(
382
  turn.text.isNotEmpty ? turn.text : 'What do you say?',
383
+ style: GoogleFonts.almarai(color: kBrutalSlate, fontSize: 18, fontWeight: FontWeight.w900),
384
  ),
385
  const SizedBox(height: 12),
386
  ...List.generate(turn.options?.length ?? 0, (optionIndex) {
 
388
  final made = _chosen[idx];
389
  final isChosen = made == optionIndex;
390
  final isCorrect = option.isCorrect;
391
+ Color borderColor = kBrutalBlack;
392
+ Color bg = kBrutalWhite;
393
+ Color textColor = kBrutalSlate;
394
 
395
  if (made != null) {
396
  if (isCorrect) {
397
+ bg = kBrutalGreen;
398
+ textColor = kBrutalWhite;
 
399
  } else if (isChosen) {
400
+ bg = kBrutalRed;
401
+ textColor = kBrutalWhite;
 
402
  } else {
403
+ textColor = kBrutalMuted;
404
  }
405
  }
406
 
407
  return Padding(
408
  padding: const EdgeInsets.only(bottom: 10),
409
+ child: GestureDetector(
 
410
  onTap: made == null ? () => _handleChoice(idx, optionIndex) : null,
411
  child: Container(
412
  width: double.infinity,
413
  padding: const EdgeInsets.all(14),
414
  decoration: BoxDecoration(
415
  color: bg,
416
+ borderRadius: BorderRadius.circular(kBrutalRadius),
417
  border: Border.all(color: borderColor, width: 2),
418
+ boxShadow: const [BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0)],
419
  ),
420
  child: Column(
421
  crossAxisAlignment: CrossAxisAlignment.start,
 
455
  return Container(
456
  padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
457
  decoration: BoxDecoration(
458
+ color: kBrutalWhite,
459
  borderRadius: BorderRadius.circular(99),
460
+ border: Border.all(color: kBrutalBlack, width: 2),
461
  ),
462
+ child: Text(label, style: GoogleFonts.almarai(color: kBrutalSlate, fontSize: 12, fontWeight: FontWeight.w900)),
463
  );
464
  }
465
  }
466
 
467
  class _Eyebrow extends StatelessWidget {
468
+ const _Eyebrow(this.text, {this.color = kBrutalMuted});
469
  final String text;
470
  final Color color;
471
 
 
473
  Widget build(BuildContext context) {
474
  return Text(
475
  text,
476
+ style: GoogleFonts.almarai(color: color, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 1.2),
477
  );
478
  }
479
  }
 
 
mobile/lib/features/practice/dialogues_list_screen.dart CHANGED
@@ -8,8 +8,6 @@ import '../../core/languages.dart';
8
  import '../../theme/app_theme.dart';
9
  import 'dialogue_screen.dart';
10
 
11
- const _scenarioFill = Color(0xFFE8F7F6);
12
-
13
  class _Scenario {
14
  const _Scenario({
15
  required this.title,
@@ -157,32 +155,16 @@ class _DialoguesListScreenState extends ConsumerState<DialoguesListScreen> {
157
  }
158
 
159
  return Scaffold(
160
- appBar: AppBar(
161
- toolbarHeight: 72,
162
- title: Column(
163
- crossAxisAlignment: CrossAxisAlignment.start,
164
- children: [
165
- const Text('Tiny Lesson'),
166
- if (profile != null)
167
- Text(
168
- '${languageName(profile.targetLang)} / ${profile.level}',
169
- style: GoogleFonts.almarai(color: kMuted, fontSize: 12, fontWeight: FontWeight.w900),
170
- ),
171
- ],
172
- ),
173
- ),
174
  body: SafeArea(
175
  child: Center(
176
  child: ConstrainedBox(
177
  constraints: const BoxConstraints(maxWidth: 980),
178
  child: ListView(
179
- padding: const EdgeInsets.fromLTRB(16, 20, 16, 28),
180
  children: [
181
- _PracticeHeader(),
182
- const SizedBox(height: 28),
183
- _ModePills(),
184
- const SizedBox(height: 34),
185
- _SectionTitle(icon: Icons.dashboard_customize_rounded, title: 'Scenario Library'),
186
  const SizedBox(height: 14),
187
  _ScenarioGrid(
188
  busyKey: _busyKey,
@@ -194,8 +176,6 @@ class _DialoguesListScreenState extends ConsumerState<DialoguesListScreen> {
194
  loading: _busyKey == 'custom',
195
  onGenerate: _generateCustom,
196
  ),
197
- const SizedBox(height: 18),
198
- const _NoHistoryNotice(),
199
  ],
200
  ),
201
  ),
@@ -205,64 +185,76 @@ class _DialoguesListScreenState extends ConsumerState<DialoguesListScreen> {
205
  }
206
  }
207
 
208
- class _PracticeHeader extends StatelessWidget {
209
- @override
210
- Widget build(BuildContext context) {
211
- return RichText(
212
- text: TextSpan(
213
- style: GoogleFonts.almarai(
214
- color: kForeground,
215
- fontSize: 34,
216
- fontWeight: FontWeight.w900,
217
- height: 0.98,
218
- letterSpacing: 0,
219
- ),
220
- children: const [
221
- TextSpan(text: 'Practice '),
222
- TextSpan(text: 'real dialogue', style: TextStyle(color: kPrimary)),
223
- TextSpan(text: ' for everyday situations'),
224
- ],
225
- ),
226
- );
227
- }
228
- }
229
-
230
- class _ModePills extends StatelessWidget {
231
- @override
232
- Widget build(BuildContext context) {
233
- return Wrap(
234
- spacing: 10,
235
- runSpacing: 10,
236
- children: const [
237
- _ModePill(label: 'Scenarios', active: true),
238
- _ModePill(label: 'Camera'),
239
- _ModePill(label: 'Phrases'),
240
- ],
241
- );
242
- }
243
- }
244
-
245
- class _ModePill extends StatelessWidget {
246
- const _ModePill({required this.label, this.active = false});
247
- final String label;
248
- final bool active;
249
 
250
  @override
251
  Widget build(BuildContext context) {
252
  return Container(
253
- padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 12),
254
  decoration: BoxDecoration(
255
- color: active ? _scenarioFill : Colors.transparent,
256
- borderRadius: BorderRadius.circular(99),
 
 
 
 
257
  ),
258
- child: Text(
259
- label,
260
- style: GoogleFonts.almarai(
261
- color: active ? kForeground : kMuted,
262
- fontSize: 13,
263
- fontWeight: FontWeight.w900,
264
- letterSpacing: 0,
265
- ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  ),
267
  );
268
  }
@@ -277,12 +269,17 @@ class _SectionTitle extends StatelessWidget {
277
  Widget build(BuildContext context) {
278
  return Row(
279
  children: [
280
- Icon(icon, color: kPrimary, size: 26),
281
- const SizedBox(width: 10),
282
  Expanded(
283
  child: Text(
284
  title,
285
- style: GoogleFonts.almarai(color: kForeground, fontSize: 26, fontWeight: FontWeight.w900),
 
 
 
 
 
286
  ),
287
  ),
288
  ],
@@ -306,7 +303,7 @@ class _ScenarioGrid extends StatelessWidget {
306
  itemCount: _scenarios.length,
307
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
308
  crossAxisCount: columns,
309
- mainAxisExtent: 192,
310
  crossAxisSpacing: 14,
311
  mainAxisSpacing: 14,
312
  ),
@@ -325,7 +322,7 @@ class _ScenarioGrid extends StatelessWidget {
325
  }
326
  }
327
 
328
- class _ScenarioCard extends StatelessWidget {
329
  const _ScenarioCard({
330
  required this.scenario,
331
  required this.loading,
@@ -338,16 +335,34 @@ class _ScenarioCard extends StatelessWidget {
338
  final bool disabled;
339
  final VoidCallback onTap;
340
 
 
 
 
 
 
 
 
341
  @override
342
  Widget build(BuildContext context) {
343
- return InkWell(
344
- borderRadius: BorderRadius.circular(22),
345
- onTap: disabled ? null : onTap,
346
- child: Container(
347
- padding: const EdgeInsets.all(18),
 
 
 
 
 
 
 
348
  decoration: BoxDecoration(
349
- color: _scenarioFill,
350
- borderRadius: BorderRadius.circular(22),
 
 
 
 
351
  ),
352
  child: Column(
353
  crossAxisAlignment: CrossAxisAlignment.start,
@@ -355,34 +370,46 @@ class _ScenarioCard extends StatelessWidget {
355
  Row(
356
  children: [
357
  Container(
358
- width: 46,
359
- height: 46,
360
- decoration: BoxDecoration(color: kCard, borderRadius: BorderRadius.circular(16)),
361
- child: Icon(scenario.icon, color: kPrimary),
 
 
 
 
362
  ),
363
  const Spacer(),
364
- Icon(Icons.arrow_forward_rounded, color: loading ? kMuted : kPrimary),
 
 
 
365
  ],
366
  ),
367
- const SizedBox(height: 16),
368
  Text(
369
- scenario.title,
370
  maxLines: 1,
371
  overflow: TextOverflow.ellipsis,
372
- style: GoogleFonts.almarai(color: kForeground, fontSize: 20, fontWeight: FontWeight.w900),
373
  ),
374
- const SizedBox(height: 8),
375
  Expanded(
376
  child: Text(
377
- scenario.description,
378
  maxLines: 3,
379
  overflow: TextOverflow.ellipsis,
380
- style: GoogleFonts.almarai(color: kMuted, fontSize: 13, fontWeight: FontWeight.w800, height: 1.35),
381
  ),
382
  ),
383
  Text(
384
- loading ? 'Generating new dialogue...' : 'Generate fresh dialogue',
385
- style: GoogleFonts.almarai(color: kPrimary, fontSize: 12, fontWeight: FontWeight.w900),
 
 
 
 
 
386
  ),
387
  ],
388
  ),
@@ -405,16 +432,24 @@ class _CustomTopicPanel extends StatelessWidget {
405
  @override
406
  Widget build(BuildContext context) {
407
  return Container(
408
- padding: const EdgeInsets.all(20),
409
- decoration: duoCardDecoration(color: kCard),
410
  child: Column(
411
  crossAxisAlignment: CrossAxisAlignment.start,
412
  children: [
413
- Text('Build your own', style: GoogleFonts.almarai(color: kPrimary, fontSize: 13, fontWeight: FontWeight.w900)),
 
 
 
 
 
 
 
 
414
  const SizedBox(height: 8),
415
  Text(
416
  'What do you want to practice?',
417
- style: GoogleFonts.almarai(color: kForeground, fontSize: 24, fontWeight: FontWeight.w900, height: 1.1),
418
  ),
419
  const SizedBox(height: 12),
420
  TextField(
@@ -432,7 +467,7 @@ class _CustomTopicPanel extends StatelessWidget {
432
  child: FilledButton.icon(
433
  onPressed: loading ? null : onGenerate,
434
  icon: const Icon(Icons.arrow_forward_rounded),
435
- label: Text(loading ? 'Generating...' : 'Generate dialogue'),
436
  ),
437
  ),
438
  ],
@@ -440,37 +475,3 @@ class _CustomTopicPanel extends StatelessWidget {
440
  );
441
  }
442
  }
443
-
444
- class _NoHistoryNotice extends StatelessWidget {
445
- const _NoHistoryNotice();
446
-
447
- @override
448
- Widget build(BuildContext context) {
449
- return Container(
450
- padding: const EdgeInsets.all(22),
451
- decoration: duoCardDecoration(),
452
- child: Column(
453
- children: [
454
- Container(
455
- width: 58,
456
- height: 58,
457
- decoration: BoxDecoration(color: _scenarioFill, borderRadius: BorderRadius.circular(18)),
458
- child: const Icon(Icons.lock_clock_rounded, color: kPrimary, size: 30),
459
- ),
460
- const SizedBox(height: 14),
461
- Text(
462
- 'No saved dialogue history',
463
- textAlign: TextAlign.center,
464
- style: GoogleFonts.almarai(color: kForeground, fontWeight: FontWeight.w900, fontSize: 18),
465
- ),
466
- const SizedBox(height: 6),
467
- Text(
468
- 'Generated lessons are shown once and discarded when you leave this screen.',
469
- textAlign: TextAlign.center,
470
- style: GoogleFonts.almarai(color: kMuted, fontWeight: FontWeight.w800, fontSize: 13, height: 1.35),
471
- ),
472
- ],
473
- ),
474
- );
475
- }
476
- }
 
8
  import '../../theme/app_theme.dart';
9
  import 'dialogue_screen.dart';
10
 
 
 
11
  class _Scenario {
12
  const _Scenario({
13
  required this.title,
 
155
  }
156
 
157
  return Scaffold(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  body: SafeArea(
159
  child: Center(
160
  child: ConstrainedBox(
161
  constraints: const BoxConstraints(maxWidth: 980),
162
  child: ListView(
163
+ padding: const EdgeInsets.fromLTRB(16, 18, 16, 28),
164
  children: [
165
+ _Banner(profile: profile),
166
+ const SizedBox(height: 22),
167
+ _SectionTitle(icon: Icons.dashboard_customize_rounded, title: 'SCENARIO LIBRARY'),
 
 
168
  const SizedBox(height: 14),
169
  _ScenarioGrid(
170
  busyKey: _busyKey,
 
176
  loading: _busyKey == 'custom',
177
  onGenerate: _generateCustom,
178
  ),
 
 
179
  ],
180
  ),
181
  ),
 
185
  }
186
  }
187
 
188
+ class _Banner extends StatelessWidget {
189
+ const _Banner({required this.profile});
190
+ final Profile? profile;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
 
192
  @override
193
  Widget build(BuildContext context) {
194
  return Container(
 
195
  decoration: BoxDecoration(
196
+ color: kBrutalTeal,
197
+ borderRadius: BorderRadius.circular(kBrutalRadius),
198
+ border: Border.all(color: kBrutalBlack, width: 3),
199
+ boxShadow: const [
200
+ BoxShadow(color: kBrutalBlack, offset: Offset(5, 5), blurRadius: 0),
201
+ ],
202
  ),
203
+ padding: const EdgeInsets.all(20),
204
+ child: Row(
205
+ children: [
206
+ Container(
207
+ width: 56,
208
+ height: 56,
209
+ decoration: BoxDecoration(
210
+ color: kBrutalWhite,
211
+ borderRadius: BorderRadius.circular(kBrutalRadius),
212
+ border: Border.all(color: kBrutalBlack, width: 2),
213
+ boxShadow: const [
214
+ BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0),
215
+ ],
216
+ ),
217
+ child: const Icon(Icons.chat_bubble_rounded, color: kBrutalBlack),
218
+ ),
219
+ const SizedBox(width: 14),
220
+ Expanded(
221
+ child: Column(
222
+ crossAxisAlignment: CrossAxisAlignment.start,
223
+ children: [
224
+ Text(
225
+ 'PRACTICE',
226
+ style: GoogleFonts.almarai(
227
+ color: kBrutalWhite,
228
+ fontSize: 11,
229
+ fontWeight: FontWeight.w900,
230
+ letterSpacing: 1.4,
231
+ ),
232
+ ),
233
+ Text(
234
+ 'TINY LESSON',
235
+ style: GoogleFonts.almarai(
236
+ color: kBrutalWhite,
237
+ fontSize: 22,
238
+ fontWeight: FontWeight.w900,
239
+ height: 1.05,
240
+ ),
241
+ ),
242
+ if (profile != null)
243
+ Padding(
244
+ padding: const EdgeInsets.only(top: 4),
245
+ child: Text(
246
+ '${languageName(profile!.targetLang).toUpperCase()} · ${profile!.level}',
247
+ style: GoogleFonts.almarai(
248
+ color: kBrutalWhite,
249
+ fontSize: 12,
250
+ fontWeight: FontWeight.w800,
251
+ ),
252
+ ),
253
+ ),
254
+ ],
255
+ ),
256
+ ),
257
+ ],
258
  ),
259
  );
260
  }
 
269
  Widget build(BuildContext context) {
270
  return Row(
271
  children: [
272
+ Icon(icon, color: kBrutalSlate, size: 22),
273
+ const SizedBox(width: 8),
274
  Expanded(
275
  child: Text(
276
  title,
277
+ style: GoogleFonts.almarai(
278
+ color: kBrutalSlate,
279
+ fontSize: 14,
280
+ fontWeight: FontWeight.w900,
281
+ letterSpacing: 1.2,
282
+ ),
283
  ),
284
  ),
285
  ],
 
303
  itemCount: _scenarios.length,
304
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
305
  crossAxisCount: columns,
306
+ mainAxisExtent: 188,
307
  crossAxisSpacing: 14,
308
  mainAxisSpacing: 14,
309
  ),
 
322
  }
323
  }
324
 
325
+ class _ScenarioCard extends StatefulWidget {
326
  const _ScenarioCard({
327
  required this.scenario,
328
  required this.loading,
 
335
  final bool disabled;
336
  final VoidCallback onTap;
337
 
338
+ @override
339
+ State<_ScenarioCard> createState() => _ScenarioCardState();
340
+ }
341
+
342
+ class _ScenarioCardState extends State<_ScenarioCard> {
343
+ bool _pressed = false;
344
+
345
  @override
346
  Widget build(BuildContext context) {
347
+ final disabled = widget.disabled;
348
+ final offset = _pressed && !disabled ? 0.0 : 4.0;
349
+
350
+ return GestureDetector(
351
+ onTapDown: disabled ? null : (_) => setState(() => _pressed = true),
352
+ onTapUp: disabled ? null : (_) => setState(() => _pressed = false),
353
+ onTapCancel: disabled ? null : () => setState(() => _pressed = false),
354
+ onTap: disabled ? null : widget.onTap,
355
+ child: AnimatedContainer(
356
+ duration: const Duration(milliseconds: 80),
357
+ transform: Matrix4.translationValues(_pressed && !disabled ? 4 : 0, _pressed && !disabled ? 4 : 0, 0),
358
+ padding: const EdgeInsets.all(16),
359
  decoration: BoxDecoration(
360
+ color: kBrutalWhite,
361
+ borderRadius: BorderRadius.circular(kBrutalRadius),
362
+ border: Border.all(color: kBrutalBlack, width: 2),
363
+ boxShadow: [
364
+ BoxShadow(color: kBrutalBlack, offset: Offset(offset, offset), blurRadius: 0),
365
+ ],
366
  ),
367
  child: Column(
368
  crossAxisAlignment: CrossAxisAlignment.start,
 
370
  Row(
371
  children: [
372
  Container(
373
+ width: 44,
374
+ height: 44,
375
+ decoration: BoxDecoration(
376
+ color: kBrutalYellow,
377
+ borderRadius: BorderRadius.circular(kBrutalRadius),
378
+ border: Border.all(color: kBrutalBlack, width: 2),
379
+ ),
380
+ child: Icon(widget.scenario.icon, color: kBrutalBlack, size: 22),
381
  ),
382
  const Spacer(),
383
+ Icon(
384
+ widget.loading ? Icons.hourglass_top_rounded : Icons.arrow_forward_rounded,
385
+ color: widget.loading ? kBrutalMuted : kBrutalBlack,
386
+ ),
387
  ],
388
  ),
389
+ const SizedBox(height: 14),
390
  Text(
391
+ widget.scenario.title,
392
  maxLines: 1,
393
  overflow: TextOverflow.ellipsis,
394
+ style: GoogleFonts.almarai(color: kBrutalSlate, fontSize: 18, fontWeight: FontWeight.w900),
395
  ),
396
+ const SizedBox(height: 6),
397
  Expanded(
398
  child: Text(
399
+ widget.scenario.description,
400
  maxLines: 3,
401
  overflow: TextOverflow.ellipsis,
402
+ style: GoogleFonts.almarai(color: kBrutalMuted, fontSize: 12, fontWeight: FontWeight.w800, height: 1.35),
403
  ),
404
  ),
405
  Text(
406
+ widget.loading ? 'GENERATING…' : 'GENERATE DIALOGUE',
407
+ style: GoogleFonts.almarai(
408
+ color: widget.loading ? kBrutalMuted : kBrutalTeal,
409
+ fontSize: 11,
410
+ fontWeight: FontWeight.w900,
411
+ letterSpacing: 1.0,
412
+ ),
413
  ),
414
  ],
415
  ),
 
432
  @override
433
  Widget build(BuildContext context) {
434
  return Container(
435
+ padding: const EdgeInsets.all(18),
436
+ decoration: brutalCard(),
437
  child: Column(
438
  crossAxisAlignment: CrossAxisAlignment.start,
439
  children: [
440
+ Text(
441
+ 'BUILD YOUR OWN',
442
+ style: GoogleFonts.almarai(
443
+ color: kBrutalTeal,
444
+ fontSize: 11,
445
+ fontWeight: FontWeight.w900,
446
+ letterSpacing: 1.2,
447
+ ),
448
+ ),
449
  const SizedBox(height: 8),
450
  Text(
451
  'What do you want to practice?',
452
+ style: GoogleFonts.almarai(color: kBrutalSlate, fontSize: 22, fontWeight: FontWeight.w900, height: 1.1),
453
  ),
454
  const SizedBox(height: 12),
455
  TextField(
 
467
  child: FilledButton.icon(
468
  onPressed: loading ? null : onGenerate,
469
  icon: const Icon(Icons.arrow_forward_rounded),
470
+ label: Text(loading ? 'GENERATING…' : 'GENERATE DIALOGUE'),
471
  ),
472
  ),
473
  ],
 
475
  );
476
  }
477
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/lib/features/practice/stories_list_screen.dart DELETED
@@ -1,213 +0,0 @@
1
- import 'package:flutter/material.dart';
2
- import 'package:flutter_riverpod/flutter_riverpod.dart';
3
- import 'package:go_router/go_router.dart';
4
- import 'package:google_fonts/google_fonts.dart';
5
-
6
- import '../../core/api_client.dart';
7
- import '../../theme/app_theme.dart';
8
-
9
- final _storiesProvider = FutureProvider.autoDispose<List<StorySummary>>((ref) async {
10
- final api = ref.watch(apiClientProvider);
11
- final res = await api.dio.get<List<dynamic>>('/api/stories');
12
- final list = res.data ?? [];
13
- return list.map((e) => StorySummary.fromJson(e as Map<String, dynamic>)).toList();
14
- });
15
-
16
- class StorySummary {
17
- StorySummary({required this.id, required this.title, this.read = false});
18
- final String id;
19
- final String title;
20
- final bool read;
21
-
22
- factory StorySummary.fromJson(Map<String, dynamic> j) => StorySummary(
23
- id: (j['id'] ?? '') as String,
24
- title: (j['title'] ?? '') as String,
25
- );
26
- }
27
-
28
- class StoriesListScreen extends ConsumerWidget {
29
- const StoriesListScreen({super.key});
30
-
31
- @override
32
- Widget build(BuildContext context, WidgetRef ref) {
33
- return Scaffold(
34
- appBar: AppBar(title: const Text('Stories')),
35
- body: const StoriesTabContent(),
36
- );
37
- }
38
- }
39
-
40
- class StoriesTabContent extends ConsumerWidget {
41
- const StoriesTabContent({super.key});
42
-
43
- @override
44
- Widget build(BuildContext context, WidgetRef ref) {
45
- final async = ref.watch(_storiesProvider);
46
-
47
- return async.when(
48
- loading: () => const Center(child: CircularProgressIndicator(color: kPrimary)),
49
- error: (e, _) => Center(child: Text('Error: $e')),
50
- data: (stories) => RefreshIndicator(
51
- color: kPrimary,
52
- onRefresh: () => ref.refresh(_storiesProvider.future),
53
- child: Center(
54
- child: ConstrainedBox(
55
- constraints: const BoxConstraints(maxWidth: 760),
56
- child: ListView(
57
- padding: const EdgeInsets.all(16),
58
- children: [
59
- _GenerateStoryButton(onCreated: (id) {
60
- ref.invalidate(_storiesProvider);
61
- context.push('/practice/$id');
62
- }),
63
- const SizedBox(height: 18),
64
- if (stories.isEmpty)
65
- const _EmptyStories()
66
- else
67
- ...stories.map((story) => _StoryCard(story: story, onTap: () => context.push('/practice/${story.id}'))),
68
- ],
69
- ),
70
- ),
71
- ),
72
- ),
73
- );
74
- }
75
- }
76
-
77
- class _StoryCard extends StatelessWidget {
78
- const _StoryCard({required this.story, required this.onTap});
79
- final StorySummary story;
80
- final VoidCallback onTap;
81
-
82
- @override
83
- Widget build(BuildContext context) {
84
- return Padding(
85
- padding: const EdgeInsets.only(bottom: 14),
86
- child: InkWell(
87
- borderRadius: BorderRadius.circular(18),
88
- onTap: onTap,
89
- child: Container(
90
- padding: const EdgeInsets.all(16),
91
- decoration: duoCardDecoration(),
92
- child: Row(
93
- children: [
94
- Container(
95
- width: 48,
96
- height: 48,
97
- decoration: BoxDecoration(
98
- color: const Color(0xFFFEF3C7),
99
- borderRadius: BorderRadius.circular(16),
100
- ),
101
- child: const Icon(Icons.menu_book_rounded, color: Color(0xFF92400E)),
102
- ),
103
- const SizedBox(width: 14),
104
- Expanded(
105
- child: Text(
106
- story.title,
107
- maxLines: 2,
108
- overflow: TextOverflow.ellipsis,
109
- style: GoogleFonts.almarai(color: kForeground, fontWeight: FontWeight.w900, fontSize: 16),
110
- ),
111
- ),
112
- const Icon(Icons.chevron_right_rounded, color: kMuted),
113
- ],
114
- ),
115
- ),
116
- ),
117
- );
118
- }
119
- }
120
-
121
- class _EmptyStories extends StatelessWidget {
122
- const _EmptyStories();
123
-
124
- @override
125
- Widget build(BuildContext context) {
126
- return Container(
127
- padding: const EdgeInsets.all(28),
128
- decoration: duoCardDecoration(),
129
- child: Column(
130
- children: [
131
- Container(
132
- width: 58,
133
- height: 58,
134
- decoration: BoxDecoration(color: const Color(0xFFFEF3C7), borderRadius: BorderRadius.circular(18)),
135
- child: const Icon(Icons.menu_book_rounded, color: Color(0xFF92400E), size: 30),
136
- ),
137
- const SizedBox(height: 14),
138
- Text('No stories yet', style: GoogleFonts.almarai(color: kForeground, fontWeight: FontWeight.w900, fontSize: 18)),
139
- const SizedBox(height: 4),
140
- Text(
141
- 'Generate a bite-size reading lesson.',
142
- textAlign: TextAlign.center,
143
- style: GoogleFonts.almarai(color: kMuted, fontWeight: FontWeight.w800, fontSize: 13),
144
- ),
145
- ],
146
- ),
147
- );
148
- }
149
- }
150
-
151
- class _GenerateStoryButton extends ConsumerStatefulWidget {
152
- const _GenerateStoryButton({required this.onCreated});
153
- final void Function(String id) onCreated;
154
-
155
- @override
156
- ConsumerState<_GenerateStoryButton> createState() => _GenerateStoryButtonState();
157
- }
158
-
159
- class _GenerateStoryButtonState extends ConsumerState<_GenerateStoryButton> {
160
- bool _loading = false;
161
-
162
- Future<void> _generate(BuildContext context) async {
163
- final ctrl = TextEditingController();
164
- final topic = await showDialog<String>(
165
- context: context,
166
- builder: (ctx) => AlertDialog(
167
- title: const Text('New story'),
168
- content: TextField(
169
- controller: ctrl,
170
- decoration: const InputDecoration(labelText: 'Topic', hintText: 'A rainy day'),
171
- ),
172
- actions: [
173
- TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')),
174
- FilledButton(onPressed: () => Navigator.pop(ctx, ctrl.text.trim()), child: const Text('Generate')),
175
- ],
176
- ),
177
- );
178
- ctrl.dispose();
179
- if (topic == null) return;
180
- setState(() => _loading = true);
181
- try {
182
- final api = ref.read(apiClientProvider);
183
- final res = await api.dio.post<Map<String, dynamic>>(
184
- '/api/stories',
185
- data: {if (topic.isNotEmpty) 'topic': topic},
186
- );
187
- if ((res.statusCode == 200 || res.statusCode == 201) && res.data?['story'] != null) {
188
- final id = (res.data!['story'] as Map<String, dynamic>)['id'] as String;
189
- widget.onCreated(id);
190
- }
191
- } catch (e) {
192
- if (context.mounted) {
193
- ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
194
- }
195
- } finally {
196
- if (mounted) setState(() => _loading = false);
197
- }
198
- }
199
-
200
- @override
201
- Widget build(BuildContext context) {
202
- return SizedBox(
203
- width: double.infinity,
204
- child: FilledButton.icon(
205
- onPressed: _loading ? null : () => _generate(context),
206
- icon: const Icon(Icons.auto_awesome_rounded),
207
- label: Text(_loading ? 'Generating...' : 'New story'),
208
- ),
209
- );
210
- }
211
- }
212
-
213
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/lib/features/practice/story_screen.dart DELETED
@@ -1,501 +0,0 @@
1
- import 'package:flutter/gestures.dart';
2
- import 'package:flutter/material.dart';
3
- import 'package:flutter_riverpod/flutter_riverpod.dart';
4
- import 'package:google_fonts/google_fonts.dart';
5
-
6
- import '../../auth/auth_provider.dart';
7
- import '../../core/api_client.dart';
8
- import '../../theme/app_theme.dart';
9
- import '../../widgets/tts_button.dart';
10
-
11
- class _StoryData {
12
- _StoryData({
13
- required this.title,
14
- required this.content,
15
- required this.targetLang,
16
- required this.vocab,
17
- });
18
-
19
- final String title;
20
- final String content;
21
- final String targetLang;
22
- final List<Map<String, String>> vocab;
23
- }
24
-
25
- final storyFuture = FutureProvider.family.autoDispose<_StoryData, String>((ref, id) async {
26
- final api = ref.watch(apiClientProvider);
27
- final res = await api.dio.get<Map<String, dynamic>>('/api/stories/$id');
28
- final s = res.data!['story'] as Map<String, dynamic>;
29
- final vocab = ((s['vocab'] as List?) ?? [])
30
- .cast<Map<String, dynamic>>()
31
- .map((v) => {'word': (v['word'] ?? '').toString(), 'gloss': (v['gloss'] ?? '').toString()})
32
- .toList();
33
- return _StoryData(
34
- title: s['title'] as String,
35
- content: s['content'] as String,
36
- targetLang: s['targetLang'] as String,
37
- vocab: vocab,
38
- );
39
- });
40
-
41
- class StoryQuestion {
42
- StoryQuestion({required this.question, required this.options, required this.correctIndex, required this.explanation});
43
- final String question;
44
- final List<String> options;
45
- final int correctIndex;
46
- final String explanation;
47
-
48
- factory StoryQuestion.fromJson(Map<String, dynamic> j) => StoryQuestion(
49
- question: (j['question'] ?? '').toString(),
50
- options: ((j['options'] as List?) ?? []).map((o) => o.toString()).toList(),
51
- correctIndex: (j['correctIndex'] is num) ? (j['correctIndex'] as num).toInt() : 0,
52
- explanation: (j['explanation'] ?? '').toString(),
53
- );
54
- }
55
-
56
- class StoryScreen extends ConsumerStatefulWidget {
57
- const StoryScreen({super.key, required this.storyId});
58
- final String storyId;
59
-
60
- @override
61
- ConsumerState<StoryScreen> createState() => _StoryScreenState();
62
- }
63
-
64
- class _StoryScreenState extends ConsumerState<StoryScreen> {
65
- bool _readMarked = false;
66
- bool _quizLoading = false;
67
- String? _quizError;
68
- List<StoryQuestion>? _questions;
69
- final Map<int, int> _answers = {};
70
-
71
- Future<void> _markRead() async {
72
- if (_readMarked) return;
73
- _readMarked = true;
74
- try {
75
- final api = ref.read(apiClientProvider);
76
- await api.dio.post<Map<String, dynamic>>('/api/stories/${widget.storyId}/read');
77
- } catch (_) {}
78
- }
79
-
80
- Future<void> _loadQuiz(String content) async {
81
- setState(() {
82
- _quizLoading = true;
83
- _quizError = null;
84
- });
85
- try {
86
- final api = ref.read(apiClientProvider);
87
- final res = await api.dio.post<dynamic>(
88
- '/api/stories/${widget.storyId}/questions',
89
- data: {'content': content},
90
- );
91
- final data = res.data;
92
- if ((res.statusCode ?? 500) >= 200 && (res.statusCode ?? 500) < 300 && data is List) {
93
- final qs = data
94
- .whereType<Map>()
95
- .map((m) => StoryQuestion.fromJson(Map<String, dynamic>.from(m)))
96
- .where((q) => q.question.isNotEmpty && q.options.length >= 2)
97
- .toList();
98
- if (mounted) setState(() => _questions = qs);
99
- } else {
100
- if (mounted) setState(() => _quizError = 'Could not load questions. Try again.');
101
- }
102
- } catch (_) {
103
- if (mounted) setState(() => _quizError = 'Could not load questions. Try again.');
104
- } finally {
105
- if (mounted) setState(() => _quizLoading = false);
106
- }
107
- }
108
-
109
- void _answer(int qIdx, int oIdx) {
110
- if (_answers.containsKey(qIdx)) return;
111
- setState(() => _answers[qIdx] = oIdx);
112
- }
113
-
114
- int? get _quizScore {
115
- if (_questions == null) return null;
116
- if (_answers.length != _questions!.length) return null;
117
- var correct = 0;
118
- for (var i = 0; i < _questions!.length; i++) {
119
- if (_answers[i] == _questions![i].correctIndex) correct++;
120
- }
121
- return correct;
122
- }
123
-
124
- @override
125
- Widget build(BuildContext context) {
126
- final async = ref.watch(storyFuture(widget.storyId));
127
- final profile = ref.watch(authControllerProvider).profile;
128
-
129
- return Scaffold(
130
- appBar: AppBar(title: const Text('Story')),
131
- body: async.when(
132
- loading: () => const Center(child: CircularProgressIndicator(color: kPrimary)),
133
- error: (e, _) => Center(child: Text('Error: $e')),
134
- data: (story) {
135
- WidgetsBinding.instance.addPostFrameCallback((_) => _markRead());
136
- return Center(
137
- child: ConstrainedBox(
138
- constraints: const BoxConstraints(maxWidth: 760),
139
- child: ListView(
140
- padding: const EdgeInsets.all(16),
141
- children: [
142
- Container(
143
- decoration: duoCardDecoration(color: kWarning),
144
- padding: const EdgeInsets.all(20),
145
- child: Row(
146
- children: [
147
- Container(
148
- width: 52,
149
- height: 52,
150
- decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(18)),
151
- child: const Icon(Icons.menu_book_rounded, color: Color(0xFF92400E)),
152
- ),
153
- const SizedBox(width: 14),
154
- Expanded(
155
- child: Text(
156
- story.title,
157
- style: GoogleFonts.almarai(color: kForeground, fontSize: 26, fontWeight: FontWeight.w900, height: 1.1),
158
- ),
159
- ),
160
- const SizedBox(width: 8),
161
- TtsButton(text: story.content, lang: story.targetLang, size: 38),
162
- ],
163
- ),
164
- ),
165
- const SizedBox(height: 16),
166
- Container(
167
- padding: const EdgeInsets.all(20),
168
- decoration: duoCardDecoration(),
169
- child: _TappableText(
170
- text: story.content,
171
- fromLang: story.targetLang,
172
- toLang: profile?.nativeLang ?? 'en',
173
- preGlossed: {for (final v in story.vocab) v['word']!.toLowerCase(): v['gloss']!},
174
- ref: ref,
175
- ),
176
- ),
177
- if (story.vocab.isNotEmpty) ...[
178
- const SizedBox(height: 18),
179
- Container(
180
- padding: const EdgeInsets.all(18),
181
- decoration: duoCardDecoration(),
182
- child: Column(
183
- crossAxisAlignment: CrossAxisAlignment.start,
184
- children: [
185
- Text('Key vocabulary', style: GoogleFonts.almarai(color: kMuted, fontWeight: FontWeight.w900, fontSize: 12)),
186
- const SizedBox(height: 12),
187
- ...story.vocab.map(
188
- (v) => Container(
189
- margin: const EdgeInsets.only(bottom: 10),
190
- padding: const EdgeInsets.all(12),
191
- decoration: BoxDecoration(
192
- color: kCardFeature,
193
- borderRadius: BorderRadius.circular(16),
194
- border: Border.all(color: kBorder, width: 2),
195
- ),
196
- child: Row(
197
- children: [
198
- Expanded(
199
- child: Text(v['word'] ?? '', style: GoogleFonts.almarai(fontWeight: FontWeight.w900, color: kForeground)),
200
- ),
201
- const SizedBox(width: 10),
202
- Flexible(
203
- child: Text(
204
- v['gloss'] ?? '',
205
- textAlign: TextAlign.right,
206
- style: GoogleFonts.almarai(fontWeight: FontWeight.w800, color: kMuted),
207
- ),
208
- ),
209
- ],
210
- ),
211
- ),
212
- ),
213
- ],
214
- ),
215
- ),
216
- ],
217
- const SizedBox(height: 18),
218
- _QuizSection(
219
- questions: _questions,
220
- loading: _quizLoading,
221
- error: _quizError,
222
- answers: _answers,
223
- score: _quizScore,
224
- onGenerate: () => _loadQuiz(story.content),
225
- onAnswer: _answer,
226
- ),
227
- const SizedBox(height: 24),
228
- ],
229
- ),
230
- ),
231
- );
232
- },
233
- ),
234
- );
235
- }
236
- }
237
-
238
- class _QuizSection extends StatelessWidget {
239
- const _QuizSection({
240
- required this.questions,
241
- required this.loading,
242
- required this.error,
243
- required this.answers,
244
- required this.score,
245
- required this.onGenerate,
246
- required this.onAnswer,
247
- });
248
-
249
- final List<StoryQuestion>? questions;
250
- final bool loading;
251
- final String? error;
252
- final Map<int, int> answers;
253
- final int? score;
254
- final VoidCallback onGenerate;
255
- final void Function(int qIdx, int oIdx) onAnswer;
256
-
257
- @override
258
- Widget build(BuildContext context) {
259
- return Container(
260
- padding: const EdgeInsets.all(18),
261
- decoration: duoCardDecoration(),
262
- child: Column(
263
- crossAxisAlignment: CrossAxisAlignment.start,
264
- children: [
265
- Row(
266
- children: [
267
- Container(
268
- width: 44,
269
- height: 44,
270
- decoration: BoxDecoration(color: const Color(0xFFEFF6FF), borderRadius: BorderRadius.circular(14)),
271
- child: const Icon(Icons.menu_book_rounded, color: kSecondary, size: 22),
272
- ),
273
- const SizedBox(width: 12),
274
- Expanded(
275
- child: Column(
276
- crossAxisAlignment: CrossAxisAlignment.start,
277
- children: [
278
- Text('Test yourself', style: GoogleFonts.almarai(color: kForeground, fontSize: 18, fontWeight: FontWeight.w900)),
279
- Text('Answer every question to finish the lesson.',
280
- style: GoogleFonts.almarai(color: kMuted, fontSize: 12, fontWeight: FontWeight.w800)),
281
- ],
282
- ),
283
- ),
284
- ],
285
- ),
286
- const SizedBox(height: 16),
287
- if (questions == null && !loading && error == null)
288
- FilledButton(onPressed: onGenerate, child: const Text('Generate questions')),
289
- if (loading)
290
- Text('Generating questions...', style: GoogleFonts.almarai(color: kMuted, fontWeight: FontWeight.w800)),
291
- if (error != null) ...[
292
- Text(error!, style: GoogleFonts.almarai(color: kDanger, fontWeight: FontWeight.w900, fontSize: 13)),
293
- const SizedBox(height: 8),
294
- OutlinedButton(onPressed: onGenerate, child: const Text('Retry')),
295
- ],
296
- if (questions != null && questions!.isEmpty)
297
- Text('No questions were generated for this story.',
298
- style: GoogleFonts.almarai(color: kMuted, fontWeight: FontWeight.w800)),
299
- if (questions != null && questions!.isNotEmpty) ...[
300
- for (var qIdx = 0; qIdx < questions!.length; qIdx++) ...[
301
- const SizedBox(height: 18),
302
- Text('${qIdx + 1}. ${questions![qIdx].question}',
303
- style: GoogleFonts.almarai(color: kForeground, fontSize: 15, fontWeight: FontWeight.w900)),
304
- const SizedBox(height: 10),
305
- ...List.generate(questions![qIdx].options.length, (oIdx) {
306
- final option = questions![qIdx].options[oIdx];
307
- final made = answers.containsKey(qIdx);
308
- final chosen = answers[qIdx] == oIdx;
309
- final correct = oIdx == questions![qIdx].correctIndex;
310
- Color bg = kCard;
311
- Color border = kBorder;
312
- Color textColor = kForeground;
313
- if (made && correct) {
314
- bg = const Color(0xFFCCFBF1);
315
- border = const Color(0xFF0EA5A4);
316
- textColor = const Color(0xFF0B7C7B);
317
- } else if (made && chosen && !correct) {
318
- bg = const Color(0xFFFFE4E6);
319
- border = kDanger;
320
- textColor = kDangerShadow;
321
- } else if (made) {
322
- textColor = const Color(0xFFAFAFAF);
323
- }
324
- return Padding(
325
- padding: const EdgeInsets.only(bottom: 8),
326
- child: InkWell(
327
- borderRadius: BorderRadius.circular(14),
328
- onTap: made ? null : () => onAnswer(qIdx, oIdx),
329
- child: Container(
330
- width: double.infinity,
331
- padding: const EdgeInsets.all(14),
332
- decoration: BoxDecoration(
333
- color: bg,
334
- borderRadius: BorderRadius.circular(14),
335
- border: Border.all(color: border, width: 2),
336
- ),
337
- child: Text(option,
338
- style: GoogleFonts.almarai(color: textColor, fontSize: 14, fontWeight: FontWeight.w900)),
339
- ),
340
- ),
341
- );
342
- }),
343
- if (answers.containsKey(qIdx))
344
- Padding(
345
- padding: const EdgeInsets.only(top: 6),
346
- child: Text(questions![qIdx].explanation,
347
- style: GoogleFonts.almarai(color: kMuted, fontSize: 12, fontWeight: FontWeight.w800)),
348
- ),
349
- ],
350
- if (score != null) ...[
351
- const SizedBox(height: 18),
352
- Container(
353
- padding: const EdgeInsets.all(18),
354
- decoration: BoxDecoration(
355
- color: const Color(0xFFCCFBF1),
356
- borderRadius: BorderRadius.circular(18),
357
- border: Border.all(color: const Color(0xFF0EA5A4), width: 2),
358
- ),
359
- child: Column(
360
- children: [
361
- const Icon(Icons.check_circle_rounded, color: Color(0xFF0B7C7B), size: 36),
362
- const SizedBox(height: 6),
363
- Text('$score / ${questions!.length}',
364
- style: GoogleFonts.almarai(color: const Color(0xFF0B7C7B), fontSize: 28, fontWeight: FontWeight.w900)),
365
- Text(score == questions!.length ? 'Perfect score!' : 'Keep reading to improve!',
366
- style: GoogleFonts.almarai(color: const Color(0xFF0B7C7B), fontWeight: FontWeight.w900, fontSize: 13)),
367
- ],
368
- ),
369
- ),
370
- ],
371
- ],
372
- ],
373
- ),
374
- );
375
- }
376
- }
377
-
378
- class _TappableText extends StatelessWidget {
379
- const _TappableText({
380
- required this.text,
381
- required this.fromLang,
382
- required this.toLang,
383
- required this.preGlossed,
384
- required this.ref,
385
- });
386
-
387
- final String text;
388
- final String fromLang;
389
- final String toLang;
390
- final Map<String, String> preGlossed;
391
- final WidgetRef ref;
392
-
393
- @override
394
- Widget build(BuildContext context) {
395
- final re = RegExp(r"[\p{L}\p{M}][\p{L}\p{M}\p{N}\-']*", unicode: true);
396
- final children = <InlineSpan>[];
397
- var last = 0;
398
-
399
- for (final match in re.allMatches(text)) {
400
- if (match.start > last) {
401
- children.add(TextSpan(text: text.substring(last, match.start)));
402
- }
403
- final word = match.group(0)!;
404
- children.add(
405
- TextSpan(
406
- text: word,
407
- style: const TextStyle(
408
- decoration: TextDecoration.underline,
409
- decorationColor: kPrimary,
410
- decorationThickness: 2,
411
- ),
412
- recognizer: TapGestureRecognizer()..onTap = () => _showTranslation(context, word),
413
- ),
414
- );
415
- last = match.end;
416
- }
417
- if (last < text.length) children.add(TextSpan(text: text.substring(last)));
418
-
419
- return RichText(
420
- text: TextSpan(
421
- style: GoogleFonts.almarai(color: kForeground, fontSize: 18, fontWeight: FontWeight.w800, height: 1.6),
422
- children: children,
423
- ),
424
- );
425
- }
426
-
427
- void _showTranslation(BuildContext context, String word) {
428
- final clean = word.toLowerCase();
429
- final preGloss = preGlossed[clean];
430
- final api = ref.read(apiClientProvider);
431
-
432
- showModalBottomSheet<void>(
433
- context: context,
434
- builder: (_) => _TranslationSheet(
435
- word: word,
436
- preGloss: preGloss,
437
- load: () async {
438
- if (preGloss != null) return preGloss;
439
- try {
440
- final res = await api.dio.post<Map<String, dynamic>>(
441
- '/api/translate',
442
- data: {'text': word, 'from': fromLang, 'to': toLang},
443
- );
444
- return res.statusCode == 200 ? (res.data?['translation'] ?? '-').toString() : '-';
445
- } catch (_) {
446
- return '-';
447
- }
448
- },
449
- ),
450
- );
451
- }
452
- }
453
-
454
- class _TranslationSheet extends StatelessWidget {
455
- const _TranslationSheet({
456
- required this.word,
457
- required this.preGloss,
458
- required this.load,
459
- });
460
-
461
- final String word;
462
- final String? preGloss;
463
- final Future<String> Function() load;
464
-
465
- @override
466
- Widget build(BuildContext context) {
467
- return Padding(
468
- padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + MediaQuery.of(context).padding.bottom),
469
- child: FutureBuilder<String>(
470
- future: load(),
471
- initialData: preGloss,
472
- builder: (context, snapshot) {
473
- final loading = !snapshot.hasData;
474
- return Column(
475
- mainAxisSize: MainAxisSize.min,
476
- crossAxisAlignment: CrossAxisAlignment.start,
477
- children: [
478
- Text(word, style: GoogleFonts.almarai(color: kForeground, fontSize: 24, fontWeight: FontWeight.w900)),
479
- const SizedBox(height: 10),
480
- if (loading)
481
- const Row(
482
- children: [
483
- SizedBox(width: 18, height: 18, child: CircularProgressIndicator(color: kPrimary, strokeWidth: 2)),
484
- SizedBox(width: 10),
485
- Text('Translating...'),
486
- ],
487
- )
488
- else
489
- Text(
490
- snapshot.data ?? '-',
491
- style: GoogleFonts.almarai(color: kSecondary, fontSize: 18, fontWeight: FontWeight.w900),
492
- ),
493
- ],
494
- );
495
- },
496
- ),
497
- );
498
- }
499
- }
500
-
501
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mobile/lib/features/profile/profile_screen.dart CHANGED
@@ -18,14 +18,6 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
18
  bool _savingLevel = false;
19
  bool _savingLangs = false;
20
 
21
- @override
22
- void initState() {
23
- super.initState();
24
- WidgetsBinding.instance.addPostFrameCallback((_) {
25
- ref.read(authControllerProvider.notifier).pingStreak();
26
- });
27
- }
28
-
29
  Future<void> _changeLevel(String level) async {
30
  setState(() => _savingLevel = true);
31
  try {
@@ -57,17 +49,26 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
57
  if (available.isEmpty) return;
58
  final selected = await showModalBottomSheet<String>(
59
  context: context,
 
60
  builder: (ctx) => SafeArea(
61
  child: ListView(
62
  shrinkWrap: true,
63
  children: [
64
  Padding(
65
  padding: const EdgeInsets.all(16),
66
- child: Text('Add a language', style: GoogleFonts.almarai(color: kForeground, fontSize: 18, fontWeight: FontWeight.w900)),
 
 
 
 
 
 
 
 
67
  ),
68
  ...available.map((lang) => ListTile(
69
  leading: SizedBox(width: 26, child: CountryFlag.fromCountryCode(lang.flagCode)),
70
- title: Text(lang.name, style: GoogleFonts.almarai(color: kForeground, fontWeight: FontWeight.w900)),
71
  onTap: () => Navigator.of(ctx).pop(lang.code),
72
  )),
73
  ],
@@ -79,6 +80,23 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
79
  }
80
  }
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  @override
83
  Widget build(BuildContext context) {
84
  final state = ref.watch(authControllerProvider);
@@ -87,236 +105,237 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
87
  final streak = state.streakCount;
88
 
89
  return Scaffold(
90
- appBar: AppBar(title: const Text('Profile')),
91
- body: Center(
92
- child: ConstrainedBox(
93
- constraints: const BoxConstraints(maxWidth: 760),
94
- child: ListView(
95
- padding: const EdgeInsets.all(16),
96
- children: [
97
- Container(
98
- decoration: duoCardDecoration(color: kSecondary),
99
- padding: const EdgeInsets.all(20),
100
- child: Row(
101
- children: [
102
- CircleAvatar(
103
- radius: 36,
104
- backgroundColor: Colors.white,
105
- backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null,
106
- child: user?.avatarUrl == null
107
- ? Text(
108
- user?.hfUsername.substring(0, 1).toUpperCase() ?? '?',
109
- style: GoogleFonts.almarai(fontSize: 24, fontWeight: FontWeight.w900, color: kSecondary),
110
- )
111
- : null,
112
- ),
113
- const SizedBox(width: 16),
114
- Expanded(
115
- child: Column(
116
- crossAxisAlignment: CrossAxisAlignment.start,
117
  children: [
118
- Text(
119
- user?.hfUsername ?? '-',
120
- maxLines: 1,
121
- overflow: TextOverflow.ellipsis,
122
- style: GoogleFonts.almarai(fontSize: 24, fontWeight: FontWeight.w900, color: Colors.white),
123
  ),
124
- Text(
125
- user?.email ?? 'no email',
126
- maxLines: 1,
127
- overflow: TextOverflow.ellipsis,
128
- style: GoogleFonts.almarai(color: Colors.white.withAlpha(215), fontSize: 13, fontWeight: FontWeight.w800),
 
 
 
 
 
 
 
 
 
 
 
 
129
  ),
130
  ],
131
- ),
132
- ),
133
- ],
134
- ),
135
- ),
136
- const SizedBox(height: 16),
137
- if (profile != null) ...[
138
- LayoutBuilder(
139
- builder: (context, constraints) {
140
- final twoColumns = constraints.maxWidth >= 620;
141
- return GridView.count(
142
- crossAxisCount: twoColumns ? 4 : 2,
143
- shrinkWrap: true,
144
- physics: const NeverScrollableScrollPhysics(),
145
- mainAxisSpacing: 12,
146
- crossAxisSpacing: 12,
147
- childAspectRatio: twoColumns ? 1.55 : 2.1,
148
  children: [
149
- _StatCard(
150
- icon: _flagWidget(profile.nativeLang, kSecondary),
151
- label: 'I speak',
152
- value: languageName(profile.nativeLang),
153
- tone: kSecondary,
154
- ),
155
- _StatCard(
156
- icon: _flagWidget(profile.targetLang, kPrimary),
157
- label: 'Learning',
158
- value: languageName(profile.targetLang),
159
- tone: kPrimary,
160
- ),
161
- _StatCard(
162
- icon: const Icon(Icons.emoji_events_rounded, color: kWarning),
163
- label: 'Level',
164
- value: profile.level,
165
- tone: kWarning,
166
- ),
167
- _StatCard(
168
- icon: const Icon(Icons.local_fire_department_rounded, color: Color(0xFFEF4444)),
169
- label: 'Streak',
170
- value: '$streak ${streak == 1 ? 'day' : 'days'}',
171
- tone: const Color(0xFFEF4444),
172
  ),
173
- ],
174
- );
175
- },
176
- ),
177
- const SizedBox(height: 16),
178
- Container(
179
- padding: const EdgeInsets.all(18),
180
- decoration: duoCardDecoration(),
181
- child: Column(
182
- crossAxisAlignment: CrossAxisAlignment.start,
183
- children: [
184
- Text('Languages I am learning', style: GoogleFonts.almarai(color: kForeground, fontWeight: FontWeight.w900, fontSize: 18)),
185
- const SizedBox(height: 14),
186
- Wrap(
187
- spacing: 10,
188
- runSpacing: 10,
189
- children: [
190
- for (final lang in profile.targetLangs)
191
- Container(
192
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
193
- decoration: BoxDecoration(
194
- color: kBrutalYellow,
195
- borderRadius: BorderRadius.circular(12),
196
- border: Border.all(color: kBrutalBlack, width: 2),
197
- boxShadow: const [BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0)],
198
- ),
199
- child: Row(
200
- mainAxisSize: MainAxisSize.min,
201
- children: [
202
- SizedBox(
203
- width: 22,
204
- child: _flagWidget(lang, kSecondary),
205
- ),
206
- const SizedBox(width: 8),
207
- Text(languageName(lang), style: GoogleFonts.almarai(color: kBrutalBlack, fontWeight: FontWeight.w900, fontSize: 13)),
208
- if (profile.targetLangs.length > 1) ...[
209
- const SizedBox(width: 6),
210
- InkWell(
211
- onTap: _savingLangs
212
- ? null
213
- : () => _updateLangs(profile.targetLangs.where((l) => l != lang).toList()),
214
- borderRadius: BorderRadius.circular(8),
215
- child: const Padding(
216
- padding: EdgeInsets.all(2),
217
- child: Icon(Icons.close_rounded, size: 16, color: kBrutalBlack),
218
- ),
219
- ),
220
- ],
221
- ],
222
  ),
 
 
223
  ),
224
- InkWell(
225
- onTap: _savingLangs ? null : () => _pickLanguageToAdd(profile.targetLangs, profile.nativeLang),
226
- borderRadius: BorderRadius.circular(12),
227
- child: Container(
228
- width: 40,
229
- height: 40,
230
- decoration: BoxDecoration(
231
- color: kCard,
232
- borderRadius: BorderRadius.circular(12),
233
- border: Border.all(color: kBrutalBlack, width: 2),
234
- boxShadow: const [BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0)],
235
- ),
236
- child: const Icon(Icons.add_rounded, size: 22, color: kBrutalBlack),
237
  ),
238
  ),
239
  ],
240
- ),
241
- if (_savingLangs) ...[
242
- const SizedBox(height: 10),
243
- Text('Updating languages...',
244
- style: GoogleFonts.almarai(color: const Color(0xFF0EA5A4), fontWeight: FontWeight.w900, fontSize: 11)),
245
  ],
246
- ],
247
  ),
248
- ),
249
- const SizedBox(height: 16),
250
- Container(
251
- padding: const EdgeInsets.all(18),
252
- decoration: duoCardDecoration(),
253
- child: Column(
254
- crossAxisAlignment: CrossAxisAlignment.start,
255
- children: [
256
- Text('My level', style: GoogleFonts.almarai(color: kForeground, fontWeight: FontWeight.w900, fontSize: 18)),
257
- const SizedBox(height: 14),
258
- if (_savingLevel)
259
- const Center(
260
- child: Padding(
261
- padding: EdgeInsets.symmetric(vertical: 12),
262
- child: CircularProgressIndicator(color: kPrimary, strokeWidth: 2),
263
  ),
264
- )
265
- else
266
- Wrap(
267
- spacing: 10,
268
- runSpacing: 10,
269
- children: kLevels.map((level) {
270
- final selected = profile.level == level;
271
- return InkWell(
272
- borderRadius: BorderRadius.circular(16),
273
- onTap: selected ? null : () => _changeLevel(level),
274
- child: AnimatedContainer(
275
- duration: const Duration(milliseconds: 180),
276
- padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
277
- decoration: BoxDecoration(
278
- color: selected ? const Color(0xFFEDE9FE) : kCard,
279
- borderRadius: BorderRadius.circular(16),
280
- border: Border.all(color: selected ? kSecondary : kBorder, width: 2),
281
- boxShadow: [BoxShadow(color: selected ? kSecondaryShadow : kBorder, offset: const Offset(0, 3), blurRadius: 0)],
282
- ),
283
- child: Column(
284
- crossAxisAlignment: CrossAxisAlignment.start,
285
- mainAxisSize: MainAxisSize.min,
286
- children: [
287
- Text(level, style: GoogleFonts.almarai(color: kForeground, fontWeight: FontWeight.w900, fontSize: 15)),
288
- Text(
289
- kLevelDescriptions[level] ?? '',
290
- style: GoogleFonts.almarai(color: kMuted, fontWeight: FontWeight.w800, fontSize: 11),
291
- ),
292
- ],
293
- ),
294
- ),
295
- );
296
- }).toList(),
297
  ),
298
- ],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  ),
300
- ),
 
 
301
  ],
302
- const SizedBox(height: 20),
303
- OutlinedButton.icon(
304
- onPressed: () => ref.read(authControllerProvider.notifier).signOut(),
305
- icon: const Icon(Icons.logout_rounded),
306
- label: const Text('Sign out'),
307
- ),
308
- ],
309
  ),
310
  ),
311
  ),
312
  );
313
  }
314
 
315
- Widget _flagWidget(String langCode, Color tone) {
316
  final flag = languageFlagCode(langCode);
317
- if (flag == null || flag.isEmpty) return Icon(Icons.language, color: tone);
318
- return CountryFlag.fromCountryCode(
319
- flag,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  );
321
  }
322
  }
@@ -337,14 +356,18 @@ class _StatCard extends StatelessWidget {
337
  @override
338
  Widget build(BuildContext context) {
339
  return Container(
340
- padding: const EdgeInsets.all(14),
341
- decoration: duoCardDecoration(),
342
  child: Row(
343
  children: [
344
  Container(
345
  width: 44,
346
  height: 44,
347
- decoration: BoxDecoration(color: tone.withAlpha(34), borderRadius: BorderRadius.circular(15)),
 
 
 
 
348
  child: Center(child: icon),
349
  ),
350
  const SizedBox(width: 12),
@@ -353,12 +376,20 @@ class _StatCard extends StatelessWidget {
353
  mainAxisAlignment: MainAxisAlignment.center,
354
  crossAxisAlignment: CrossAxisAlignment.start,
355
  children: [
356
- Text(label, style: GoogleFonts.almarai(color: kMuted, fontSize: 11, fontWeight: FontWeight.w900)),
 
 
 
 
 
 
 
 
357
  Text(
358
  value,
359
  maxLines: 1,
360
  overflow: TextOverflow.ellipsis,
361
- style: GoogleFonts.almarai(color: kForeground, fontSize: 15, fontWeight: FontWeight.w900),
362
  ),
363
  ],
364
  ),
@@ -368,3 +399,190 @@ class _StatCard extends StatelessWidget {
368
  );
369
  }
370
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  bool _savingLevel = false;
19
  bool _savingLangs = false;
20
 
 
 
 
 
 
 
 
 
21
  Future<void> _changeLevel(String level) async {
22
  setState(() => _savingLevel = true);
23
  try {
 
49
  if (available.isEmpty) return;
50
  final selected = await showModalBottomSheet<String>(
51
  context: context,
52
+ backgroundColor: kBrutalWhite,
53
  builder: (ctx) => SafeArea(
54
  child: ListView(
55
  shrinkWrap: true,
56
  children: [
57
  Padding(
58
  padding: const EdgeInsets.all(16),
59
+ child: Text(
60
+ 'ADD A LANGUAGE',
61
+ style: GoogleFonts.almarai(
62
+ color: kBrutalSlate,
63
+ fontSize: 14,
64
+ fontWeight: FontWeight.w900,
65
+ letterSpacing: 1.2,
66
+ ),
67
+ ),
68
  ),
69
  ...available.map((lang) => ListTile(
70
  leading: SizedBox(width: 26, child: CountryFlag.fromCountryCode(lang.flagCode)),
71
+ title: Text(lang.name, style: GoogleFonts.almarai(color: kBrutalBlack, fontWeight: FontWeight.w900)),
72
  onTap: () => Navigator.of(ctx).pop(lang.code),
73
  )),
74
  ],
 
80
  }
81
  }
82
 
83
+ Future<void> _confirmSignOut() async {
84
+ final ok = await showDialog<bool>(
85
+ context: context,
86
+ builder: (ctx) => AlertDialog(
87
+ title: const Text('Sign out?'),
88
+ content: const Text('You will need to sign in with Hugging Face again next time.'),
89
+ actions: [
90
+ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('CANCEL')),
91
+ TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('SIGN OUT')),
92
+ ],
93
+ ),
94
+ );
95
+ if (ok == true) {
96
+ await ref.read(authControllerProvider.notifier).signOut();
97
+ }
98
+ }
99
+
100
  @override
101
  Widget build(BuildContext context) {
102
  final state = ref.watch(authControllerProvider);
 
105
  final streak = state.streakCount;
106
 
107
  return Scaffold(
108
+ body: SafeArea(
109
+ child: Center(
110
+ child: ConstrainedBox(
111
+ constraints: const BoxConstraints(maxWidth: 760),
112
+ child: ListView(
113
+ padding: const EdgeInsets.fromLTRB(16, 18, 16, 28),
114
+ children: [
115
+ _UserBanner(user: user),
116
+ const SizedBox(height: 16),
117
+ if (profile != null) ...[
118
+ LayoutBuilder(
119
+ builder: (context, constraints) {
120
+ final twoColumns = constraints.maxWidth >= 620;
121
+ return GridView.count(
122
+ crossAxisCount: twoColumns ? 4 : 2,
123
+ shrinkWrap: true,
124
+ physics: const NeverScrollableScrollPhysics(),
125
+ mainAxisSpacing: 12,
126
+ crossAxisSpacing: 12,
127
+ childAspectRatio: twoColumns ? 1.55 : 2.1,
 
 
 
 
 
 
 
128
  children: [
129
+ _StatCard(
130
+ icon: _flagWidget(profile.nativeLang),
131
+ label: 'I SPEAK',
132
+ value: languageName(profile.nativeLang),
133
+ tone: kBrutalBlue,
134
  ),
135
+ _StatCard(
136
+ icon: _flagWidget(profile.targetLang),
137
+ label: 'LEARNING',
138
+ value: languageName(profile.targetLang),
139
+ tone: kBrutalTeal,
140
+ ),
141
+ _StatCard(
142
+ icon: const Icon(Icons.emoji_events_rounded, color: kBrutalBlack),
143
+ label: 'LEVEL',
144
+ value: profile.level,
145
+ tone: kBrutalYellow,
146
+ ),
147
+ _StatCard(
148
+ icon: const Icon(Icons.local_fire_department_rounded, color: kBrutalWhite),
149
+ label: 'STREAK',
150
+ value: '$streak ${streak == 1 ? 'day' : 'days'}',
151
+ tone: kBrutalRed,
152
  ),
153
  ],
154
+ );
155
+ },
156
+ ),
157
+ const SizedBox(height: 16),
158
+ Container(
159
+ padding: const EdgeInsets.all(18),
160
+ decoration: brutalCard(),
161
+ child: Column(
162
+ crossAxisAlignment: CrossAxisAlignment.start,
 
 
 
 
 
 
 
 
163
  children: [
164
+ Text(
165
+ 'LANGUAGES I AM LEARNING',
166
+ style: GoogleFonts.almarai(
167
+ color: kBrutalSlate,
168
+ fontWeight: FontWeight.w900,
169
+ fontSize: 13,
170
+ letterSpacing: 1.2,
171
+ ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  ),
173
+ const SizedBox(height: 14),
174
+ Wrap(
175
+ spacing: 10,
176
+ runSpacing: 10,
177
+ children: [
178
+ for (var i = 0; i < profile.targetLangs.length; i++)
179
+ _LangChip(
180
+ code: profile.targetLangs[i],
181
+ primary: i == 0,
182
+ removable: profile.targetLangs.length > 1,
183
+ onRemove: _savingLangs
184
+ ? null
185
+ : () => _updateLangs(profile.targetLangs.where((l) => l != profile.targetLangs[i]).toList()),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  ),
187
+ _AddLangButton(
188
+ onTap: _savingLangs ? null : () => _pickLanguageToAdd(profile.targetLangs, profile.nativeLang),
189
  ),
190
+ ],
191
+ ),
192
+ if (_savingLangs) ...[
193
+ const SizedBox(height: 10),
194
+ Text(
195
+ 'UPDATING LANGUAGES…',
196
+ style: GoogleFonts.almarai(
197
+ color: kBrutalTeal,
198
+ fontWeight: FontWeight.w900,
199
+ fontSize: 11,
200
+ letterSpacing: 1.0,
 
 
201
  ),
202
  ),
203
  ],
 
 
 
 
 
204
  ],
205
+ ),
206
  ),
207
+ const SizedBox(height: 16),
208
+ Container(
209
+ padding: const EdgeInsets.all(18),
210
+ decoration: brutalCard(),
211
+ child: Column(
212
+ crossAxisAlignment: CrossAxisAlignment.start,
213
+ children: [
214
+ Text(
215
+ 'MY LEVEL',
216
+ style: GoogleFonts.almarai(
217
+ color: kBrutalSlate,
218
+ fontWeight: FontWeight.w900,
219
+ fontSize: 13,
220
+ letterSpacing: 1.2,
 
221
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  ),
223
+ const SizedBox(height: 14),
224
+ if (_savingLevel)
225
+ const Center(
226
+ child: Padding(
227
+ padding: EdgeInsets.symmetric(vertical: 12),
228
+ child: CircularProgressIndicator(color: kBrutalTeal, strokeWidth: 3),
229
+ ),
230
+ )
231
+ else
232
+ Wrap(
233
+ spacing: 10,
234
+ runSpacing: 10,
235
+ children: kLevels.map((level) {
236
+ return _LevelTile(
237
+ level: level,
238
+ description: kLevelDescriptions[level] ?? '',
239
+ selected: profile.level == level,
240
+ onTap: profile.level == level ? null : () => _changeLevel(level),
241
+ );
242
+ }).toList(),
243
+ ),
244
+ ],
245
+ ),
246
  ),
247
+ ],
248
+ const SizedBox(height: 24),
249
+ _SignOutButton(onPressed: _confirmSignOut),
250
  ],
251
+ ),
 
 
 
 
 
 
252
  ),
253
  ),
254
  ),
255
  );
256
  }
257
 
258
+ Widget _flagWidget(String langCode) {
259
  final flag = languageFlagCode(langCode);
260
+ if (flag == null || flag.isEmpty) return const Icon(Icons.language, color: kBrutalBlack);
261
+ return SizedBox(width: 28, height: 20, child: CountryFlag.fromCountryCode(flag));
262
+ }
263
+ }
264
+
265
+ class _UserBanner extends StatelessWidget {
266
+ const _UserBanner({required this.user});
267
+ final User? user;
268
+
269
+ @override
270
+ Widget build(BuildContext context) {
271
+ return Container(
272
+ decoration: BoxDecoration(
273
+ color: kBrutalSlate,
274
+ borderRadius: BorderRadius.circular(kBrutalRadius),
275
+ border: Border.all(color: kBrutalBlack, width: 3),
276
+ boxShadow: const [
277
+ BoxShadow(color: kBrutalBlack, offset: Offset(5, 5), blurRadius: 0),
278
+ ],
279
+ ),
280
+ padding: const EdgeInsets.all(20),
281
+ child: Row(
282
+ children: [
283
+ Container(
284
+ width: 64,
285
+ height: 64,
286
+ decoration: BoxDecoration(
287
+ color: kBrutalYellow,
288
+ borderRadius: BorderRadius.circular(kBrutalRadius),
289
+ border: Border.all(color: kBrutalBlack, width: 2),
290
+ boxShadow: const [
291
+ BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0),
292
+ ],
293
+ ),
294
+ clipBehavior: Clip.antiAlias,
295
+ child: user?.avatarUrl != null
296
+ ? Image.network(user!.avatarUrl!, fit: BoxFit.cover)
297
+ : Center(
298
+ child: Text(
299
+ user?.hfUsername.substring(0, 1).toUpperCase() ?? '?',
300
+ style: GoogleFonts.almarai(fontSize: 28, fontWeight: FontWeight.w900, color: kBrutalBlack),
301
+ ),
302
+ ),
303
+ ),
304
+ const SizedBox(width: 14),
305
+ Expanded(
306
+ child: Column(
307
+ crossAxisAlignment: CrossAxisAlignment.start,
308
+ children: [
309
+ Text(
310
+ 'PROFILE',
311
+ style: GoogleFonts.almarai(
312
+ color: kBrutalYellow,
313
+ fontSize: 11,
314
+ fontWeight: FontWeight.w900,
315
+ letterSpacing: 1.4,
316
+ ),
317
+ ),
318
+ Text(
319
+ user?.hfUsername ?? '—',
320
+ maxLines: 1,
321
+ overflow: TextOverflow.ellipsis,
322
+ style: GoogleFonts.almarai(fontSize: 22, fontWeight: FontWeight.w900, color: kBrutalWhite, height: 1.05),
323
+ ),
324
+ if (user?.email != null)
325
+ Padding(
326
+ padding: const EdgeInsets.only(top: 2),
327
+ child: Text(
328
+ user!.email!,
329
+ maxLines: 1,
330
+ overflow: TextOverflow.ellipsis,
331
+ style: GoogleFonts.almarai(color: kBrutalCream, fontSize: 12, fontWeight: FontWeight.w800),
332
+ ),
333
+ ),
334
+ ],
335
+ ),
336
+ ),
337
+ ],
338
+ ),
339
  );
340
  }
341
  }
 
356
  @override
357
  Widget build(BuildContext context) {
358
  return Container(
359
+ padding: const EdgeInsets.all(12),
360
+ decoration: brutalCard(),
361
  child: Row(
362
  children: [
363
  Container(
364
  width: 44,
365
  height: 44,
366
+ decoration: BoxDecoration(
367
+ color: tone,
368
+ borderRadius: BorderRadius.circular(kBrutalRadius),
369
+ border: Border.all(color: kBrutalBlack, width: 2),
370
+ ),
371
  child: Center(child: icon),
372
  ),
373
  const SizedBox(width: 12),
 
376
  mainAxisAlignment: MainAxisAlignment.center,
377
  crossAxisAlignment: CrossAxisAlignment.start,
378
  children: [
379
+ Text(
380
+ label,
381
+ style: GoogleFonts.almarai(
382
+ color: kBrutalMuted,
383
+ fontSize: 11,
384
+ fontWeight: FontWeight.w900,
385
+ letterSpacing: 0.8,
386
+ ),
387
+ ),
388
  Text(
389
  value,
390
  maxLines: 1,
391
  overflow: TextOverflow.ellipsis,
392
+ style: GoogleFonts.almarai(color: kBrutalSlate, fontSize: 15, fontWeight: FontWeight.w900),
393
  ),
394
  ],
395
  ),
 
399
  );
400
  }
401
  }
402
+
403
+ class _LangChip extends StatelessWidget {
404
+ const _LangChip({
405
+ required this.code,
406
+ required this.primary,
407
+ required this.removable,
408
+ required this.onRemove,
409
+ });
410
+ final String code;
411
+ final bool primary;
412
+ final bool removable;
413
+ final VoidCallback? onRemove;
414
+
415
+ @override
416
+ Widget build(BuildContext context) {
417
+ final flag = languageFlagCode(code);
418
+ final fill = primary ? kBrutalTeal : kBrutalYellow;
419
+ final fg = primary ? kBrutalWhite : kBrutalBlack;
420
+
421
+ return Container(
422
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
423
+ decoration: BoxDecoration(
424
+ color: fill,
425
+ borderRadius: BorderRadius.circular(kBrutalRadius),
426
+ border: Border.all(color: kBrutalBlack, width: 2),
427
+ boxShadow: const [
428
+ BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0),
429
+ ],
430
+ ),
431
+ child: Row(
432
+ mainAxisSize: MainAxisSize.min,
433
+ children: [
434
+ if (flag != null && flag.isNotEmpty)
435
+ SizedBox(width: 22, height: 16, child: CountryFlag.fromCountryCode(flag))
436
+ else
437
+ Icon(Icons.language, color: fg, size: 16),
438
+ const SizedBox(width: 8),
439
+ Text(languageName(code), style: GoogleFonts.almarai(color: fg, fontWeight: FontWeight.w900, fontSize: 13)),
440
+ if (primary) ...[
441
+ const SizedBox(width: 6),
442
+ Container(
443
+ padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
444
+ decoration: BoxDecoration(
445
+ color: kBrutalBlack,
446
+ borderRadius: BorderRadius.circular(2),
447
+ ),
448
+ child: Text(
449
+ 'PRIMARY',
450
+ style: GoogleFonts.almarai(
451
+ color: kBrutalWhite,
452
+ fontSize: 8,
453
+ fontWeight: FontWeight.w900,
454
+ letterSpacing: 0.5,
455
+ ),
456
+ ),
457
+ ),
458
+ ],
459
+ if (removable && onRemove != null) ...[
460
+ const SizedBox(width: 6),
461
+ GestureDetector(
462
+ onTap: onRemove,
463
+ child: Icon(Icons.close_rounded, size: 16, color: fg),
464
+ ),
465
+ ],
466
+ ],
467
+ ),
468
+ );
469
+ }
470
+ }
471
+
472
+ class _AddLangButton extends StatelessWidget {
473
+ const _AddLangButton({required this.onTap});
474
+ final VoidCallback? onTap;
475
+
476
+ @override
477
+ Widget build(BuildContext context) {
478
+ return GestureDetector(
479
+ onTap: onTap,
480
+ child: Container(
481
+ width: 40,
482
+ height: 40,
483
+ decoration: BoxDecoration(
484
+ color: kBrutalWhite,
485
+ borderRadius: BorderRadius.circular(kBrutalRadius),
486
+ border: Border.all(color: kBrutalBlack, width: 2),
487
+ boxShadow: const [
488
+ BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0),
489
+ ],
490
+ ),
491
+ child: const Icon(Icons.add_rounded, size: 22, color: kBrutalBlack),
492
+ ),
493
+ );
494
+ }
495
+ }
496
+
497
+ class _LevelTile extends StatelessWidget {
498
+ const _LevelTile({
499
+ required this.level,
500
+ required this.description,
501
+ required this.selected,
502
+ required this.onTap,
503
+ });
504
+ final String level;
505
+ final String description;
506
+ final bool selected;
507
+ final VoidCallback? onTap;
508
+
509
+ @override
510
+ Widget build(BuildContext context) {
511
+ return GestureDetector(
512
+ onTap: onTap,
513
+ child: AnimatedContainer(
514
+ duration: const Duration(milliseconds: 120),
515
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
516
+ decoration: BoxDecoration(
517
+ color: selected ? kBrutalTeal : kBrutalWhite,
518
+ borderRadius: BorderRadius.circular(kBrutalRadius),
519
+ border: Border.all(color: kBrutalBlack, width: 2),
520
+ boxShadow: const [
521
+ BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0),
522
+ ],
523
+ ),
524
+ child: Column(
525
+ crossAxisAlignment: CrossAxisAlignment.start,
526
+ mainAxisSize: MainAxisSize.min,
527
+ children: [
528
+ Text(
529
+ level,
530
+ style: GoogleFonts.almarai(
531
+ color: selected ? kBrutalWhite : kBrutalBlack,
532
+ fontWeight: FontWeight.w900,
533
+ fontSize: 15,
534
+ ),
535
+ ),
536
+ Text(
537
+ description,
538
+ style: GoogleFonts.almarai(
539
+ color: selected ? kBrutalWhite : kBrutalMuted,
540
+ fontWeight: FontWeight.w800,
541
+ fontSize: 11,
542
+ ),
543
+ ),
544
+ ],
545
+ ),
546
+ ),
547
+ );
548
+ }
549
+ }
550
+
551
+ class _SignOutButton extends StatelessWidget {
552
+ const _SignOutButton({required this.onPressed});
553
+ final VoidCallback onPressed;
554
+
555
+ @override
556
+ Widget build(BuildContext context) {
557
+ return GestureDetector(
558
+ onTap: onPressed,
559
+ child: Container(
560
+ padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
561
+ decoration: BoxDecoration(
562
+ color: kBrutalRed,
563
+ borderRadius: BorderRadius.circular(kBrutalRadius),
564
+ border: Border.all(color: kBrutalBlack, width: 2),
565
+ boxShadow: const [
566
+ BoxShadow(color: kBrutalBlack, offset: Offset(4, 4), blurRadius: 0),
567
+ ],
568
+ ),
569
+ child: Row(
570
+ mainAxisAlignment: MainAxisAlignment.center,
571
+ children: [
572
+ const Icon(Icons.logout_rounded, color: kBrutalWhite),
573
+ const SizedBox(width: 10),
574
+ Text(
575
+ 'SIGN OUT',
576
+ style: GoogleFonts.almarai(
577
+ color: kBrutalWhite,
578
+ fontWeight: FontWeight.w900,
579
+ fontSize: 14,
580
+ letterSpacing: 1.2,
581
+ ),
582
+ ),
583
+ ],
584
+ ),
585
+ ),
586
+ );
587
+ }
588
+ }
mobile/lib/main.dart CHANGED
@@ -1,7 +1,6 @@
1
  import 'package:flutter/material.dart';
2
  import 'package:flutter_riverpod/flutter_riverpod.dart';
3
 
4
- import 'auth/auth_provider.dart';
5
  import 'routing/app_router.dart';
6
  import 'theme/app_theme.dart';
7
 
@@ -14,15 +13,12 @@ class LangLearnApp extends ConsumerWidget {
14
 
15
  @override
16
  Widget build(BuildContext context, WidgetRef ref) {
17
- ref.watch(authControllerProvider);
18
- final router = buildRouter(ref);
19
  return MaterialApp.router(
20
- title: 'Language Learner',
21
  theme: buildTheme(),
22
  routerConfig: router,
23
  debugShowCheckedModeBanner: false,
24
  );
25
  }
26
  }
27
-
28
-
 
1
  import 'package:flutter/material.dart';
2
  import 'package:flutter_riverpod/flutter_riverpod.dart';
3
 
 
4
  import 'routing/app_router.dart';
5
  import 'theme/app_theme.dart';
6
 
 
13
 
14
  @override
15
  Widget build(BuildContext context, WidgetRef ref) {
16
+ final router = ref.watch(routerProvider);
 
17
  return MaterialApp.router(
18
+ title: 'PraxaLing',
19
  theme: buildTheme(),
20
  routerConfig: router,
21
  debugShowCheckedModeBanner: false,
22
  );
23
  }
24
  }
 
 
mobile/lib/routing/app_router.dart CHANGED
@@ -3,15 +3,30 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
3
  import 'package:go_router/go_router.dart';
4
 
5
  import '../auth/auth_provider.dart';
 
6
  import '../features/home/home_shell.dart';
7
  import '../features/login/login_screen.dart';
8
  import '../features/onboarding/onboarding_screen.dart';
9
- import '../features/practice/dialogue_screen.dart';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
- GoRouter buildRouter(WidgetRef ref) {
12
  return GoRouter(
13
  initialLocation: '/',
14
- redirect: (ctx, state) {
 
15
  final auth = ref.read(authControllerProvider);
16
  final here = state.matchedLocation;
17
  switch (auth.status) {
@@ -23,22 +38,43 @@ GoRouter buildRouter(WidgetRef ref) {
23
  return here == '/onboarding' ? null : '/onboarding';
24
  case AuthStatus.ready:
25
  if (here == '/login' || here == '/onboarding' || here == '/') {
26
- return '/home';
27
  }
28
  return null;
29
  }
30
  },
31
  routes: [
32
- GoRoute(path: '/', builder: (_, __) => const _Splash()),
33
- GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
34
- GoRoute(path: '/onboarding', builder: (_, __) => const OnboardingScreen()),
35
- GoRoute(path: '/home', builder: (_, __) => const HomeShell()),
36
- GoRoute(
37
- path: '/dialogue/:id',
38
- builder: (_, s) => DialogueScreen(dialogueId: s.pathParameters['id']!),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  ),
40
  ],
41
  );
 
 
 
 
42
  }
43
 
44
  class _Splash extends StatelessWidget {
@@ -47,5 +83,3 @@ class _Splash extends StatelessWidget {
47
  Widget build(BuildContext context) =>
48
  const Scaffold(body: Center(child: CircularProgressIndicator()));
49
  }
50
-
51
-
 
3
  import 'package:go_router/go_router.dart';
4
 
5
  import '../auth/auth_provider.dart';
6
+ import '../features/camera/camera_screen.dart';
7
  import '../features/home/home_shell.dart';
8
  import '../features/login/login_screen.dart';
9
  import '../features/onboarding/onboarding_screen.dart';
10
+ import '../features/phrases/phrases_screen.dart';
11
+ import '../features/practice/dialogues_list_screen.dart';
12
+ import '../features/profile/profile_screen.dart';
13
+
14
+ /// Long-lived router. Riverpod's [Ref.listen] re-runs GoRouter's redirect
15
+ /// whenever auth state changes (sign-in / sign-out / onboarding finished).
16
+ final routerProvider = Provider<GoRouter>((ref) {
17
+ final phrasesNav = GlobalKey<NavigatorState>(debugLabel: 'phrases');
18
+ final cameraNav = GlobalKey<NavigatorState>(debugLabel: 'camera');
19
+ final practiceNav = GlobalKey<NavigatorState>(debugLabel: 'practice');
20
+ final profileNav = GlobalKey<NavigatorState>(debugLabel: 'profile');
21
+
22
+ final listenable = _AuthListenable();
23
+ ref.listen<AuthState>(authControllerProvider, (_, _) => listenable.bump());
24
+ ref.onDispose(listenable.dispose);
25
 
 
26
  return GoRouter(
27
  initialLocation: '/',
28
+ refreshListenable: listenable,
29
+ redirect: (_, state) {
30
  final auth = ref.read(authControllerProvider);
31
  final here = state.matchedLocation;
32
  switch (auth.status) {
 
38
  return here == '/onboarding' ? null : '/onboarding';
39
  case AuthStatus.ready:
40
  if (here == '/login' || here == '/onboarding' || here == '/') {
41
+ return '/phrases';
42
  }
43
  return null;
44
  }
45
  },
46
  routes: [
47
+ GoRoute(path: '/', builder: (_, _) => const _Splash()),
48
+ GoRoute(path: '/login', builder: (_, _) => const LoginScreen()),
49
+ GoRoute(path: '/onboarding', builder: (_, _) => const OnboardingScreen()),
50
+ StatefulShellRoute.indexedStack(
51
+ builder: (context, state, navShell) => HomeShell(navigationShell: navShell),
52
+ branches: [
53
+ // Web order: Phrases, Camera, Practice, Profile (mirrors app/(app)/layout.tsx).
54
+ StatefulShellBranch(
55
+ navigatorKey: phrasesNav,
56
+ routes: [GoRoute(path: '/phrases', builder: (_, _) => const PhrasesScreen())],
57
+ ),
58
+ StatefulShellBranch(
59
+ navigatorKey: cameraNav,
60
+ routes: [GoRoute(path: '/camera', builder: (_, _) => const CameraScreen())],
61
+ ),
62
+ StatefulShellBranch(
63
+ navigatorKey: practiceNav,
64
+ routes: [GoRoute(path: '/practice', builder: (_, _) => const DialoguesListScreen())],
65
+ ),
66
+ StatefulShellBranch(
67
+ navigatorKey: profileNav,
68
+ routes: [GoRoute(path: '/profile', builder: (_, _) => const ProfileScreen())],
69
+ ),
70
+ ],
71
  ),
72
  ],
73
  );
74
+ });
75
+
76
+ class _AuthListenable extends ChangeNotifier {
77
+ void bump() => notifyListeners();
78
  }
79
 
80
  class _Splash extends StatelessWidget {
 
83
  Widget build(BuildContext context) =>
84
  const Scaffold(body: Center(child: CircularProgressIndicator()));
85
  }
 
 
mobile/lib/theme/app_theme.dart CHANGED
@@ -4,32 +4,34 @@ import 'package:google_fonts/google_fonts.dart';
4
 
5
  // --- Neo-brutalism palette (mirrors web app/globals.css + Tailwind usage) ---
6
  const kBrutalBlack = Color(0xFF000000); // borders, shadows, body text
7
- const kBrutalYellow = Color(0xFFFFD21E); // active state — matches web #FFD21E
8
  const kBrutalWhite = Color(0xFFFFFFFF); // card / unselected button
9
- const kBrutalCream = Color(0xFFFFFBEF); // page background
10
- const kBrutalRed = Color(0xFFEF4444); // error / wrong answer
11
  const kBrutalGreen = Color(0xFF22C55E); // correct answer
12
  const kBrutalBlue = Color(0xFF3B82F6); // secondary accent (web blue)
13
- const kBrutalMuted = Color(0xFF6B7280); // secondary text
 
 
14
 
15
- // --- Back-compat aliases ---
16
- // The rest of the app references these names. By repointing them at the
17
- // neo-brutal palette, every existing screen flips style without edits.
18
- const kPrimary = kBrutalYellow;
19
- const kPrimaryShadow = kBrutalBlack;
20
- const kSecondary = kBrutalBlue;
21
  const kSecondaryShadow = kBrutalBlack;
22
- const kWarning = kBrutalYellow;
23
- const kDanger = kBrutalRed;
24
- const kDangerShadow = kBrutalBlack;
25
- const kForeground = kBrutalBlack;
26
- const kMuted = kBrutalMuted;
27
- const kBackground = kBrutalCream;
28
- const kCard = kBrutalWhite;
29
- const kCardFeature = kBrutalWhite;
30
- const kBorder = kBrutalBlack;
 
 
 
31
 
32
- // Single source of truth for the heavy display face used everywhere.
33
  TextStyle brutalFont({
34
  double? fontSize,
35
  FontWeight fontWeight = FontWeight.w800,
@@ -53,7 +55,7 @@ TextStyle brutalFont({
53
  ThemeData buildTheme() {
54
  final textTheme = GoogleFonts.almaraiTextTheme(
55
  ThemeData.light().textTheme.apply(
56
- bodyColor: kBrutalBlack,
57
  displayColor: kBrutalBlack,
58
  ),
59
  );
@@ -63,12 +65,12 @@ ThemeData buildTheme() {
63
  brightness: Brightness.light,
64
  scaffoldBackgroundColor: kBrutalCream,
65
  colorScheme: const ColorScheme.light(
66
- primary: kBrutalYellow,
67
- onPrimary: kBrutalBlack,
68
  secondary: kBrutalBlue,
69
  onSecondary: kBrutalWhite,
70
  surface: kBrutalWhite,
71
- onSurface: kBrutalBlack,
72
  outline: kBrutalBlack,
73
  error: kBrutalRed,
74
  onError: kBrutalWhite,
@@ -83,24 +85,24 @@ ThemeData buildTheme() {
83
  systemOverlayStyle: SystemUiOverlayStyle.dark,
84
  titleTextStyle: GoogleFonts.almarai(
85
  color: kBrutalBlack,
86
- fontWeight: FontWeight.w800,
87
  fontSize: 20,
88
  ),
89
  iconTheme: const IconThemeData(color: kBrutalBlack),
90
  ),
91
  filledButtonTheme: FilledButtonThemeData(
92
  style: FilledButton.styleFrom(
93
- backgroundColor: kBrutalYellow,
94
- foregroundColor: kBrutalBlack,
95
  disabledBackgroundColor: kBrutalWhite,
96
  disabledForegroundColor: kBrutalMuted,
97
  minimumSize: const Size(0, 52),
98
  padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
99
  shape: RoundedRectangleBorder(
100
- borderRadius: BorderRadius.circular(14),
101
  side: const BorderSide(color: kBrutalBlack, width: 2),
102
  ),
103
- textStyle: GoogleFonts.almarai(fontWeight: FontWeight.w800, fontSize: 15),
104
  elevation: 0,
105
  ),
106
  ),
@@ -111,16 +113,16 @@ ThemeData buildTheme() {
111
  minimumSize: const Size(0, 52),
112
  padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
113
  shape: RoundedRectangleBorder(
114
- borderRadius: BorderRadius.circular(14),
115
  side: const BorderSide(color: kBrutalBlack, width: 2),
116
  ),
117
- textStyle: GoogleFonts.almarai(fontWeight: FontWeight.w800, fontSize: 15),
118
  ),
119
  ),
120
  textButtonTheme: TextButtonThemeData(
121
  style: TextButton.styleFrom(
122
  foregroundColor: kBrutalBlack,
123
- textStyle: GoogleFonts.almarai(fontWeight: FontWeight.w800),
124
  ),
125
  ),
126
  cardTheme: CardThemeData(
@@ -128,35 +130,38 @@ ThemeData buildTheme() {
128
  elevation: 0,
129
  surfaceTintColor: Colors.transparent,
130
  shape: RoundedRectangleBorder(
131
- borderRadius: BorderRadius.circular(14),
132
  side: const BorderSide(color: kBrutalBlack, width: 2),
133
  ),
134
  ),
135
  navigationBarTheme: NavigationBarThemeData(
136
  height: 72,
137
  backgroundColor: kBrutalWhite,
138
- indicatorColor: kBrutalYellow,
139
  surfaceTintColor: Colors.transparent,
140
  indicatorShape: RoundedRectangleBorder(
141
- borderRadius: BorderRadius.circular(12),
142
  side: const BorderSide(color: kBrutalBlack, width: 2),
143
  ),
144
  iconTheme: WidgetStateProperty.resolveWith((states) {
 
 
 
145
  return const IconThemeData(color: kBrutalBlack);
146
  }),
147
  labelTextStyle: WidgetStateProperty.resolveWith((states) {
148
  if (states.contains(WidgetState.selected)) {
149
- return GoogleFonts.almarai(color: kBrutalBlack, fontSize: 12, fontWeight: FontWeight.w800);
150
  }
151
- return GoogleFonts.almarai(color: kBrutalMuted, fontSize: 12, fontWeight: FontWeight.w700);
152
  }),
153
  ),
154
  tabBarTheme: TabBarThemeData(
155
  labelColor: kBrutalBlack,
156
  unselectedLabelColor: kBrutalMuted,
157
  indicatorSize: TabBarIndicatorSize.tab,
158
- labelStyle: GoogleFonts.almarai(fontWeight: FontWeight.w800),
159
- unselectedLabelStyle: GoogleFonts.almarai(fontWeight: FontWeight.w700),
160
  ),
161
  dividerColor: kBrutalBlack,
162
  inputDecorationTheme: InputDecorationTheme(
@@ -164,35 +169,35 @@ ThemeData buildTheme() {
164
  fillColor: kBrutalWhite,
165
  contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
166
  border: OutlineInputBorder(
167
- borderRadius: BorderRadius.circular(14),
168
  borderSide: const BorderSide(color: kBrutalBlack, width: 2),
169
  ),
170
  enabledBorder: OutlineInputBorder(
171
- borderRadius: BorderRadius.circular(14),
172
  borderSide: const BorderSide(color: kBrutalBlack, width: 2),
173
  ),
174
  focusedBorder: OutlineInputBorder(
175
- borderRadius: BorderRadius.circular(14),
176
- borderSide: const BorderSide(color: kBrutalBlack, width: 2),
177
  ),
178
  hintStyle: GoogleFonts.almarai(color: kBrutalMuted, fontWeight: FontWeight.w700),
179
  labelStyle: GoogleFonts.almarai(color: kBrutalMuted, fontWeight: FontWeight.w800),
180
  ),
181
  chipTheme: ChipThemeData(
182
  backgroundColor: kBrutalWhite,
183
- selectedColor: kBrutalYellow,
184
  disabledColor: kBrutalWhite,
185
- labelStyle: GoogleFonts.almarai(color: kBrutalBlack, fontWeight: FontWeight.w800),
186
- secondaryLabelStyle: GoogleFonts.almarai(color: kBrutalBlack, fontWeight: FontWeight.w800),
187
  side: const BorderSide(color: kBrutalBlack, width: 2),
188
- shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
189
  ),
190
  snackBarTheme: SnackBarThemeData(
191
  backgroundColor: kBrutalBlack,
192
  contentTextStyle: GoogleFonts.almarai(color: kBrutalWhite, fontWeight: FontWeight.w800),
193
  behavior: SnackBarBehavior.floating,
194
  shape: RoundedRectangleBorder(
195
- borderRadius: BorderRadius.circular(12),
196
  side: const BorderSide(color: kBrutalBlack, width: 2),
197
  ),
198
  ),
@@ -200,18 +205,18 @@ ThemeData buildTheme() {
200
  backgroundColor: kBrutalWhite,
201
  surfaceTintColor: Colors.transparent,
202
  shape: RoundedRectangleBorder(
203
- borderRadius: BorderRadius.circular(18),
204
- side: const BorderSide(color: kBrutalBlack, width: 2),
205
  ),
206
- titleTextStyle: GoogleFonts.almarai(color: kBrutalBlack, fontSize: 20, fontWeight: FontWeight.w800),
207
- contentTextStyle: GoogleFonts.almarai(color: kBrutalBlack, fontWeight: FontWeight.w700),
208
  ),
209
  bottomSheetTheme: const BottomSheetThemeData(
210
  backgroundColor: kBrutalWhite,
211
  surfaceTintColor: Colors.transparent,
212
  shape: RoundedRectangleBorder(
213
- borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
214
- side: BorderSide(color: kBrutalBlack, width: 2),
215
  ),
216
  ),
217
  );
@@ -219,17 +224,19 @@ ThemeData buildTheme() {
219
 
220
  // --- Decorations ---
221
 
222
- /// Neo-brutal card: 2px black border + hard black offset shadow.
223
  /// [offset] shrinks to indicate a pressed state (web: translate-1).
224
  BoxDecoration brutalCard({
225
  Color color = kBrutalWhite,
226
- double offset = 3,
227
- double radius = 14,
 
 
228
  }) {
229
  return BoxDecoration(
230
  color: color,
231
  borderRadius: BorderRadius.circular(radius),
232
- border: Border.all(color: kBrutalBlack, width: 2),
233
  boxShadow: [
234
  BoxShadow(color: kBrutalBlack, offset: Offset(offset, offset), blurRadius: 0),
235
  ],
@@ -239,7 +246,7 @@ BoxDecoration brutalCard({
239
  /// Pressed/active variant — collapses to a 1px shadow, yellow fill by default.
240
  BoxDecoration brutalCardPressed({
241
  Color color = kBrutalYellow,
242
- double radius = 14,
243
  }) =>
244
  brutalCard(color: color, offset: 1, radius: radius);
245
 
 
4
 
5
  // --- Neo-brutalism palette (mirrors web app/globals.css + Tailwind usage) ---
6
  const kBrutalBlack = Color(0xFF000000); // borders, shadows, body text
7
+ const kBrutalYellow = Color(0xFFFFD21E); // warning / accent
8
  const kBrutalWhite = Color(0xFFFFFFFF); // card / unselected button
9
+ const kBrutalCream = Color(0xFFF8FBFF); // page background — matches web #F8FBFF
10
+ const kBrutalRed = Color(0xFFF43F5E); // error / wrong answer — matches web #F43F5E
11
  const kBrutalGreen = Color(0xFF22C55E); // correct answer
12
  const kBrutalBlue = Color(0xFF3B82F6); // secondary accent (web blue)
13
+ const kBrutalTeal = Color(0xFF0EA5A4); // primary brand (web teal)
14
+ const kBrutalMuted = Color(0xFF777777); // secondary text — matches web #777777
15
+ const kBrutalSlate = Color(0xFF3C3C3C); // body text — matches web #3C3C3C
16
 
17
+ // --- Back-compat aliases (old screens still reference these names) ---
18
+ const kPrimary = kBrutalTeal; // teal is the primary brand color (web)
19
+ const kPrimaryShadow = kBrutalBlack;
20
+ const kSecondary = kBrutalBlue;
 
 
21
  const kSecondaryShadow = kBrutalBlack;
22
+ const kWarning = kBrutalYellow;
23
+ const kDanger = kBrutalRed;
24
+ const kDangerShadow = kBrutalBlack;
25
+ const kForeground = kBrutalSlate;
26
+ const kMuted = kBrutalMuted;
27
+ const kBackground = kBrutalCream;
28
+ const kCard = kBrutalWhite;
29
+ const kCardFeature = kBrutalWhite;
30
+ const kBorder = kBrutalBlack;
31
+
32
+ // Universal corner radius — neo-brutal: small, never soft.
33
+ const double kBrutalRadius = 4;
34
 
 
35
  TextStyle brutalFont({
36
  double? fontSize,
37
  FontWeight fontWeight = FontWeight.w800,
 
55
  ThemeData buildTheme() {
56
  final textTheme = GoogleFonts.almaraiTextTheme(
57
  ThemeData.light().textTheme.apply(
58
+ bodyColor: kBrutalSlate,
59
  displayColor: kBrutalBlack,
60
  ),
61
  );
 
65
  brightness: Brightness.light,
66
  scaffoldBackgroundColor: kBrutalCream,
67
  colorScheme: const ColorScheme.light(
68
+ primary: kBrutalTeal,
69
+ onPrimary: kBrutalWhite,
70
  secondary: kBrutalBlue,
71
  onSecondary: kBrutalWhite,
72
  surface: kBrutalWhite,
73
+ onSurface: kBrutalSlate,
74
  outline: kBrutalBlack,
75
  error: kBrutalRed,
76
  onError: kBrutalWhite,
 
85
  systemOverlayStyle: SystemUiOverlayStyle.dark,
86
  titleTextStyle: GoogleFonts.almarai(
87
  color: kBrutalBlack,
88
+ fontWeight: FontWeight.w900,
89
  fontSize: 20,
90
  ),
91
  iconTheme: const IconThemeData(color: kBrutalBlack),
92
  ),
93
  filledButtonTheme: FilledButtonThemeData(
94
  style: FilledButton.styleFrom(
95
+ backgroundColor: kBrutalTeal,
96
+ foregroundColor: kBrutalWhite,
97
  disabledBackgroundColor: kBrutalWhite,
98
  disabledForegroundColor: kBrutalMuted,
99
  minimumSize: const Size(0, 52),
100
  padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
101
  shape: RoundedRectangleBorder(
102
+ borderRadius: BorderRadius.circular(kBrutalRadius),
103
  side: const BorderSide(color: kBrutalBlack, width: 2),
104
  ),
105
+ textStyle: GoogleFonts.almarai(fontWeight: FontWeight.w900, fontSize: 15),
106
  elevation: 0,
107
  ),
108
  ),
 
113
  minimumSize: const Size(0, 52),
114
  padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
115
  shape: RoundedRectangleBorder(
116
+ borderRadius: BorderRadius.circular(kBrutalRadius),
117
  side: const BorderSide(color: kBrutalBlack, width: 2),
118
  ),
119
+ textStyle: GoogleFonts.almarai(fontWeight: FontWeight.w900, fontSize: 15),
120
  ),
121
  ),
122
  textButtonTheme: TextButtonThemeData(
123
  style: TextButton.styleFrom(
124
  foregroundColor: kBrutalBlack,
125
+ textStyle: GoogleFonts.almarai(fontWeight: FontWeight.w900),
126
  ),
127
  ),
128
  cardTheme: CardThemeData(
 
130
  elevation: 0,
131
  surfaceTintColor: Colors.transparent,
132
  shape: RoundedRectangleBorder(
133
+ borderRadius: BorderRadius.circular(kBrutalRadius),
134
  side: const BorderSide(color: kBrutalBlack, width: 2),
135
  ),
136
  ),
137
  navigationBarTheme: NavigationBarThemeData(
138
  height: 72,
139
  backgroundColor: kBrutalWhite,
140
+ indicatorColor: kBrutalTeal,
141
  surfaceTintColor: Colors.transparent,
142
  indicatorShape: RoundedRectangleBorder(
143
+ borderRadius: BorderRadius.circular(kBrutalRadius),
144
  side: const BorderSide(color: kBrutalBlack, width: 2),
145
  ),
146
  iconTheme: WidgetStateProperty.resolveWith((states) {
147
+ if (states.contains(WidgetState.selected)) {
148
+ return const IconThemeData(color: kBrutalWhite);
149
+ }
150
  return const IconThemeData(color: kBrutalBlack);
151
  }),
152
  labelTextStyle: WidgetStateProperty.resolveWith((states) {
153
  if (states.contains(WidgetState.selected)) {
154
+ return GoogleFonts.almarai(color: kBrutalBlack, fontSize: 12, fontWeight: FontWeight.w900);
155
  }
156
+ return GoogleFonts.almarai(color: kBrutalMuted, fontSize: 12, fontWeight: FontWeight.w800);
157
  }),
158
  ),
159
  tabBarTheme: TabBarThemeData(
160
  labelColor: kBrutalBlack,
161
  unselectedLabelColor: kBrutalMuted,
162
  indicatorSize: TabBarIndicatorSize.tab,
163
+ labelStyle: GoogleFonts.almarai(fontWeight: FontWeight.w900),
164
+ unselectedLabelStyle: GoogleFonts.almarai(fontWeight: FontWeight.w800),
165
  ),
166
  dividerColor: kBrutalBlack,
167
  inputDecorationTheme: InputDecorationTheme(
 
169
  fillColor: kBrutalWhite,
170
  contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
171
  border: OutlineInputBorder(
172
+ borderRadius: BorderRadius.circular(kBrutalRadius),
173
  borderSide: const BorderSide(color: kBrutalBlack, width: 2),
174
  ),
175
  enabledBorder: OutlineInputBorder(
176
+ borderRadius: BorderRadius.circular(kBrutalRadius),
177
  borderSide: const BorderSide(color: kBrutalBlack, width: 2),
178
  ),
179
  focusedBorder: OutlineInputBorder(
180
+ borderRadius: BorderRadius.circular(kBrutalRadius),
181
+ borderSide: const BorderSide(color: kBrutalBlue, width: 3),
182
  ),
183
  hintStyle: GoogleFonts.almarai(color: kBrutalMuted, fontWeight: FontWeight.w700),
184
  labelStyle: GoogleFonts.almarai(color: kBrutalMuted, fontWeight: FontWeight.w800),
185
  ),
186
  chipTheme: ChipThemeData(
187
  backgroundColor: kBrutalWhite,
188
+ selectedColor: kBrutalTeal,
189
  disabledColor: kBrutalWhite,
190
+ labelStyle: GoogleFonts.almarai(color: kBrutalBlack, fontWeight: FontWeight.w900),
191
+ secondaryLabelStyle: GoogleFonts.almarai(color: kBrutalWhite, fontWeight: FontWeight.w900),
192
  side: const BorderSide(color: kBrutalBlack, width: 2),
193
+ shape: const StadiumBorder(side: BorderSide(color: kBrutalBlack, width: 2)),
194
  ),
195
  snackBarTheme: SnackBarThemeData(
196
  backgroundColor: kBrutalBlack,
197
  contentTextStyle: GoogleFonts.almarai(color: kBrutalWhite, fontWeight: FontWeight.w800),
198
  behavior: SnackBarBehavior.floating,
199
  shape: RoundedRectangleBorder(
200
+ borderRadius: BorderRadius.circular(kBrutalRadius),
201
  side: const BorderSide(color: kBrutalBlack, width: 2),
202
  ),
203
  ),
 
205
  backgroundColor: kBrutalWhite,
206
  surfaceTintColor: Colors.transparent,
207
  shape: RoundedRectangleBorder(
208
+ borderRadius: BorderRadius.circular(kBrutalRadius),
209
+ side: const BorderSide(color: kBrutalBlack, width: 3),
210
  ),
211
+ titleTextStyle: GoogleFonts.almarai(color: kBrutalBlack, fontSize: 20, fontWeight: FontWeight.w900),
212
+ contentTextStyle: GoogleFonts.almarai(color: kBrutalSlate, fontWeight: FontWeight.w800),
213
  ),
214
  bottomSheetTheme: const BottomSheetThemeData(
215
  backgroundColor: kBrutalWhite,
216
  surfaceTintColor: Colors.transparent,
217
  shape: RoundedRectangleBorder(
218
+ borderRadius: BorderRadius.vertical(top: Radius.circular(kBrutalRadius)),
219
+ side: BorderSide(color: kBrutalBlack, width: 3),
220
  ),
221
  ),
222
  );
 
224
 
225
  // --- Decorations ---
226
 
227
+ /// Neo-brutal card: thick black border + hard black offset shadow.
228
  /// [offset] shrinks to indicate a pressed state (web: translate-1).
229
  BoxDecoration brutalCard({
230
  Color color = kBrutalWhite,
231
+ double offset = 4,
232
+ double radius = kBrutalRadius,
233
+ Color borderColor = kBrutalBlack,
234
+ double borderWidth = 2,
235
  }) {
236
  return BoxDecoration(
237
  color: color,
238
  borderRadius: BorderRadius.circular(radius),
239
+ border: Border.all(color: borderColor, width: borderWidth),
240
  boxShadow: [
241
  BoxShadow(color: kBrutalBlack, offset: Offset(offset, offset), blurRadius: 0),
242
  ],
 
246
  /// Pressed/active variant — collapses to a 1px shadow, yellow fill by default.
247
  BoxDecoration brutalCardPressed({
248
  Color color = kBrutalYellow,
249
+ double radius = kBrutalRadius,
250
  }) =>
251
  brutalCard(color: color, offset: 1, radius: radius);
252
 
mobile/lib/theme/widgets/brutal_button.dart CHANGED
@@ -1,18 +1,19 @@
1
  import 'package:flutter/material.dart';
2
  import '../app_theme.dart';
3
 
4
- /// Neo-brutal button. Black 2px border, hard 3px black offset shadow,
5
- /// collapses to 1px shadow on press (mirrors the web's
6
- /// `active:translate-x-[1px] translate-y-[1px]` behavior).
 
7
  class BrutalButton extends StatefulWidget {
8
  const BrutalButton({
9
  super.key,
10
  required this.onPressed,
11
  required this.child,
12
- this.color = kBrutalYellow,
13
- this.foregroundColor = kBrutalBlack,
14
- this.padding = const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
15
- this.radius = 14,
16
  this.expand = false,
17
  this.enabled = true,
18
  });
@@ -42,13 +43,13 @@ class _BrutalButtonState extends State<BrutalButton> {
42
  @override
43
  Widget build(BuildContext context) {
44
  final disabled = !widget.enabled || widget.onPressed == null;
45
- final offset = _pressed ? 1.0 : 3.0;
46
 
47
  final btn = AnimatedContainer(
48
  duration: const Duration(milliseconds: 80),
49
  curve: Curves.easeOut,
50
  padding: widget.padding,
51
- transform: Matrix4.translationValues(_pressed ? 2 : 0, _pressed ? 2 : 0, 0),
52
  decoration: BoxDecoration(
53
  color: disabled ? kBrutalWhite : widget.color,
54
  borderRadius: BorderRadius.circular(widget.radius),
@@ -64,7 +65,7 @@ class _BrutalButtonState extends State<BrutalButton> {
64
  child: DefaultTextStyle.merge(
65
  style: brutalFont(
66
  color: disabled ? kBrutalMuted : widget.foregroundColor,
67
- fontWeight: FontWeight.w800,
68
  fontSize: 15,
69
  ),
70
  textAlign: TextAlign.center,
 
1
  import 'package:flutter/material.dart';
2
  import '../app_theme.dart';
3
 
4
+ /// Neo-brutal button. Black 2px border, hard 4px black offset shadow,
5
+ /// collapses to (0,0) shadow on press while the body translates (4,4) —
6
+ /// matches the web `active:translate-x-[1px] translate-y-[1px]` behavior
7
+ /// scaled up for touch.
8
  class BrutalButton extends StatefulWidget {
9
  const BrutalButton({
10
  super.key,
11
  required this.onPressed,
12
  required this.child,
13
+ this.color = kBrutalTeal,
14
+ this.foregroundColor = kBrutalWhite,
15
+ this.padding = const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
16
+ this.radius = kBrutalRadius,
17
  this.expand = false,
18
  this.enabled = true,
19
  });
 
43
  @override
44
  Widget build(BuildContext context) {
45
  final disabled = !widget.enabled || widget.onPressed == null;
46
+ final offset = _pressed ? 0.0 : 4.0;
47
 
48
  final btn = AnimatedContainer(
49
  duration: const Duration(milliseconds: 80),
50
  curve: Curves.easeOut,
51
  padding: widget.padding,
52
+ transform: Matrix4.translationValues(_pressed ? 4 : 0, _pressed ? 4 : 0, 0),
53
  decoration: BoxDecoration(
54
  color: disabled ? kBrutalWhite : widget.color,
55
  borderRadius: BorderRadius.circular(widget.radius),
 
65
  child: DefaultTextStyle.merge(
66
  style: brutalFont(
67
  color: disabled ? kBrutalMuted : widget.foregroundColor,
68
+ fontWeight: FontWeight.w900,
69
  fontSize: 15,
70
  ),
71
  textAlign: TextAlign.center,