Imported from oh-242/Sakina_Ai (
Sakina_AI-frontend/AGENTS.md). Install upstream withnpx skills add oh-242/Sakina_Ai --skill Sakina_AI-frontend. Copyright stays with the author.
AGENTS.md — Sakina AI Frontend
Build & Verify
flutter pub get # Install dependencies (run after any pubspec change)
flutter analyze # Static analysis — must pass with 0 errors
flutter test # Single widget test (pumps splash screen)
flutter build apk --release # Build release APK → build/app/outputs/flutter-apk/app-release.apk
No CI, no pre-commit hooks. You must run flutter analyze after any code change. dart format is not enforced but available.
Flutter SDK Location
Installed at ~/.sdk/flutter. Invoke as ~/.sdk/flutter/bin/flutter, not bare flutter.
Java Requirement
The system default Java is 25.0.3, which cannot compile Kotlin/Gradle code. Always set:
export JAVA_HOME=/home/hesham/.jdks/temurin-17.0.18
export PATH=$JAVA_HOME/bin:$PATH
Android SDK is at ~/android-sdk (configured via flutter config --android-sdk ~/android-sdk).
Backend URL (Configurable)
The server URL is configurable at login time via a collapsible "Server" field on the auth screen. The URL is persisted to server_url.txt in the app documents directory (via dart:io File).
- Default:
http://192.168.100.102:8080(fallback inApiClientconstructor) ApiClient.setBaseUrl(url)updatesdio.options.baseUrlin-place, clears old session, persists to file- Auth screen auto-prepends
http://if user enters bareip:port AppState.baseUrlgetter andAppState.setBaseUrl(url)exposed to screensusesCleartextTraffic="true"is set in the manifest for HTTP
Architecture
lib/
├── main.dart # Entry: Provider<AppState>, splash→auth→MainShell gate
├── theme/app_theme.dart # static AppTheme class, dark/light via Material3
├── models/ # Data classes matching backend JSON schemas
│ ├── auth_response.dart
│ ├── task_item.dart # TaskItem (toJson/fromJson, used across all task ops)
│ ├── metrics.dart # MetricsRequest (copyWith), MetricsResponse, MetricsData
│ ├── burnout_risk.dart # BurnoutRiskResponse, RiskFactor
│ ├── conflict_zone.dart # ApiConflictZone (prefix: api in radar_screen)
│ ├── environmental_influence.dart
│ ├── recommendation.dart
│ └── error_response.dart
├── services/ # Stateless service layer + state
│ ├── api_client.dart # Singleton Dio with persistent cookie jar + CSRF auto-attach
│ ├── app_state.dart # ChangeNotifier (Provider) — single source of truth for all data
│ ├── auth_service.dart # register, login, logout, me, changePassword
│ ├── metrics_service.dart # getMetrics (nullable → 404), updateMetrics
│ ├── task_service.dart # CRUD + PATCH /tasks/{id}/complete
│ ├── ai_service.dart # 4 AI endpoints; returns null on 404 (no metrics yet)
│ ├── health_service.dart # Health Connect / HealthKit via `health: ^13.3.1`
│ ├── screen_time_service.dart # Android UsageStatsManager via `app_usage` package
│ ├── commute_service.dart # GPS speed-threshold commute detection via `geolocator`
│ └── mock_academic_data.dart # Seeds 5 tasks + academic defaults on first login
├── screens/
│ ├── auth_screen.dart # Login/Register toggle form with configurable server URL
│ ├── home_screen.dart # Digital twin dashboard (burnout gauge + metric cards)
│ ├── insights_screen.dart # Biometrics hub + real tasks list with add/delete/complete + quiz card
│ ├── quiz_screen.dart # Full-screen form: commute, employment, resilience, extracurricular hours → PUT /metrics
│ ├── radar_screen.dart # RadarPainter (CustomPainter) + conflict zones + recs
│ └── profile_screen.dart # User info, permissions UI, health sync, hidden academic settings
└── widgets/
├── glass_card.dart # Glassmorphism container (height is now nullable, minHeight: 80)
└── pill_nav_bar.dart # 4-tab floating nav (home, insights, radar, profile)
Navigation
4 tabs (Twin Talk removed):
| Index | Icon | Screen |
|---|---|---|
| 0 | home_outlined |
HomeScreen |
| 1 | favorite_border |
InsightsScreen |
| 2 | Icons.radar (circle) |
RadarScreen |
| 3 | person_outline |
ProfileScreen |
No routing library. Index switching in _MainShellState. The settings sub-page was removed from profile — all settings are now inline in the Profile screen.
State Management
Provider (provider: ^6.1.0) wrapping a single AppState ChangeNotifier. Every screen reads context.watch<AppState>(). The AppState holds:
- Auth state + user
- Metrics data, task list
- Burnout risk, conflict zones, environmental influences, recommendations
- References to
HealthService,ScreenTimeService,CommuteService,MockAcademicData
On startup, AppState.initialize() checks session validity via GET /api/auth/me. If 200 → loads all data. If 401 → shows AuthScreen. On first login with no metrics/tasks, AppState seeds mock academic data (5 tasks, 18 credit hours, 3.2 GPA, etc.).
Startup flow (initialize())
hasValidSession()→ GET/api/auth/me(restores JSESSIONID + XSRF-TOKEN from persisted cookie jar)- If valid:
me(),loadAllData()(metrics + tasks + AI data) - If first login (null metrics or empty tasks):
_seedMockData()(POST tasks + PUT metrics) _checkPermissions()— checks Health Connect, Screen Time, Location permissions (awaited)- If metrics exist: auto-calls
syncAllHealthData()to merge sensor data into dashboard notifyListeners()
The auto-sync in step 5 is critical — the dashboard would show zeros without it because mock data seeds health fields as 0.
API Flow
- Session cookies (
JSESSIONID) persisted across restarts viaPersistCookieJar(file-backed in app Documents dir) - CSRF:
XSRF-TOKENcookie extracted from login response → attached asX-XSRF-TOKENheader on POST/PUT/DELETE/PATCH - CSRF token restored from cookie jar on startup (
api_client.dart) — the_csrfTokenfield is populated by reading the persisted cookies duringinit(). Without this, PUT/POST requests get 403 after app restart. - GET requests do not need CSRF header
- AI endpoints return 404 if no metrics submitted → services return
nullgracefully
Important Conventions
withValues(alpha: ...)only — neverwithOpacity(). Dart 3.6+ API.- Google Fonts: Outfit for headings/display, Inter for body. No font assets.
- Dark-first:
ThemeMode.darkdefault via globalValueNotifier<ThemeMode> themeNotifierinmain.dart. - All text must have overflow handling — every dynamic
Textwidget needsmaxLines+overflow: TextOverflow.ellipsis. Static labels can skip this. - GlassCard height is nullable — default is
null(auto-sized,minHeight: 80). Pass explicitheightonly for cards that need fixed sizing (e.g., bio cards at 140, radar canvas). - Radar canvas uses
AspectRatio(aspectRatio: 1)— do not hardcode width/height; the painter assumes a square area. import 'dart:ui' as ui;is needed inradar_screen.dartforui.TextDirection.ltr— the material re-export doesn't carryTextDirection.ltrin this Flutter version (3.41.9).- Screen constants: all screens use
SizedBox(height: 120)bottom spacer for the nav bar (pill is 70px + 30px bottom margin = 100px needed; 120px is safe). - Android minSdk = 26 — required by the
healthpackage. The app won't install on Android <8.0.
Android Configuration Gotchas
usesCleartextTraffic="true"is set in<application>for HTTP access to the backend.- All health permissions have
android:required="false"— the app installs on devices without Health Connect. PACKAGE_USAGE_STATSusestools:ignore="ProtectedPermissions"— the user grants this in system Settings, not via runtime dialog.xmlns:tools="http://schemas.android.com/tools"declared on the root<manifest>tag — do not remove.
Health Connect v13 requirements
The health: ^13.3.1 package requires these Android setup changes:
MainActivity.ktmust extendFlutterFragmentActivity, notFlutterActivity. This is needed forregisterForActivityResultin the permission flow.gradle.propertiesneedsandroid.enableJetifier=true.AndroidManifest.xmladditions:activity-aliasforViewPermissionUsageActivity(privacy policy link in HC permissions screen)intent-filterwithACTION_SHOW_PERMISSIONS_RATIONALEinside.MainActivityandroid.permission.health.READ_HEALTH_DATA_HISTORY(required since health v12.1.0)
Sleep data types in v13
SLEEP_IN_BED is iOS-only in health v13.x. On Android, sleep quality uses:
quality = SLEEP_ASLEEP / (SLEEP_ASLEEP + SLEEP_AWAKE)
SLEEP_ASLEEP, SLEEP_AWAKE, SLEEP_DEEP, SLEEP_LIGHT, SLEEP_REM work on both platforms.
Health Service Permission Model
HealthService (lib/services/health_service.dart) distinguishes three layers:
| Layer | Getter | Meaning |
|---|---|---|
| HC installed | isHealthConnectInstalled |
_health.isHealthConnectAvailable() |
| HC available | isAvailable |
Health Connect is installed on the device |
| Permissions granted | isAuthorized |
User granted read access for requested types |
hasPermissions() always queries _health.hasPermissions() fresh — it never returns a cached value. This is intentional so permissions granted manually in the Health Connect app are detected without an app restart.
checkExistingPermissions() runs at startup (from _checkPermissions() in AppState.initialize()). It calls both isHealthConnectAvailable() and hasPermissions() to populate all three state fields.
When the user taps the Health Connect row in Profile: first calls checkExistingPermissions() to detect already-granted permissions, only falls back to requestPermissions() (shows dialog) if not already granted.
Screen Time (_hasUsageAccess) and Commute (_hasPermission) permissions are also cached in service fields and checked during _checkPermissions().
Mock Tasks — UUID Gotcha
Mock tasks in mock_academic_data.dart have id set to strings like "mock-1". The backend expects id to be a java.util.UUID. Before calling addTask() in _seedMockData(), task.id = null is set so toJson() excludes the id field and the backend auto-generates a proper UUID. If you generate new mock tasks, follow the same pattern: keep mock IDs for fallback identification but null them before POST.
MetricsData — Null-Safe Tasks
MetricsData.fromJson() at metrics.dart:155 handles a nullable tasks field:
tasks: (json['tasks'] as List?)?.map((t) => TaskItem.fromJson(t)).toList() ?? [],
The backend may return metrics without a tasks array. Do not revert to a non-nullable as List cast.
Permission State & UI Reactivity
Permission service fields (_authorized, _hasUsageAccess, _hasPermission) are NOT backed by ChangeNotifier. To reflect permission state in the UI:
- On startup:
_checkPermissions()is awaited ininitialize(), followed bynotifyListeners(). Do not make this fire-and-forget. - On manual action (Profile tap): call
setState()on the widget after the async operation completes. - Getters in
AppState(healthPermissionGranted,screenTimePermissionGranted,locationPermissionGranted) delegate to service fields and must be read viacontext.watch<AppState>().
Hidden Features
- Mock academic settings: Tap the gear icon in Profile 5 times to reveal sliders for credit hours, GPA, Blackboard %, absences, and resilience. Changes push to the backend via
syncAcademicData().
Known Backend Gaps
| # | Missing | Workaround |
|---|---|---|
| 1 | No PUT/PATCH /api/auth/profile |
Profile info is read-only |
| 2 | No user preferences endpoint | Notification/lang/threshold settings are local-only |
| 3 | No activity log / history endpoint | Not wired |
| 4 | No mental reflection/quiz endpoint | Quiz screen (quiz_screen.dart) collects responses and saves via PUT /metrics |
| 5 | employmentStatus is free-text |
Frontend uses defaults from mock data |
| 6 | No screen time ingestion endpoint | Client builds MetricsRequest and calls PUT /metrics |
| 7 | iOS ScreenTime API blocked by Apple | Screen time is Android-only; iOS falls back to manual entry |
| 8 | Task IDs must be valid UUIDs | Mock tasks null their id before POST; backend auto-generates |
Testing
flutter test # Single test in test/widget_test.dart — pumps splash screen
No mock frameworks, no integration tests, no golden tests. The test creates a real ApiClient (requires WidgetsFlutterBinding.ensureInitialized()).
Timeout & Hang Protection
Dio timeouts (api_client.dart)
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 90),
The 90s receive timeout accommodates slow AI endpoints (which can take 1–5s each, 4 in parallel = ~5s max, but the /recommendations endpoint internally calls 2 other endpoints + AI model).
Local platform calls have individual timeouts
syncAllHealthData() in app_state.dart wraps each local service call:
healthService.fetchHealthData()— 15s timeoutscreenTimeService.fetchScreenTime()— 10s timeoutcommuteService.getCommuteThisWeek()— 10s timeout
Without these, a hung platform channel (Health Connect, UsageStatsManager, GPS) would hang the sync indefinitely. The outer sync call also has a 120s timeout in profile_screen.dart:_syncAll().
AI refresh is parallelized
refreshAIData() in app_state.dart uses Future.wait to call all 4 AI endpoints concurrently instead of sequentially. This reduces total latency from ~20s (worst case) to ~5s (single slowest endpoint).
Quiz save is decoupled from AI refresh
The quiz screen calls updateMetrics() → shows success → pops → fires refreshAIData() in background (no await). The user sees immediate confirmation; dashboard updates when AI data arrives.
Health Connect — SLEEP_IN_BED is iOS-only
health_service.dart conditionally excludes SLEEP_IN_BED from both _typesToRead (permission check) and the fetchHealthData() sleep query via if (Platform.isIOS). On Android, sleep quality falls back to SLEEP_ASLEEP / (SLEEP_ASLEEP + SLEEP_AWAKE). Including SLEEP_IN_BED on Android causes hasPermissions() to return false even when the user has granted permissions manually.