need to port to IOS and fix many bugs
Browse files- .claude/settings.local.json +9 -1
- app/api/me/route.ts +1 -1
- mobile/android/app/src/main/AndroidManifest.xml +12 -2
- mobile/ios/Runner/Info.plist +2 -2
- mobile/lib/auth/auth_provider.dart +10 -4
- mobile/lib/features/camera/camera_screen.dart +349 -346
- mobile/lib/features/home/home_shell.dart +115 -43
- mobile/lib/features/login/login_screen.dart +208 -108
- mobile/lib/features/onboarding/onboarding_screen.dart +325 -130
- mobile/lib/features/phrases/phrases_screen.dart +163 -77
- mobile/lib/features/practice/dialogue_screen.dart +59 -90
- mobile/lib/features/practice/dialogues_list_screen.dart +141 -140
- mobile/lib/features/practice/stories_list_screen.dart +0 -213
- mobile/lib/features/practice/story_screen.dart +0 -501
- mobile/lib/features/profile/profile_screen.dart +439 -221
- mobile/lib/main.dart +2 -6
- mobile/lib/routing/app_router.dart +47 -13
- mobile/lib/theme/app_theme.dart +66 -59
- mobile/lib/theme/widgets/brutal_button.dart +11 -10
.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="
|
| 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>
|
| 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>
|
| 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
|
|
|
|
|
|
|
| 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 |
-
//
|
| 16 |
|
| 17 |
class VisionResult {
|
| 18 |
VisionResult({
|
|
@@ -57,7 +57,7 @@ class VisionSentence {
|
|
| 57 |
final String? romanized;
|
| 58 |
}
|
| 59 |
|
| 60 |
-
//
|
| 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 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
child:
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
),
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
color: kPrimary,
|
| 197 |
-
fontSize: 12,
|
| 198 |
-
fontWeight: FontWeight.w600),
|
| 199 |
),
|
| 200 |
-
|
| 201 |
),
|
| 202 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
],
|
| 204 |
),
|
| 205 |
-
|
| 206 |
-
|
| 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 |
-
|
|
|
|
| 230 |
decoration: BoxDecoration(
|
| 231 |
-
color:
|
| 232 |
-
borderRadius: BorderRadius.circular(
|
| 233 |
-
border: Border.all(color:
|
|
|
|
|
|
|
|
|
|
| 234 |
),
|
| 235 |
-
child:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
children: [
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
| 241 |
),
|
| 242 |
),
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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:
|
| 324 |
const SizedBox(height: 16),
|
| 325 |
-
Text(
|
| 326 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
const SizedBox(height: 4),
|
| 328 |
-
Text(
|
| 329 |
-
|
|
|
|
|
|
|
| 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:
|
| 348 |
-
borderRadius: BorderRadius.circular(
|
| 349 |
-
border: Border.all(color:
|
|
|
|
|
|
|
|
|
|
| 350 |
),
|
| 351 |
child: Row(children: [
|
| 352 |
-
const Icon(Icons.error_outline, color:
|
| 353 |
const SizedBox(width: 8),
|
| 354 |
Expanded(
|
| 355 |
-
child: Text(
|
| 356 |
-
|
|
|
|
|
|
|
| 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
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
width:
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
),
|
| 406 |
);
|
| 407 |
});
|
| 408 |
}
|
| 409 |
}
|
| 410 |
|
| 411 |
-
class
|
| 412 |
-
const
|
| 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 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 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 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
Text(
|
| 458 |
-
object.
|
| 459 |
style: GoogleFonts.almarai(
|
| 460 |
-
color:
|
| 461 |
-
fontSize:
|
| 462 |
-
fontWeight: FontWeight.
|
| 463 |
),
|
| 464 |
),
|
| 465 |
-
|
| 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(
|
| 492 |
-
|
| 493 |
-
|
|
|
|
|
|
|
| 494 |
style: GoogleFonts.almarai(
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 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 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
),
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
),
|
| 542 |
),
|
| 543 |
-
),
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
// ── Sentences ─────────────────────────────────────────────────────────
|
| 548 |
-
Text('PRACTICE SENTENCES',
|
| 549 |
style: GoogleFonts.almarai(
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 578 |
),
|
| 579 |
),
|
| 580 |
-
const SizedBox(width: 8),
|
| 581 |
-
TtsButton(text: s.target, lang: lang, size: 32),
|
| 582 |
],
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
color: kPrimary.withAlpha(204),
|
| 589 |
-
fontSize: 12,
|
| 590 |
-
fontStyle: FontStyle.italic)),
|
| 591 |
],
|
| 592 |
-
|
| 593 |
-
Text(s.gloss,
|
| 594 |
-
style: GoogleFonts.almarai(
|
| 595 |
-
color: kMuted, fontSize: 12, fontWeight: FontWeight.w800)),
|
| 596 |
-
],
|
| 597 |
),
|
| 598 |
),
|
| 599 |
),
|
| 600 |
-
),
|
| 601 |
-
|
| 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:
|
| 39 |
-
bottomNavigationBar:
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
| 43 |
),
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
),
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|
| 20 |
|
| 21 |
@override
|
| 22 |
void initState() {
|
| 23 |
super.initState();
|
| 24 |
-
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds:
|
| 25 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 44 |
} finally {
|
| 45 |
if (mounted) setState(() => _loading = false);
|
| 46 |
}
|
| 47 |
}
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
@override
|
| 50 |
Widget build(BuildContext context) {
|
|
|
|
|
|
|
|
|
|
| 51 |
return Scaffold(
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
),
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 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 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
),
|
| 116 |
),
|
| 117 |
],
|
| 118 |
-
|
| 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
|
| 135 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
@override
|
| 138 |
Widget build(BuildContext context) {
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
child:
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
),
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 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
|
| 18 |
-
|
|
|
|
| 19 |
String _level = 'A1';
|
| 20 |
bool _saving = false;
|
| 21 |
String? _error;
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
Future<void> _save() async {
|
| 24 |
-
if (
|
| 25 |
-
setState(() => _error = '
|
| 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:
|
|
|
|
| 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.
|
| 54 |
children: [
|
| 55 |
-
|
| 56 |
-
decoration: duoCardDecoration(),
|
| 57 |
-
clipBehavior: Clip.antiAlias,
|
| 58 |
child: Column(
|
| 59 |
crossAxisAlignment: CrossAxisAlignment.stretch,
|
| 60 |
children: [
|
| 61 |
-
|
| 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.
|
| 109 |
child: Column(
|
| 110 |
crossAxisAlignment: CrossAxisAlignment.stretch,
|
| 111 |
children: [
|
| 112 |
-
_SectionLabel('I
|
| 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 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 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:
|
| 150 |
-
borderRadius: BorderRadius.circular(
|
| 151 |
-
border: Border.all(color:
|
|
|
|
|
|
|
|
|
|
| 152 |
),
|
| 153 |
child: Text(
|
| 154 |
_error!,
|
| 155 |
-
style: GoogleFonts.almarai(color:
|
| 156 |
),
|
| 157 |
),
|
| 158 |
],
|
| 159 |
-
const SizedBox(height:
|
| 160 |
FilledButton(
|
| 161 |
onPressed: _saving ? null : _save,
|
| 162 |
-
child: Text(_saving ? '
|
| 163 |
),
|
| 164 |
],
|
| 165 |
),
|
|
@@ -174,17 +162,80 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|
| 174 |
),
|
| 175 |
);
|
| 176 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
);
|
| 202 |
}
|
| 203 |
}
|
| 204 |
|
| 205 |
-
class
|
| 206 |
-
const
|
| 207 |
-
required this.
|
| 208 |
-
required this.description,
|
| 209 |
required this.selected,
|
|
|
|
| 210 |
required this.onTap,
|
| 211 |
});
|
| 212 |
|
| 213 |
-
final
|
| 214 |
-
final String description;
|
| 215 |
final bool selected;
|
|
|
|
| 216 |
final VoidCallback onTap;
|
| 217 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
@override
|
| 219 |
Widget build(BuildContext context) {
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
child: AnimatedContainer(
|
| 224 |
-
duration: const Duration(milliseconds:
|
| 225 |
-
|
|
|
|
| 226 |
decoration: BoxDecoration(
|
| 227 |
-
color:
|
| 228 |
-
borderRadius: BorderRadius.circular(
|
| 229 |
-
border: Border.all(color:
|
| 230 |
-
boxShadow: [
|
|
|
|
|
|
|
| 231 |
),
|
| 232 |
-
child:
|
| 233 |
-
mainAxisAlignment: MainAxisAlignment.center,
|
| 234 |
-
crossAxisAlignment: CrossAxisAlignment.start,
|
| 235 |
children: [
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 236 |
children: [
|
| 237 |
-
|
| 238 |
-
const SizedBox(height:
|
| 239 |
_InputPanel(
|
| 240 |
controller: _controller,
|
| 241 |
loading: _loading,
|
|
@@ -261,22 +257,75 @@ class _PhrasesScreenState extends ConsumerState<PhrasesScreen> {
|
|
| 261 |
}
|
| 262 |
}
|
| 263 |
|
| 264 |
-
class
|
|
|
|
|
|
|
|
|
|
| 265 |
@override
|
| 266 |
Widget build(BuildContext context) {
|
| 267 |
-
return
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 309 |
-
decoration:
|
| 310 |
child: Column(
|
| 311 |
crossAxisAlignment: CrossAxisAlignment.start,
|
| 312 |
children: [
|
| 313 |
-
Text(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ? '
|
| 330 |
),
|
| 331 |
),
|
| 332 |
if (error != null) ...[
|
| 333 |
const SizedBox(height: 10),
|
| 334 |
-
Text(error!, style: GoogleFonts.almarai(color:
|
| 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(
|
| 363 |
-
decoration:
|
| 364 |
child: Column(
|
| 365 |
children: [
|
| 366 |
Container(
|
| 367 |
-
width:
|
| 368 |
-
height:
|
| 369 |
decoration: BoxDecoration(
|
| 370 |
color: kBrutalYellow,
|
| 371 |
-
borderRadius: BorderRadius.circular(
|
| 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:
|
| 378 |
),
|
| 379 |
-
const SizedBox(height:
|
| 380 |
if (isPending) ...[
|
| 381 |
Text(
|
| 382 |
-
'
|
| 383 |
textAlign: TextAlign.center,
|
| 384 |
-
style: GoogleFonts.almarai(color:
|
| 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:
|
| 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:
|
| 453 |
),
|
| 454 |
],
|
| 455 |
);
|
|
@@ -474,8 +549,8 @@ class _PhraseResult extends StatelessWidget {
|
|
| 474 |
children: [
|
| 475 |
Container(
|
| 476 |
width: double.infinity,
|
| 477 |
-
padding: const EdgeInsets.all(
|
| 478 |
-
decoration:
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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(
|
| 547 |
-
decoration:
|
| 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:
|
| 568 |
if (sentence.note.isNotEmpty) ...[
|
| 569 |
const SizedBox(height: 8),
|
| 570 |
-
Text(sentence.note, style: GoogleFonts.almarai(color:
|
| 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:
|
| 639 |
if (kanji.exampleWord.isNotEmpty) ...[
|
| 640 |
const SizedBox(height: 14),
|
| 641 |
Container(
|
| 642 |
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
| 643 |
-
decoration:
|
| 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(
|
| 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(
|
| 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:
|
| 760 |
if (word.note.isNotEmpty) ...[
|
| 761 |
const SizedBox(height: 8),
|
| 762 |
-
Text(word.note, style: GoogleFonts.almarai(color:
|
| 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:
|
| 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(
|
| 862 |
-
decoration:
|
| 863 |
child: Column(
|
| 864 |
crossAxisAlignment: CrossAxisAlignment.start,
|
| 865 |
children: [
|
| 866 |
-
Text(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 867 |
const SizedBox(height: 6),
|
| 868 |
-
Text(verbInfo, style: GoogleFonts.almarai(color:
|
| 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:
|
| 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:
|
| 898 |
-
const SizedBox(width:
|
| 899 |
Expanded(
|
| 900 |
child: Text(
|
| 901 |
-
title,
|
| 902 |
-
style: GoogleFonts.almarai(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 197 |
child: Column(
|
| 198 |
crossAxisAlignment: CrossAxisAlignment.start,
|
| 199 |
children: [
|
| 200 |
-
_Eyebrow('SCENARIO', color:
|
| 201 |
const SizedBox(height: 8),
|
| 202 |
Text(
|
| 203 |
widget.dialogue.scenario,
|
| 204 |
-
style: GoogleFonts.almarai(color:
|
| 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('
|
| 224 |
),
|
| 225 |
],
|
| 226 |
);
|
|
@@ -234,22 +195,32 @@ class _DialogueLessonViewState extends State<DialogueLessonView> {
|
|
| 234 |
children: [
|
| 235 |
Container(
|
| 236 |
padding: const EdgeInsets.all(28),
|
| 237 |
-
decoration:
|
| 238 |
child: Column(
|
| 239 |
children: [
|
| 240 |
Container(
|
| 241 |
width: 78,
|
| 242 |
height: 78,
|
| 243 |
-
decoration: BoxDecoration(
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
),
|
| 246 |
const SizedBox(height: 18),
|
| 247 |
-
Text('$pct%', style: GoogleFonts.almarai(color:
|
| 248 |
-
Text('$_score of $_totalChoices correct', style: GoogleFonts.almarai(color:
|
| 249 |
const SizedBox(height: 24),
|
| 250 |
-
FilledButton.icon(onPressed: _restart, icon: const Icon(Icons.refresh_rounded), label: const Text('
|
| 251 |
const SizedBox(height: 12),
|
| 252 |
-
OutlinedButton(
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 270 |
child: Column(
|
| 271 |
crossAxisAlignment: CrossAxisAlignment.start,
|
| 272 |
children: [
|
| 273 |
ClipRRect(
|
| 274 |
-
borderRadius: BorderRadius.circular(
|
| 275 |
child: LinearProgressIndicator(
|
| 276 |
value: progress,
|
| 277 |
minHeight: 12,
|
| 278 |
-
backgroundColor:
|
| 279 |
-
color:
|
| 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:
|
| 311 |
child: Column(
|
| 312 |
crossAxisAlignment: CrossAxisAlignment.stretch,
|
| 313 |
children: [
|
| 314 |
Text(
|
| 315 |
turn.text,
|
| 316 |
textAlign: TextAlign.center,
|
| 317 |
-
style: GoogleFonts.almarai(color:
|
| 318 |
),
|
| 319 |
if (isCurrent) ...[
|
| 320 |
const SizedBox(height: 12),
|
| 321 |
-
FilledButton(onPressed: _advance, child: const Text('
|
| 322 |
],
|
| 323 |
],
|
| 324 |
),
|
|
@@ -335,23 +306,26 @@ class _DialogueLessonViewState extends State<DialogueLessonView> {
|
|
| 335 |
width: 54,
|
| 336 |
height: 54,
|
| 337 |
decoration: BoxDecoration(
|
| 338 |
-
color:
|
| 339 |
-
borderRadius: BorderRadius.circular(
|
| 340 |
-
border: Border.all(color:
|
|
|
|
|
|
|
|
|
|
| 341 |
),
|
| 342 |
child: Center(
|
| 343 |
-
child: Text(initial, style: GoogleFonts.almarai(color:
|
| 344 |
),
|
| 345 |
),
|
| 346 |
const SizedBox(width: 10),
|
| 347 |
Expanded(
|
| 348 |
child: Container(
|
| 349 |
padding: const EdgeInsets.all(16),
|
| 350 |
-
decoration:
|
| 351 |
child: Column(
|
| 352 |
crossAxisAlignment: CrossAxisAlignment.start,
|
| 353 |
children: [
|
| 354 |
-
if (turn.speakerName != null) _Eyebrow(turn.speakerName!.toUpperCase(), color:
|
| 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 |
-
|
| 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:
|
| 379 |
const SizedBox(width: 6),
|
| 380 |
Text(
|
| 381 |
(_showTranslation[idx] ?? false) ? turn.translation! : 'Show translation',
|
| 382 |
-
style: GoogleFonts.almarai(color:
|
| 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('
|
| 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:
|
| 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:
|
| 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 =
|
| 418 |
-
Color bg =
|
| 419 |
-
Color textColor =
|
| 420 |
|
| 421 |
if (made != null) {
|
| 422 |
if (isCorrect) {
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
textColor = kPrimaryShadow;
|
| 426 |
} else if (isChosen) {
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
textColor = kDangerShadow;
|
| 430 |
} else {
|
| 431 |
-
textColor =
|
| 432 |
}
|
| 433 |
}
|
| 434 |
|
| 435 |
return Padding(
|
| 436 |
padding: const EdgeInsets.only(bottom: 10),
|
| 437 |
-
child:
|
| 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(
|
| 446 |
border: Border.all(color: borderColor, width: 2),
|
| 447 |
-
boxShadow: const [BoxShadow(color:
|
| 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:
|
| 488 |
borderRadius: BorderRadius.circular(99),
|
| 489 |
-
border: Border.all(color:
|
| 490 |
),
|
| 491 |
-
child: Text(label, style: GoogleFonts.almarai(color:
|
| 492 |
);
|
| 493 |
}
|
| 494 |
}
|
| 495 |
|
| 496 |
class _Eyebrow extends StatelessWidget {
|
| 497 |
-
const _Eyebrow(this.text, {this.color =
|
| 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:
|
| 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,
|
| 180 |
children: [
|
| 181 |
-
|
| 182 |
-
const SizedBox(height:
|
| 183 |
-
|
| 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
|
| 209 |
-
|
| 210 |
-
|
| 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:
|
| 256 |
-
borderRadius: BorderRadius.circular(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
),
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 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:
|
| 281 |
-
const SizedBox(width:
|
| 282 |
Expanded(
|
| 283 |
child: Text(
|
| 284 |
title,
|
| 285 |
-
style: GoogleFonts.almarai(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
),
|
| 287 |
),
|
| 288 |
],
|
|
@@ -306,7 +303,7 @@ class _ScenarioGrid extends StatelessWidget {
|
|
| 306 |
itemCount: _scenarios.length,
|
| 307 |
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
| 308 |
crossAxisCount: columns,
|
| 309 |
-
mainAxisExtent:
|
| 310 |
crossAxisSpacing: 14,
|
| 311 |
mainAxisSpacing: 14,
|
| 312 |
),
|
|
@@ -325,7 +322,7 @@ class _ScenarioGrid extends StatelessWidget {
|
|
| 325 |
}
|
| 326 |
}
|
| 327 |
|
| 328 |
-
class _ScenarioCard extends
|
| 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 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
decoration: BoxDecoration(
|
| 349 |
-
color:
|
| 350 |
-
borderRadius: BorderRadius.circular(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
),
|
| 352 |
child: Column(
|
| 353 |
crossAxisAlignment: CrossAxisAlignment.start,
|
|
@@ -355,34 +370,46 @@ class _ScenarioCard extends StatelessWidget {
|
|
| 355 |
Row(
|
| 356 |
children: [
|
| 357 |
Container(
|
| 358 |
-
width:
|
| 359 |
-
height:
|
| 360 |
-
decoration: BoxDecoration(
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
),
|
| 363 |
const Spacer(),
|
| 364 |
-
Icon(
|
|
|
|
|
|
|
|
|
|
| 365 |
],
|
| 366 |
),
|
| 367 |
-
const SizedBox(height:
|
| 368 |
Text(
|
| 369 |
-
scenario.title,
|
| 370 |
maxLines: 1,
|
| 371 |
overflow: TextOverflow.ellipsis,
|
| 372 |
-
style: GoogleFonts.almarai(color:
|
| 373 |
),
|
| 374 |
-
const SizedBox(height:
|
| 375 |
Expanded(
|
| 376 |
child: Text(
|
| 377 |
-
scenario.description,
|
| 378 |
maxLines: 3,
|
| 379 |
overflow: TextOverflow.ellipsis,
|
| 380 |
-
style: GoogleFonts.almarai(color:
|
| 381 |
),
|
| 382 |
),
|
| 383 |
Text(
|
| 384 |
-
loading ? '
|
| 385 |
-
style: GoogleFonts.almarai(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 409 |
-
decoration:
|
| 410 |
child: Column(
|
| 411 |
crossAxisAlignment: CrossAxisAlignment.start,
|
| 412 |
children: [
|
| 413 |
-
Text(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
const SizedBox(height: 8),
|
| 415 |
Text(
|
| 416 |
'What do you want to practice?',
|
| 417 |
-
style: GoogleFonts.almarai(color:
|
| 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 ? '
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
)
|
| 111 |
-
: null,
|
| 112 |
-
),
|
| 113 |
-
const SizedBox(width: 16),
|
| 114 |
-
Expanded(
|
| 115 |
-
child: Column(
|
| 116 |
-
crossAxisAlignment: CrossAxisAlignment.start,
|
| 117 |
children: [
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
),
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
),
|
| 130 |
],
|
| 131 |
-
)
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 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 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 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 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 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 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 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 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 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 |
-
|
| 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
|
| 316 |
final flag = languageFlagCode(langCode);
|
| 317 |
-
if (flag == null || flag.isEmpty) return Icon(Icons.language, color:
|
| 318 |
-
return CountryFlag.fromCountryCode(
|
| 319 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 341 |
-
decoration:
|
| 342 |
child: Row(
|
| 343 |
children: [
|
| 344 |
Container(
|
| 345 |
width: 44,
|
| 346 |
height: 44,
|
| 347 |
-
decoration: BoxDecoration(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
Text(
|
| 358 |
value,
|
| 359 |
maxLines: 1,
|
| 360 |
overflow: TextOverflow.ellipsis,
|
| 361 |
-
style: GoogleFonts.almarai(color:
|
| 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(
|
| 18 |
-
final router = buildRouter(ref);
|
| 19 |
return MaterialApp.router(
|
| 20 |
-
title: '
|
| 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/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
GoRouter buildRouter(WidgetRef ref) {
|
| 12 |
return GoRouter(
|
| 13 |
initialLocation: '/',
|
| 14 |
-
|
|
|
|
| 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 '/
|
| 27 |
}
|
| 28 |
return null;
|
| 29 |
}
|
| 30 |
},
|
| 31 |
routes: [
|
| 32 |
-
GoRoute(path: '/', builder: (_,
|
| 33 |
-
GoRoute(path: '/login', builder: (_,
|
| 34 |
-
GoRoute(path: '/onboarding', builder: (_,
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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); //
|
| 8 |
const kBrutalWhite = Color(0xFFFFFFFF); // card / unselected button
|
| 9 |
-
const kBrutalCream = Color(
|
| 10 |
-
const kBrutalRed = Color(
|
| 11 |
const kBrutalGreen = Color(0xFF22C55E); // correct answer
|
| 12 |
const kBrutalBlue = Color(0xFF3B82F6); // secondary accent (web blue)
|
| 13 |
-
const
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
// --- Back-compat aliases ---
|
| 16 |
-
//
|
| 17 |
-
|
| 18 |
-
const
|
| 19 |
-
const kPrimaryShadow = kBrutalBlack;
|
| 20 |
-
const kSecondary = kBrutalBlue;
|
| 21 |
const kSecondaryShadow = kBrutalBlack;
|
| 22 |
-
const kWarning
|
| 23 |
-
const kDanger
|
| 24 |
-
const kDangerShadow
|
| 25 |
-
const kForeground
|
| 26 |
-
const kMuted
|
| 27 |
-
const kBackground
|
| 28 |
-
const kCard
|
| 29 |
-
const kCardFeature
|
| 30 |
-
const kBorder
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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:
|
| 67 |
-
onPrimary:
|
| 68 |
secondary: kBrutalBlue,
|
| 69 |
onSecondary: kBrutalWhite,
|
| 70 |
surface: kBrutalWhite,
|
| 71 |
-
onSurface:
|
| 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.
|
| 87 |
fontSize: 20,
|
| 88 |
),
|
| 89 |
iconTheme: const IconThemeData(color: kBrutalBlack),
|
| 90 |
),
|
| 91 |
filledButtonTheme: FilledButtonThemeData(
|
| 92 |
style: FilledButton.styleFrom(
|
| 93 |
-
backgroundColor:
|
| 94 |
-
foregroundColor:
|
| 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(
|
| 101 |
side: const BorderSide(color: kBrutalBlack, width: 2),
|
| 102 |
),
|
| 103 |
-
textStyle: GoogleFonts.almarai(fontWeight: FontWeight.
|
| 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(
|
| 115 |
side: const BorderSide(color: kBrutalBlack, width: 2),
|
| 116 |
),
|
| 117 |
-
textStyle: GoogleFonts.almarai(fontWeight: FontWeight.
|
| 118 |
),
|
| 119 |
),
|
| 120 |
textButtonTheme: TextButtonThemeData(
|
| 121 |
style: TextButton.styleFrom(
|
| 122 |
foregroundColor: kBrutalBlack,
|
| 123 |
-
textStyle: GoogleFonts.almarai(fontWeight: FontWeight.
|
| 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(
|
| 132 |
side: const BorderSide(color: kBrutalBlack, width: 2),
|
| 133 |
),
|
| 134 |
),
|
| 135 |
navigationBarTheme: NavigationBarThemeData(
|
| 136 |
height: 72,
|
| 137 |
backgroundColor: kBrutalWhite,
|
| 138 |
-
indicatorColor:
|
| 139 |
surfaceTintColor: Colors.transparent,
|
| 140 |
indicatorShape: RoundedRectangleBorder(
|
| 141 |
-
borderRadius: BorderRadius.circular(
|
| 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.
|
| 150 |
}
|
| 151 |
-
return GoogleFonts.almarai(color: kBrutalMuted, fontSize: 12, fontWeight: FontWeight.
|
| 152 |
}),
|
| 153 |
),
|
| 154 |
tabBarTheme: TabBarThemeData(
|
| 155 |
labelColor: kBrutalBlack,
|
| 156 |
unselectedLabelColor: kBrutalMuted,
|
| 157 |
indicatorSize: TabBarIndicatorSize.tab,
|
| 158 |
-
labelStyle: GoogleFonts.almarai(fontWeight: FontWeight.
|
| 159 |
-
unselectedLabelStyle: GoogleFonts.almarai(fontWeight: FontWeight.
|
| 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(
|
| 168 |
borderSide: const BorderSide(color: kBrutalBlack, width: 2),
|
| 169 |
),
|
| 170 |
enabledBorder: OutlineInputBorder(
|
| 171 |
-
borderRadius: BorderRadius.circular(
|
| 172 |
borderSide: const BorderSide(color: kBrutalBlack, width: 2),
|
| 173 |
),
|
| 174 |
focusedBorder: OutlineInputBorder(
|
| 175 |
-
borderRadius: BorderRadius.circular(
|
| 176 |
-
borderSide: const BorderSide(color:
|
| 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:
|
| 184 |
disabledColor: kBrutalWhite,
|
| 185 |
-
labelStyle: GoogleFonts.almarai(color: kBrutalBlack, fontWeight: FontWeight.
|
| 186 |
-
secondaryLabelStyle: GoogleFonts.almarai(color:
|
| 187 |
side: const BorderSide(color: kBrutalBlack, width: 2),
|
| 188 |
-
shape:
|
| 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(
|
| 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(
|
| 204 |
-
side: const BorderSide(color: kBrutalBlack, width:
|
| 205 |
),
|
| 206 |
-
titleTextStyle: GoogleFonts.almarai(color: kBrutalBlack, fontSize: 20, fontWeight: FontWeight.
|
| 207 |
-
contentTextStyle: GoogleFonts.almarai(color:
|
| 208 |
),
|
| 209 |
bottomSheetTheme: const BottomSheetThemeData(
|
| 210 |
backgroundColor: kBrutalWhite,
|
| 211 |
surfaceTintColor: Colors.transparent,
|
| 212 |
shape: RoundedRectangleBorder(
|
| 213 |
-
borderRadius: BorderRadius.vertical(top: Radius.circular(
|
| 214 |
-
side: BorderSide(color: kBrutalBlack, width:
|
| 215 |
),
|
| 216 |
),
|
| 217 |
);
|
|
@@ -219,17 +224,19 @@ ThemeData buildTheme() {
|
|
| 219 |
|
| 220 |
// --- Decorations ---
|
| 221 |
|
| 222 |
-
/// Neo-brutal card:
|
| 223 |
/// [offset] shrinks to indicate a pressed state (web: translate-1).
|
| 224 |
BoxDecoration brutalCard({
|
| 225 |
Color color = kBrutalWhite,
|
| 226 |
-
double offset =
|
| 227 |
-
double radius =
|
|
|
|
|
|
|
| 228 |
}) {
|
| 229 |
return BoxDecoration(
|
| 230 |
color: color,
|
| 231 |
borderRadius: BorderRadius.circular(radius),
|
| 232 |
-
border: Border.all(color:
|
| 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 =
|
| 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
|
| 5 |
-
/// collapses to
|
| 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 =
|
| 13 |
-
this.foregroundColor =
|
| 14 |
-
this.padding = const EdgeInsets.symmetric(horizontal: 18, vertical:
|
| 15 |
-
this.radius =
|
| 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 ?
|
| 46 |
|
| 47 |
final btn = AnimatedContainer(
|
| 48 |
duration: const Duration(milliseconds: 80),
|
| 49 |
curve: Curves.easeOut,
|
| 50 |
padding: widget.padding,
|
| 51 |
-
transform: Matrix4.translationValues(_pressed ?
|
| 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.
|
| 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,
|