Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# AGENTS.md

## Cursor Cloud specific instructions

### Project overview

RoadSOS is a Flutter mobile app (Dart) with Supabase Edge Functions (TypeScript/Deno) as the backend. The primary development loop is Flutter-based: `flutter pub get`, `dart analyze`, `flutter test`, `flutter build web`.

### Flutter SDK

The CI pins **Flutter 3.41.9** (Dart 3.11.5). The `pubspec.yaml` SDK constraint is `^3.11.0`. Flutter must be installed at `/opt/flutter` with `/opt/flutter/bin` on `PATH` (the update script handles this).

### Key commands

| Action | Command |
|---|---|
| Install deps | `flutter pub get` |
| Lint | `dart analyze` |
| Test | `flutter test` |
| Build web | `flutter build web --dart-define=SUPABASE_URL="$SUPABASE_URL" --dart-define=SUPABASE_ANON_KEY="$SUPABASE_ANON_KEY"` |
| Serve demo | `python3 -m http.server 8080` (from repo root, then visit `/demo/index.html`) |
| Serve Flutter web | `cd build/web && python3 -m http.server 8081` |

### Environment file

Copy `assets/env.template` to `assets/.env` before running the Flutter app. The app needs `SUPABASE_URL` and `SUPABASE_ANON_KEY` to initialize — without them the Flutter web app shows a blank page. The standalone demo at `demo/index.html` works without any env vars.

### Testing without external services

- `dart analyze` and `flutter test` work without any secrets or external services.
- The standalone HTML demo (`demo/index.html`) supports an **Offline Fallback** triage mode that runs keyword-based classification without a Google AI API key.
- The full Flutter web app requires Supabase credentials to initialize (see below).

### Building and serving the Flutter web app

The web build is a **release build** by default, so `.env` files are not loaded. Pass secrets via `--dart-define`:

```bash
flutter build web \
--dart-define=SUPABASE_URL="$SUPABASE_URL" \
--dart-define=SUPABASE_ANON_KEY="$SUPABASE_ANON_KEY"
```

Serve from the build output directory directly (not the repo root), so relative asset paths resolve:

```bash
cd build/web && python3 -m http.server 8081
```

Serving from the repo root (e.g. `http://localhost:8080/build/web/`) will cause 404s for `flutter_bootstrap.js` and `manifest.json`.

### Gotchas

- The `flutter_blue_plus_winrt` warnings during `pub get` / build are harmless (Windows-only plugin, irrelevant on Linux).
- Localization warnings about untranslated messages in `bn`, `hi`, `mr`, `ta`, `te` are expected and non-blocking.
- The Wasm dry-run warnings during `flutter build web` are informational only; the JS build succeeds.
- `assets/.env` is not gitignored at the `assets/` path (only `/.env` at root is ignored); do not commit it with secrets.

### Supabase Edge Functions

Located in `supabase/functions/`. Deploying requires the Supabase CLI and project credentials. Not required for running tests or the standalone demo.
111 changes: 81 additions & 30 deletions lib/services/gemma_model_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,40 +13,51 @@ import '../logging/app_log.dart';
/// • Cancel in-progress download
/// • Delete to reclaim space
///
/// Model: gemma-4-e4b-it-Q4_K_M.gguf (~2.4 GB)
/// Source: https://huggingface.co/google/gemma-4-E4B-it-GGUF
/// Model: gemma-4-E4B-it-Q4_K_M.gguf (~2.4 GB)
/// Source: https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF
///
/// **NO TOKEN REQUIRED.** Gemma 4 (released Apr 2026) is **Apache 2.0** +
/// **ungated** on Hugging Face — the earlier code's HF-token gate was
/// inherited from the Gemma 2 days when the license required acceptance
/// and is now pure friction that drives users to skip the download.
/// Downloads now run anonymous-HTTPS with optional token override for
/// users behind a rate-limited HF mirror.
/// **NO TOKEN REQUIRED.** Gemma 4 weights are Apache 2.0, but Google's
/// **official** repo `google/gemma-4-E4B-it-GGUF` still gates the resolve
/// endpoint behind a click-through terms screen + HF login (returns HTTP
/// 401 to anonymous fetches as of May 2026). The Unsloth, ggml-org, and
/// bartowski mirrors redistribute the **identical model bytes** under the
/// same Apache 2.0 license with no gating — verified 302→CDN on anonymous
/// `curl` from a clean IP. We point at Unsloth (most-downloaded GGUF
/// publisher, maintained by the same team behind unsloth.ai's training
/// stack) so the auto-installer just works without any HF account.
class GemmaModelManager {
static const _modelFileName = 'gemma-4-e4b-it-Q4_K_M.gguf';
/// Filename — matches Unsloth's casing for the Q4_K_M quant.
static const _modelFileName = 'gemma-4-E4B-it-Q4_K_M.gguf';

/// Minimum sane file size: anything under 800 MB is definitely truncated.
static const int expectedMinBytes = 800 * 1024 * 1024;

/// Approximate full model size — used for progress UI when Content-Length is missing.
static const int approximateFullBytes = 2_400_000_000;

/// HuggingFace download URL. Apache 2.0 + ungated — no Authorization header
/// needed. We keep an optional token override for users who hit an HF
/// rate-limit on a shared IP (very rare for one-time downloads).
/// Primary download URL — Unsloth mirror (no auth required, identical bytes
/// to Google's official repo, Apache 2.0).
static const String modelDownloadUrl =
'https://huggingface.co/google/gemma-4-E4B-it-GGUF/resolve/main/$_modelFileName';
'https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF/resolve/main/$_modelFileName';

/// Fallback mirrors in case the primary is rate-limited or in maintenance.
/// Tried in order; all hold the same Apache 2.0 weights.
static const List<String> modelDownloadFallbackUrls = [
'https://huggingface.co/ggml-org/gemma-4-E4B-it-GGUF/resolve/main/$_modelFileName',
'https://huggingface.co/bartowski/google_gemma-4-E4B-it-GGUF/resolve/main/google_gemma-4-E4B-it-Q4_K_M.gguf',
];

/// Model card — opens in browser if the user wants to read the license /
/// model card before downloading. Not required to proceed.
static const String hfModelCardUrl =
'https://huggingface.co/google/gemma-4-E4B-it-GGUF';
'https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF';

/// Optional HF token override page (only needed for very-high-volume IPs).
static const String hfTokenUrl = 'https://huggingface.co/settings/tokens';

/// SharedPreferences keys.
static const String prefAutoDownloadOptOut = 'gemma_auto_download_opt_out_v1';
static const String prefAutoDownloadOptOut =
'gemma_auto_download_opt_out_v1';
static const String prefAutoDownloadInFlight =
'gemma_auto_download_in_flight_v1';

Expand Down Expand Up @@ -110,6 +121,44 @@ class GemmaModelManager {
String? hfToken,
required void Function(int received, int total) onProgress,
CancelToken? cancelToken,
}) async {
final candidateUrls = <String>[
modelDownloadUrl,
...modelDownloadFallbackUrls,
];
GemmaDownloadException? lastErr;
for (final url in candidateUrls) {
if (cancelToken?.isCancelled ?? false) return;
try {
await _downloadFromUrl(
url: url,
hfToken: hfToken,
onProgress: onProgress,
cancelToken: cancelToken,
);
return; // success
} on GemmaDownloadException catch (e) {
lastErr = e;
// Only fail-over on auth-ish / rate-limit responses; bubble unknown
// errors so the caller can show a clean message.
if (e.statusCode != 401 &&
e.statusCode != 403 &&
e.statusCode != 404 &&
e.statusCode != 429) {
rethrow;
}
appLog.w('[GemmaModel] $url failed (${e.statusCode}); trying next mirror');
}
}
throw lastErr ??
const GemmaDownloadException('All Gemma 4 mirrors are unreachable.');
}

static Future<void> _downloadFromUrl({
required String url,
String? hfToken,
required void Function(int received, int total) onProgress,
CancelToken? cancelToken,
}) async {
final path = await localModelPath();
final tmpPath = '$path.download';
Expand All @@ -120,7 +169,7 @@ class GemmaModelManager {
if (tmpFile.existsSync()) {
alreadyHave = tmpFile.lengthSync();
appLog.i(
'[GemmaModel] Resuming download from ${(alreadyHave / 1e6).round()} MB',
'[GemmaModel] Resuming download from ${(alreadyHave / 1e6).round()} MB ($url)',
);
}

Expand All @@ -134,7 +183,7 @@ class GemmaModelManager {
final client = http.Client();

try {
final request = http.Request('GET', Uri.parse(modelDownloadUrl));
final request = http.Request('GET', Uri.parse(url));
request.headers.addAll(headers);
final response = await client.send(request);

Expand All @@ -143,21 +192,27 @@ class GemmaModelManager {
final body = await response.stream.bytesToString();
if (response.statusCode == 401 || response.statusCode == 403) {
throw GemmaDownloadException(
'Hugging Face refused the request (HTTP ${response.statusCode}). '
'This is unusual for Gemma 4 (Apache 2.0 + ungated). Most likely '
'cause: a shared IP / VPN hit anonymous rate-limits. '
'Workaround: create a free read token at $hfTokenUrl and paste it '
'in Settings → Advanced.',
'Hugging Face mirror $url refused the request (HTTP '
'${response.statusCode}). Trying the next mirror — if all fail, '
'paste an HF read token in Settings → Advanced as a workaround.',
statusCode: response.statusCode,
);
}
if (response.statusCode == 429) {
throw GemmaDownloadException(
'Hugging Face rate-limit reached (HTTP 429). Try again in a few '
'minutes, or add an HF read token in Settings → Advanced.',
'Hugging Face rate-limited mirror $url (HTTP 429). Will retry on '
'the next Wi-Fi event, or paste an HF read token in Settings → '
'Advanced to override.',
statusCode: response.statusCode,
);
}
if (response.statusCode == 404) {
throw GemmaDownloadException(
'Mirror $url is missing the expected GGUF file (HTTP 404). Trying '
'the next mirror.',
statusCode: 404,
);
}
throw GemmaDownloadException(
'Server returned HTTP ${response.statusCode}: ${body.substring(0, body.length.clamp(0, 200))}',
statusCode: response.statusCode,
Expand All @@ -178,9 +233,7 @@ class GemmaModelManager {
try {
await for (final chunk in response.stream) {
if (cancelToken?.isCancelled ?? false) {
appLog.i(
'[GemmaModel] Download cancelled — partial file kept for resume',
);
appLog.i('[GemmaModel] Download cancelled — partial file kept for resume');
await sink.flush();
await sink.close();
return;
Expand All @@ -207,9 +260,7 @@ class GemmaModelManager {

// Atomic rename: .download → final path.
await tmpFile.rename(path);
appLog.i(
'[GemmaModel] ✓ Download complete — ${(finalSize / 1e6).round()} MB at $path',
);
appLog.i('[GemmaModel] ✓ Download complete — ${(finalSize / 1e6).round()} MB at $path');
} finally {
client.close();
}
Expand Down
33 changes: 19 additions & 14 deletions lib/services/government_facility_seed_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,32 +22,37 @@ class GovernmentFacilitySeedService {

final list = decoded['facilities'] as List<dynamic>? ?? [];
var n = 0;
final batchParameters = <List<Object?>>[];
for (final item in list) {
if (item is! Map<String, dynamic>) continue;
final row = Map<String, dynamic>.from(item);
final id = row['id']?.toString() ?? '';
if (id.isEmpty) continue;

await db.execute(
batchParameters.add([
id,
row['name'] ?? 'Facility',
row['type'] ?? 'hospital',
(row['latitude'] as num).toDouble(),
(row['longitude'] as num).toDouble(),
row['contact_number'],
row['capabilities'],
row['data_source'] ?? 'gov',
row['state_code'],
row['district'],
]);
n++;
}

if (batchParameters.isNotEmpty) {
await db.executeBatch(
'''
INSERT OR REPLACE INTO emergency_facilities
(id, name, type, latitude, longitude, contact_number, capabilities, data_source, state_code, district)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
[
id,
row['name'] ?? 'Facility',
row['type'] ?? 'hospital',
(row['latitude'] as num).toDouble(),
(row['longitude'] as num).toDouble(),
row['contact_number'],
row['capabilities'],
row['data_source'] ?? 'gov',
row['state_code'],
row['district'],
],
batchParameters,
);
n++;
}

await prefs.setInt(_prefsKeyImportedVersion, v);
Expand Down
8 changes: 4 additions & 4 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1177,10 +1177,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
Expand Down Expand Up @@ -1798,10 +1798,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.9"
timezone:
dependency: transitive
description:
Expand Down
2 changes: 1 addition & 1 deletion supabase/functions/family-track/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";
const allowOrigin = Deno.env.get("FAMILY_TRACK_ALLOWED_ORIGIN") ?? "";
const cors = {
// Default is same-origin only; set FAMILY_TRACK_ALLOWED_ORIGIN="*" explicitly for demos.
"Access-Control-Allow-Origin": allowOrigin.trim() ? allowOrigin.trim() : "null",
...(allowOrigin.trim() ? { "Access-Control-Allow-Origin": allowOrigin.trim() } : {}),
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type, accept",
"Access-Control-Allow-Methods": "GET, OPTIONS",
Expand Down
Loading
Loading