img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/components/ui/input.tsx b/components/ui/input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7d21babba91655ac1cd6a5ea630eb2ec18d2b52b
--- /dev/null
+++ b/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import * as React from "react"
+import { Input as InputPrimitive } from "@base-ui/react/input"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b6e15b3efcc9d514c2d25a5ebf819bfda406780c
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,15 @@
+services:
+ postgres:
+ image: postgres:16-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: langlearn
+ POSTGRES_PASSWORD: langlearn
+ POSTGRES_DB: langlearn
+ ports:
+ - "5432:5432"
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+
+volumes:
+ pgdata:
diff --git a/docs/API.md b/docs/API.md
new file mode 100644
index 0000000000000000000000000000000000000000..8ec1ae95565d8a4b571ad4bfc9d905fb46464dae
--- /dev/null
+++ b/docs/API.md
@@ -0,0 +1,89 @@
+# API contract
+
+All routes are served by the Next.js app. Auth is either:
+- `Cookie: ll_session=
` (web) — set by the OAuth callback.
+- `Authorization: Bearer ` (mobile) — JWT delivered via the `langlearn://auth/callback?token=…` redirect.
+
+The JWT is signed HS256 with `AUTH_SECRET` and contains `{ userId, hfUsername }`. TTL is 7 days.
+
+## Auth
+
+### `GET /api/auth/login`
+Starts the HuggingFace OAuth dance.
+
+Query params:
+- `client=mobile` — if set, the callback will 302 to `langlearn://auth/callback?token=…` instead of setting a cookie. Otherwise, cookie + 302 to `/practice`.
+
+### `GET /api/auth/callback/huggingface`
+Handles HF's redirect back. Validates state + verifier cookies, exchanges code, upserts user, mints JWT. Never call directly.
+
+### `POST /api/auth/logout`
+Clears the session cookie. For mobile, the client just drops the stored JWT.
+
+## Profile
+
+### `GET /api/me`
+```json
+{
+ "user": { "id": "uuid", "hfUsername": "alice", "email": "a@x.y", "avatarUrl": "…" },
+ "profile": { "nativeLang": "en", "targetLang": "es", "level": "A2" } | null
+}
+```
+
+### `PUT /api/me/profile`
+Body:
+```json
+{ "nativeLang": "en", "targetLang": "es", "level": "A2" }
+```
+Responses: 200 with `{ profile }`, 400 on validation errors.
+Mobile bearer-token requests also receive `{ token }` with a refreshed JWT carrying the updated profile.
+
+## Stories
+
+### `GET /api/stories`
+Lists stories matching the user's profile (targetLang + level). Response:
+```json
+{ "stories": [{ "id", "title", "targetLang", "level", "createdAt" }], "profile": {...} }
+```
+
+### `POST /api/stories`
+Generates a new story for the user's profile.
+Body (optional): `{ "topic": "a walk in the park" }`.
+Response: `{ "story": { id, title, content, vocab, ... } }`.
+
+### `GET /api/stories/:id`
+`{ "story": { ... full story ... } }`
+
+### `POST /api/stories/:id/read`
+Marks the story as read by the current user.
+
+## Translate
+
+### `POST /api/translate`
+```json
+{ "text": "perro", "from": "es", "to": "en" }
+```
+Response: `{ "translation": "dog", "cached": true? }`.
+
+## Vision
+
+### `POST /api/vision/analyze`
+Multipart form with field `image` (≤ 8 MB). Response:
+```json
+{
+ "id": "uuid",
+ "caption": "a cat sitting on a couch",
+ "objects": [
+ { "label": "cat", "translation": "gato", "box": [x1,y1,x2,y2], "score": 0.92 }
+ ],
+ "sentences": [
+ { "target": "El gato está sobre el sofá.", "gloss": "The cat is on the sofa." }
+ ],
+ "imageUrl": "https://…" // empty if blob storage isn't configured
+}
+```
+
+## Error shape
+
+All failures return `{ "error": "code", "message"?: "…" }` with an appropriate HTTP status.
+Common codes: `unauthenticated` (401), `invalid_input` (400), `no_profile` (400), `inference_failed` (502), `image_too_large` (413).
diff --git a/drizzle.config.ts b/drizzle.config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..66af194a74672d16630908e7056be6365a0bde7d
--- /dev/null
+++ b/drizzle.config.ts
@@ -0,0 +1,10 @@
+import type { Config } from "drizzle-kit";
+
+export default {
+ schema: "./lib/db/schema.ts",
+ out: "./drizzle",
+ dialect: "postgresql",
+ dbCredentials: {
+ url: process.env.DATABASE_URL ?? "postgres://langlearn:langlearn@localhost:5432/langlearn",
+ },
+} satisfies Config;
diff --git a/drizzle/0000_init.sql b/drizzle/0000_init.sql
new file mode 100644
index 0000000000000000000000000000000000000000..2356ec6c616755a226876022a2a8ad24663354cf
--- /dev/null
+++ b/drizzle/0000_init.sql
@@ -0,0 +1,51 @@
+CREATE TABLE "profiles" (
+ "user_id" uuid PRIMARY KEY NOT NULL,
+ "native_lang" varchar(8) NOT NULL,
+ "target_lang" varchar(8) NOT NULL,
+ "level" varchar(4) NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "stories" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "target_lang" varchar(8) NOT NULL,
+ "level" varchar(4) NOT NULL,
+ "title" text NOT NULL,
+ "content" text NOT NULL,
+ "vocab" jsonb DEFAULT '[]'::jsonb,
+ "created_by" uuid,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "story_reads" (
+ "user_id" uuid NOT NULL,
+ "story_id" uuid NOT NULL,
+ "completed_at" timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT "story_reads_user_id_story_id_pk" PRIMARY KEY("user_id","story_id")
+);
+--> statement-breakpoint
+CREATE TABLE "users" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "hf_id" varchar(64) NOT NULL,
+ "hf_username" varchar(128) NOT NULL,
+ "email" varchar(256),
+ "avatar_url" text,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT "users_hf_id_unique" UNIQUE("hf_id")
+);
+--> statement-breakpoint
+CREATE TABLE "vision_jobs" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "user_id" uuid NOT NULL,
+ "image_url" text NOT NULL,
+ "caption" text,
+ "objects" jsonb,
+ "sentences" jsonb,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "profiles" ADD CONSTRAINT "profiles_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "stories" ADD CONSTRAINT "stories_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "story_reads" ADD CONSTRAINT "story_reads_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "story_reads" ADD CONSTRAINT "story_reads_story_id_stories_id_fk" FOREIGN KEY ("story_id") REFERENCES "public"."stories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "vision_jobs" ADD CONSTRAINT "vision_jobs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
\ No newline at end of file
diff --git a/drizzle/0001_dialogues.sql b/drizzle/0001_dialogues.sql
new file mode 100644
index 0000000000000000000000000000000000000000..1f4651b78405d7fd902584fbc2f18c21090b8656
--- /dev/null
+++ b/drizzle/0001_dialogues.sql
@@ -0,0 +1,13 @@
+CREATE TABLE IF NOT EXISTS "dialogues" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "target_lang" varchar(8) NOT NULL,
+ "level" varchar(4) NOT NULL,
+ "title" text NOT NULL,
+ "scenario" text NOT NULL,
+ "turns" jsonb DEFAULT '[]'::jsonb NOT NULL,
+ "created_by" uuid,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT "dialogues_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE set null
+);
+
+CREATE INDEX IF NOT EXISTS "dialogues_lang_level_idx" ON "dialogues" ("target_lang", "level");
diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json
new file mode 100644
index 0000000000000000000000000000000000000000..f0bc4a5717fe1080335cb2e82eda8884678f1595
--- /dev/null
+++ b/drizzle/meta/0000_snapshot.json
@@ -0,0 +1,352 @@
+{
+ "id": "0f15c437-0b8f-4ae0-bb21-462e97311f86",
+ "prevId": "00000000-0000-0000-0000-000000000000",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.profiles": {
+ "name": "profiles",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "native_lang": {
+ "name": "native_lang",
+ "type": "varchar(8)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_lang": {
+ "name": "target_lang",
+ "type": "varchar(8)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "level": {
+ "name": "level",
+ "type": "varchar(4)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "profiles_user_id_users_id_fk": {
+ "name": "profiles_user_id_users_id_fk",
+ "tableFrom": "profiles",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.stories": {
+ "name": "stories",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "target_lang": {
+ "name": "target_lang",
+ "type": "varchar(8)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "level": {
+ "name": "level",
+ "type": "varchar(4)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "vocab": {
+ "name": "vocab",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "stories_created_by_users_id_fk": {
+ "name": "stories_created_by_users_id_fk",
+ "tableFrom": "stories",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.story_reads": {
+ "name": "story_reads",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "story_id": {
+ "name": "story_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "story_reads_user_id_users_id_fk": {
+ "name": "story_reads_user_id_users_id_fk",
+ "tableFrom": "story_reads",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "story_reads_story_id_stories_id_fk": {
+ "name": "story_reads_story_id_stories_id_fk",
+ "tableFrom": "story_reads",
+ "tableTo": "stories",
+ "columnsFrom": [
+ "story_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "story_reads_user_id_story_id_pk": {
+ "name": "story_reads_user_id_story_id_pk",
+ "columns": [
+ "user_id",
+ "story_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "hf_id": {
+ "name": "hf_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hf_username": {
+ "name": "hf_username",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_hf_id_unique": {
+ "name": "users_hf_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "hf_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.vision_jobs": {
+ "name": "vision_jobs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "caption": {
+ "name": "caption",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "objects": {
+ "name": "objects",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sentences": {
+ "name": "sentences",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "vision_jobs_user_id_users_id_fk": {
+ "name": "vision_jobs_user_id_users_id_fk",
+ "tableFrom": "vision_jobs",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
new file mode 100644
index 0000000000000000000000000000000000000000..3b5caffbbb9cae9967b4d3b963c8e383d12e5e7d
--- /dev/null
+++ b/drizzle/meta/_journal.json
@@ -0,0 +1,13 @@
+{
+ "version": "7",
+ "dialect": "postgresql",
+ "entries": [
+ {
+ "idx": 0,
+ "version": "7",
+ "when": 1777027078006,
+ "tag": "0000_init",
+ "breakpoints": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..05e726d1b4201bc8c7716d2b058279676582e8c0
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,18 @@
+import { defineConfig, globalIgnores } from "eslint/config";
+import nextVitals from "eslint-config-next/core-web-vitals";
+import nextTs from "eslint-config-next/typescript";
+
+const eslintConfig = defineConfig([
+ ...nextVitals,
+ ...nextTs,
+ // Override default ignores of eslint-config-next.
+ globalIgnores([
+ // Default ignores of eslint-config-next:
+ ".next/**",
+ "out/**",
+ "build/**",
+ "next-env.d.ts",
+ ]),
+]);
+
+export default eslintConfig;
diff --git a/lib/auth/hf.ts b/lib/auth/hf.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e1f0220baefed029a7ea6162018ef89185ebef22
--- /dev/null
+++ b/lib/auth/hf.ts
@@ -0,0 +1,83 @@
+import { OAuth2Client, CodeChallengeMethod, generateCodeVerifier, generateState, type OAuth2Tokens } from "arctic";
+
+const AUTH_URL = "https://huggingface.co/oauth/authorize";
+const TOKEN_URL = "https://huggingface.co/oauth/token";
+const USERINFO_URL = "https://huggingface.co/oauth/userinfo";
+
+function firstHeaderValue(value: string | null): string | null {
+ return value?.split(",")[0]?.trim() || null;
+}
+
+function stripScheme(host: string | undefined): string | null {
+ return host?.trim().replace(/^https?:\/\//, "").replace(/\/$/, "") || null;
+}
+
+function isLocalHost(host: string): boolean {
+ return host.startsWith("localhost") || host.startsWith("127.") || host.startsWith("0.0.0.0") || host === "::1";
+}
+
+// Derive the public origin from the incoming request. HF Spaces serves at
+// reubencf-praxaling.hf.space and our app sits behind their reverse proxy, so
+// X-Forwarded-* headers carry the real host/scheme. NEXT_PUBLIC_APP_URL is
+// unreliable on deploy targets (defaults to localhost in .env), so we never
+// use it for the OAuth redirect URI — auth-request and token-exchange must
+// use byte-identical redirect_uri values, and request-derived origin is the
+// one thing both sides see consistently.
+export function hfOriginFromRequest(req: Request): string {
+ const forwardedHost = firstHeaderValue(req.headers.get("x-forwarded-host"));
+ const forwardedProto = firstHeaderValue(req.headers.get("x-forwarded-proto"));
+ const spaceHost = stripScheme(process.env.SPACE_HOST);
+ const url = new URL(req.url);
+ const requestHost = stripScheme(forwardedHost ?? req.headers.get("host") ?? url.host) ?? url.host;
+ const host = spaceHost && isLocalHost(requestHost) ? spaceHost : requestHost;
+ const proto = forwardedProto ?? (isLocalHost(host) ? "http" : "https");
+ return `${proto}://${host}`;
+}
+
+export type HFUser = {
+ sub: string;
+ name?: string;
+ preferred_username: string;
+ email?: string;
+ picture?: string;
+ email_verified?: boolean;
+};
+
+function clientForRedirect(redirectUri: string) {
+ const id = process.env.OAUTH_CLIENT_ID ?? process.env.HF_OAUTH_CLIENT_ID;
+ const secret = process.env.OAUTH_CLIENT_SECRET ?? process.env.HF_OAUTH_CLIENT_SECRET;
+ if (!id || !secret) {
+ throw new Error("HF_OAUTH_CLIENT_ID / HF_OAUTH_CLIENT_SECRET or OAUTH_CLIENT_ID / OAUTH_CLIENT_SECRET not set");
+ }
+ return new OAuth2Client(id, secret, redirectUri);
+}
+
+export function hfBuildAuthUrl(redirectUri: string) {
+ const client = clientForRedirect(redirectUri);
+ const state = generateState();
+ const verifier = generateCodeVerifier();
+ const url = client.createAuthorizationURLWithPKCE(AUTH_URL, state, CodeChallengeMethod.S256, verifier, [
+ "openid",
+ "profile",
+ "email",
+ "inference-api",
+ ]);
+ return { url, state, verifier };
+}
+
+export async function hfExchangeCode(
+ redirectUri: string,
+ code: string,
+ verifier: string,
+): Promise {
+ const client = clientForRedirect(redirectUri);
+ return client.validateAuthorizationCode(TOKEN_URL, code, verifier);
+}
+
+export async function hfFetchUser(accessToken: string): Promise {
+ const res = await fetch(USERINFO_URL, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ });
+ if (!res.ok) throw new Error(`HF userinfo failed: ${res.status}`);
+ return (await res.json()) as HFUser;
+}
diff --git a/lib/auth/session.ts b/lib/auth/session.ts
new file mode 100644
index 0000000000000000000000000000000000000000..52ff90edf56a72d9c4ca24e1cb9cfb7f3c1ed407
--- /dev/null
+++ b/lib/auth/session.ts
@@ -0,0 +1,88 @@
+import { SignJWT, jwtVerify } from "jose";
+import { cookies, headers } from "next/headers";
+import type { NextResponse } from "next/server";
+
+export const SESSION_COOKIE = "ll_session";
+const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
+
+function secret() {
+ const s = process.env.AUTH_SECRET;
+ if (!s) throw new Error("AUTH_SECRET is not set");
+ return new TextEncoder().encode(s);
+}
+
+export type SessionPayload = {
+ hfId: string;
+ hfUsername: string;
+ email?: string;
+ avatarUrl?: string;
+ nativeLang?: string;
+ targetLang?: string;
+ targetLangs?: string[];
+ level?: string;
+ accessToken?: string;
+ streakCount?: number;
+ lastActiveDate?: string;
+};
+
+export async function signSession(payload: SessionPayload): Promise {
+ return new SignJWT({ ...payload })
+ .setProtectedHeader({ alg: "HS256" })
+ .setIssuedAt()
+ .setExpirationTime(`${SESSION_TTL_SECONDS}s`)
+ .sign(secret());
+}
+
+export async function verifySession(token: string): Promise {
+ try {
+ const { payload } = await jwtVerify(token, secret(), { algorithms: ["HS256"] });
+ if (!payload.hfId) return null;
+ return {
+ hfId: payload.hfId,
+ hfUsername: payload.hfUsername,
+ email: payload.email,
+ avatarUrl: payload.avatarUrl,
+ nativeLang: payload.nativeLang,
+ targetLang: payload.targetLang,
+ targetLangs: payload.targetLangs,
+ level: payload.level,
+ accessToken: payload.accessToken,
+ streakCount: payload.streakCount,
+ lastActiveDate: payload.lastActiveDate,
+ };
+ } catch {
+ return null;
+ }
+}
+
+export async function getSession(): Promise {
+ const authHeader = (await headers()).get("authorization");
+ if (authHeader?.toLowerCase().startsWith("bearer ")) {
+ const token = authHeader.slice(7).trim();
+ if (token) return verifySession(token);
+ }
+ const token = (await cookies()).get(SESSION_COOKIE)?.value;
+ if (!token) return null;
+ return verifySession(token);
+}
+
+export function sessionCookieOptions(maxAge: number) {
+ const isProd = process.env.NODE_ENV === "production";
+ return {
+ httpOnly: true,
+ secure: isProd,
+ sameSite: (isProd ? "none" : "lax") as "none" | "lax",
+ path: "/",
+ maxAge,
+ };
+}
+
+export function setSessionCookie(res: NextResponse, token: string) {
+ res.cookies.set(SESSION_COOKIE, token, sessionCookieOptions(SESSION_TTL_SECONDS));
+}
+
+export function clearSessionCookie(res: NextResponse) {
+ res.cookies.set(SESSION_COOKIE, "", sessionCookieOptions(0));
+}
+
+export const SESSION_MAX_AGE = SESSION_TTL_SECONDS;
diff --git a/lib/db/client.ts b/lib/db/client.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8f2183cde1482cd18ce528bd21b2089cfe5929e3
--- /dev/null
+++ b/lib/db/client.ts
@@ -0,0 +1,17 @@
+import { drizzle } from "drizzle-orm/postgres-js";
+import postgres from "postgres";
+import * as schema from "./schema";
+
+const connectionString = process.env.DATABASE_URL;
+
+if (!connectionString) {
+ throw new Error("DATABASE_URL is not set");
+}
+
+const globalForDb = globalThis as unknown as { pg?: ReturnType };
+const client = globalForDb.pg ?? postgres(connectionString, { prepare: false });
+if (process.env.NODE_ENV !== "production") globalForDb.pg = client;
+
+export const db = drizzle(client, { schema });
+export { schema };
+
diff --git a/lib/db/schema.ts b/lib/db/schema.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fc5433a0d8d1d46a62a8ed29f1a617d0bcb8a2b1
--- /dev/null
+++ b/lib/db/schema.ts
@@ -0,0 +1,120 @@
+import { relations, sql } from "drizzle-orm";
+import {
+ jsonb,
+ pgTable,
+ primaryKey,
+ text,
+ timestamp,
+ uuid,
+ varchar,
+} from "drizzle-orm/pg-core";
+
+export const users = pgTable("users", {
+ id: uuid("id").primaryKey().defaultRandom(),
+ hfId: varchar("hf_id", { length: 64 }).notNull().unique(),
+ hfUsername: varchar("hf_username", { length: 128 }).notNull(),
+ email: varchar("email", { length: 256 }),
+ avatarUrl: text("avatar_url"),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+});
+
+export const profiles = pgTable("profiles", {
+ userId: uuid("user_id")
+ .primaryKey()
+ .references(() => users.id, { onDelete: "cascade" }),
+ nativeLang: varchar("native_lang", { length: 8 }).notNull(),
+ targetLang: varchar("target_lang", { length: 8 }).notNull(),
+ level: varchar("level", { length: 4 }).notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
+});
+
+export const stories = pgTable("stories", {
+ id: uuid("id").primaryKey().defaultRandom(),
+ targetLang: varchar("target_lang", { length: 8 }).notNull(),
+ level: varchar("level", { length: 4 }).notNull(),
+ title: text("title").notNull(),
+ content: text("content").notNull(),
+ vocab: jsonb("vocab").$type>().default(sql`'[]'::jsonb`),
+ createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+});
+
+export const storyReads = pgTable(
+ "story_reads",
+ {
+ userId: uuid("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ storyId: uuid("story_id")
+ .notNull()
+ .references(() => stories.id, { onDelete: "cascade" }),
+ completedAt: timestamp("completed_at", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (t) => [primaryKey({ columns: [t.userId, t.storyId] })],
+);
+
+export const visionJobs = pgTable("vision_jobs", {
+ id: uuid("id").primaryKey().defaultRandom(),
+ userId: uuid("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ imageUrl: text("image_url").notNull(),
+ caption: text("caption"),
+ objects: jsonb("objects").$type<
+ Array<{ label: string; translation: string; box: [number, number, number, number]; score: number }>
+ >(),
+ sentences: jsonb("sentences").$type>(),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+});
+
+export type DialogueOption = {
+ text: string;
+ isCorrect: boolean;
+ feedback: string;
+};
+
+export type DialogueTurn = {
+ type: "narration" | "character" | "user_choice";
+ text: string;
+ translation?: string;
+ speakerName?: string;
+ options?: DialogueOption[];
+};
+
+export const dialogues = pgTable("dialogues", {
+ id: uuid("id").primaryKey().defaultRandom(),
+ targetLang: varchar("target_lang", { length: 8 }).notNull(),
+ level: varchar("level", { length: 4 }).notNull(),
+ title: text("title").notNull(),
+ scenario: text("scenario").notNull(),
+ turns: jsonb("turns").$type().notNull().default(sql`'[]'::jsonb`),
+ createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+});
+
+export const usersRelations = relations(users, ({ one, many }) => ({
+ profile: one(profiles, { fields: [users.id], references: [profiles.userId] }),
+ stories: many(stories),
+ reads: many(storyReads),
+ visionJobs: many(visionJobs),
+ dialogues: many(dialogues),
+}));
+
+export const profilesRelations = relations(profiles, ({ one }) => ({
+ user: one(users, { fields: [profiles.userId], references: [users.id] }),
+}));
+
+export const storiesRelations = relations(stories, ({ one, many }) => ({
+ author: one(users, { fields: [stories.createdBy], references: [users.id] }),
+ reads: many(storyReads),
+}));
+
+export const storyReadsRelations = relations(storyReads, ({ one }) => ({
+ user: one(users, { fields: [storyReads.userId], references: [users.id] }),
+ story: one(stories, { fields: [storyReads.storyId], references: [stories.id] }),
+}));
+
+export const dialoguesRelations = relations(dialogues, ({ one }) => ({
+ author: one(users, { fields: [dialogues.createdBy], references: [users.id] }),
+}));
+
diff --git a/lib/dialogue-scenarios.ts b/lib/dialogue-scenarios.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4b4604ed6dd724d4442eccbf94c2c4a218a149d8
--- /dev/null
+++ b/lib/dialogue-scenarios.ts
@@ -0,0 +1,108 @@
+export type DialogueScenario = {
+ title: string;
+ scenario: string;
+ description: string;
+};
+
+export const DIALOGUE_SCENARIOS: DialogueScenario[] = [
+ {
+ title: "Airport Check-In",
+ scenario: "checking in at an airport counter, asking about baggage, seat choice, and boarding time",
+ description: "Baggage, seats, gates, and polite travel questions.",
+ },
+ {
+ title: "Cafe Order",
+ scenario: "ordering coffee and food at a busy cafe, asking about ingredients and paying",
+ description: "Order naturally, ask follow-ups, and respond to staff.",
+ },
+ {
+ title: "Doctor Visit",
+ scenario: "visiting a doctor, explaining symptoms, answering questions, and understanding advice",
+ description: "Describe how you feel and understand care instructions.",
+ },
+ {
+ title: "Hotel Reception",
+ scenario: "checking into a hotel, confirming a reservation, asking about breakfast and room problems",
+ description: "Reservations, requests, timings, and service issues.",
+ },
+ {
+ title: "Job Interview",
+ scenario: "a job interview with questions about experience, strengths, schedule, and salary expectations",
+ description: "Professional answers with realistic follow-up questions.",
+ },
+ {
+ title: "Apartment Viewing",
+ scenario: "viewing an apartment, asking about rent, utilities, neighborhood, and lease terms",
+ description: "Housing vocabulary and negotiation practice.",
+ },
+ {
+ title: "Pharmacy",
+ scenario: "buying medicine at a pharmacy, explaining a minor illness and asking how to take it",
+ description: "Symptoms, dosage, warnings, and simple health questions.",
+ },
+ {
+ title: "Train Station",
+ scenario: "buying a train ticket, asking about platforms, delays, and connections",
+ description: "Tickets, schedules, delays, and directions.",
+ },
+ {
+ title: "Restaurant Reservation",
+ scenario: "calling a restaurant to book a table, requesting a time, special seating, and dietary needs",
+ description: "Booking by phone with polite, specific requests.",
+ },
+ {
+ title: "Bank Visit",
+ scenario: "opening a bank account or sending a transfer, asking about fees, ID, and processing time",
+ description: "Accounts, transfers, IDs, and clarifying fees.",
+ },
+ {
+ title: "Taxi or Rideshare",
+ scenario: "taking a taxi or rideshare, giving the destination, talking about route and fare",
+ description: "Directions, fares, small talk during the ride.",
+ },
+ {
+ title: "Post Office",
+ scenario: "sending a parcel at the post office, comparing shipping speeds, asking about customs and tracking",
+ description: "Parcels, customs forms, tracking, and prices.",
+ },
+ {
+ title: "Phone Plan Sign-Up",
+ scenario: "signing up for a mobile phone plan, comparing data, contracts, and roaming",
+ description: "Plans, data, contracts, and cancellation rules.",
+ },
+ {
+ title: "Tech Support Call",
+ scenario: "calling tech support about an internet outage, describing the problem and following steps",
+ description: "Describe the issue, follow troubleshooting steps.",
+ },
+ {
+ title: "Haircut",
+ scenario: "getting a haircut, describing length, style, and reacting to suggestions from the stylist",
+ description: "Style requests, lengths, and polite feedback.",
+ },
+ {
+ title: "Gym Sign-Up",
+ scenario: "joining a gym, asking about membership, classes, equipment, and trial passes",
+ description: "Memberships, classes, equipment, and trials.",
+ },
+ {
+ title: "Lost & Found",
+ scenario: "reporting a lost item at a station, describing it, when and where it was lost",
+ description: "Describe the item, time, and place clearly.",
+ },
+ {
+ title: "Customs at Border",
+ scenario: "going through customs, declaring items, answering questions about your trip",
+ description: "Declarations, trip purpose, and short answers.",
+ },
+ {
+ title: "First Date",
+ scenario: "meeting someone for a first date at a cafe, getting to know each other, talking about hobbies",
+ description: "Small talk, hobbies, getting to know someone.",
+ },
+ {
+ title: "University Office",
+ scenario: "visiting the university registrar, asking about enrollment, deadlines, and required documents",
+ description: "Enrollment, paperwork, deadlines, and signatures.",
+ },
+];
diff --git a/lib/dialogue-themes.ts b/lib/dialogue-themes.ts
new file mode 100644
index 0000000000000000000000000000000000000000..87b4290715c4186bcc359945ea72aab30532e407
--- /dev/null
+++ b/lib/dialogue-themes.ts
@@ -0,0 +1,38 @@
+import {
+ Plane, Coffee, Stethoscope, Building2, Briefcase, Key, Pill,
+ TrainFront, Utensils, Landmark, Car, Mail, Smartphone, Wifi,
+ Scissors, Dumbbell, Search, Shield, Heart, GraduationCap,
+ type LucideIcon,
+} from "lucide-react";
+
+export type ScenarioTheme = {
+ icon: LucideIcon;
+ tile: string;
+ tileText: string;
+ ring: string;
+ chip: string;
+ chipText: string;
+};
+
+export const SCENARIO_THEMES: ScenarioTheme[] = [
+ { icon: Plane, tile: "bg-emerald-50", tileText: "text-emerald-700", ring: "hover:ring-emerald-600/30", chip: "bg-emerald-50", chipText: "text-emerald-700" },
+ { icon: Coffee, tile: "bg-amber-50", tileText: "text-amber-700", ring: "hover:ring-amber-600/30", chip: "bg-amber-50", chipText: "text-amber-700" },
+ { icon: Stethoscope, tile: "bg-rose-50", tileText: "text-rose-700", ring: "hover:ring-rose-600/30", chip: "bg-rose-50", chipText: "text-rose-700" },
+ { icon: Building2, tile: "bg-teal-50", tileText: "text-teal-700", ring: "hover:ring-teal-600/30", chip: "bg-teal-50", chipText: "text-teal-700" },
+ { icon: Briefcase, tile: "bg-orange-50", tileText: "text-orange-700", ring: "hover:ring-orange-600/30", chip: "bg-orange-50", chipText: "text-orange-700" },
+ { icon: Key, tile: "bg-amber-50", tileText: "text-amber-800", ring: "hover:ring-amber-700/30", chip: "bg-amber-50", chipText: "text-amber-800" },
+ { icon: Pill, tile: "bg-rose-50", tileText: "text-rose-800", ring: "hover:ring-rose-700/30", chip: "bg-rose-50", chipText: "text-rose-800" },
+ { icon: TrainFront, tile: "bg-teal-50", tileText: "text-teal-800", ring: "hover:ring-teal-700/30", chip: "bg-teal-50", chipText: "text-teal-800" },
+ { icon: Utensils, tile: "bg-orange-50", tileText: "text-orange-800", ring: "hover:ring-orange-700/30", chip: "bg-orange-50", chipText: "text-orange-800" },
+ { icon: Landmark, tile: "bg-emerald-50", tileText: "text-emerald-800", ring: "hover:ring-emerald-700/30", chip: "bg-emerald-50", chipText: "text-emerald-800" },
+ { icon: Car, tile: "bg-amber-50", tileText: "text-amber-700", ring: "hover:ring-amber-600/30", chip: "bg-amber-50", chipText: "text-amber-700" },
+ { icon: Mail, tile: "bg-teal-50", tileText: "text-teal-700", ring: "hover:ring-teal-600/30", chip: "bg-teal-50", chipText: "text-teal-700" },
+ { icon: Smartphone, tile: "bg-emerald-50", tileText: "text-emerald-700", ring: "hover:ring-emerald-600/30", chip: "bg-emerald-50", chipText: "text-emerald-700" },
+ { icon: Wifi, tile: "bg-orange-50", tileText: "text-orange-700", ring: "hover:ring-orange-600/30", chip: "bg-orange-50", chipText: "text-orange-700" },
+ { icon: Scissors, tile: "bg-rose-50", tileText: "text-rose-700", ring: "hover:ring-rose-600/30", chip: "bg-rose-50", chipText: "text-rose-700" },
+ { icon: Dumbbell, tile: "bg-amber-50", tileText: "text-amber-800", ring: "hover:ring-amber-700/30", chip: "bg-amber-50", chipText: "text-amber-800" },
+ { icon: Search, tile: "bg-stone-50", tileText: "text-stone-700", ring: "hover:ring-stone-600/30", chip: "bg-stone-50", chipText: "text-stone-700" },
+ { icon: Shield, tile: "bg-slate-50", tileText: "text-slate-700", ring: "hover:ring-slate-600/30", chip: "bg-slate-50", chipText: "text-slate-700" },
+ { icon: Heart, tile: "bg-rose-50", tileText: "text-rose-600", ring: "hover:ring-rose-500/30", chip: "bg-rose-50", chipText: "text-rose-600" },
+ { icon: GraduationCap, tile: "bg-emerald-50", tileText: "text-emerald-700", ring: "hover:ring-emerald-600/30", chip: "bg-emerald-50", chipText: "text-emerald-700" },
+];
diff --git a/lib/hf/client.ts b/lib/hf/client.ts
new file mode 100644
index 0000000000000000000000000000000000000000..569620c1fc3c7819c39f6a3cea2c9cebfe8561da
--- /dev/null
+++ b/lib/hf/client.ts
@@ -0,0 +1,25 @@
+import { InferenceClient } from "@huggingface/inference";
+
+const JWT_SHAPE = /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
+
+export function createInferenceClient(userAccessToken?: string) {
+ // Prefer the signed-in user's HF OAuth access token (granted via the
+ // `inference-api` scope on sign-in). Falls back to a server-side
+ // HF_TOKEN env var if no user token is present (e.g. background jobs).
+ if (userAccessToken) {
+ return new InferenceClient(userAccessToken);
+ }
+
+ const serverToken = process.env.HF_TOKEN?.trim();
+ if (!serverToken) {
+ throw new Error(
+ "No HuggingFace token available. Sign in to use your OAuth token, or set HF_TOKEN to a Personal Access Token (starts with `hf_`) from https://huggingface.co/settings/tokens.",
+ );
+ }
+ if (JWT_SHAPE.test(serverToken)) {
+ throw new Error(
+ "HF_TOKEN looks like an OAuth access token (JWT). For server-side inference set HF_TOKEN to a long-lived Personal Access Token starting with `hf_` from https://huggingface.co/settings/tokens, or remove HF_TOKEN and rely on per-user OAuth.",
+ );
+ }
+ return new InferenceClient(serverToken);
+}
diff --git a/lib/hf/dialogue.ts b/lib/hf/dialogue.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bc9d968fb1f5458373059b46e8b374236a838b83
--- /dev/null
+++ b/lib/hf/dialogue.ts
@@ -0,0 +1,114 @@
+import { languageName, type Level } from "@/lib/languages";
+import type { DialogueTurn } from "@/lib/types/dialogue";
+import { extractJson } from "@/lib/hf/json";
+import { parseSegments } from "@/lib/hf/segments";
+import { createInferenceClient } from "@/lib/hf/client";
+import { HF_PROVIDER, TEXT_MODEL } from "@/lib/hf/models";
+
+const SCENARIO_HINTS: Record = {
+ A1: "simple daily situations: buying food at a market, asking for directions, checking into a hostel, ordering a coffee",
+ A2: "everyday transactions: taking a taxi, at the pharmacy, booking a train ticket, at a hotel reception",
+ B1: "intermediate situations: a job interview, visiting a doctor, renting a flat, discussing travel plans",
+ B2: "nuanced interactions: a business meeting, negotiating a contract, discussing news events, academic advising",
+ C1: "complex scenarios: legal consultation, academic debate, diplomatic exchange, cultural discussion",
+};
+
+export async function generateDialogue(opts: {
+ targetLang: string;
+ nativeLang: string;
+ level: Level;
+ scenario?: string;
+ accessToken?: string;
+}): Promise<{ title: string; scenario: string; turns: DialogueTurn[] }> {
+ const hf = createInferenceClient(opts.accessToken);
+ const lang = languageName(opts.targetLang);
+ const native = languageName(opts.nativeLang);
+ const hint = SCENARIO_HINTS[opts.level];
+ const scenarioPrompt = opts.scenario?.trim() || `a random situation from: ${hint}`;
+
+ const isJapanese = opts.targetLang === "ja";
+ const japaneseRule = isJapanese
+ ? `\n- TARGET LANGUAGE IS JAPANESE: for EVERY Japanese-text field (each "character" turn's "text", each option's "text"), ALSO emit a parallel "textSegments" array on the same object. Each segment is { "text": , "reading": }. Concatenating all "text" values reproduces the original string exactly. OMIT "reading" for pure-kana segments. Example: "猫が好きです" → [{"text":"猫","reading":"ねこ"},{"text":"が"},{"text":"好","reading":"す"},{"text":"きです"}]. Do NOT add furigana parens inside the plain "text" field — keep that pure Japanese.`
+ : "";
+
+ const system = `You are a language-learning dialogue writer. Create an interactive dialogue in ${lang} at CEFR ${opts.level}.
+
+Rules:
+- 8-12 turns total
+- TWO distinct named characters speak in this scene (give them culturally appropriate names and clearly different roles, e.g. clerk + traveler, doctor + patient, two friends with contrasting personalities). Alternate between them naturally so the user hears both voices.
+- Mix of "narration" (${native} scene-setting), "character" (character speaks in ${lang}), and "user_choice" (player picks a response)
+- Each "character" turn MUST set "speakerName" to ONE of the two names you chose, and MUST have a "translation" field with the meaning in ${native}
+- Each "user_choice" turn MUST have exactly 3 "options": 1 correct, 2 plausible but wrong
+- VARY the position of the correct option across turns — sometimes first, sometimes second, sometimes third. Never put the correct one in the same slot twice in a row. (The client also reshuffles, but YOUR draft should not always lead with the correct one.)
+- Wrong options should be genuinely tempting (right register but wrong meaning, right meaning but rude tone, grammatically odd, etc.) — not obviously silly.
+- Each option needs "feedback" (2-3 words in ${native} explaining why correct/wrong)
+- LOCK the language difficulty to CEFR ${opts.level}. Vocabulary, grammar tenses, sentence length, and idiom load must all match this level. A real ${opts.level} learner should understand each character line on first read. If a phrase would push them above ${opts.level}, simplify it to stay in band.
+- Make each generation FRESH — vary the setting details, character names, conflict, and resolution. Avoid generic templates. Pick concrete specifics (a particular dish, a real-sounding street name, an unusual request).
+- End with a positive or interesting resolution${japaneseRule}
+
+Output STRICT JSON:
+{
+ "title": "string (${native} title of the scenario)",
+ "scenario": "string (1-2 sentence ${native} description of the scene and role for the player)",
+ "turns": [
+ { "type": "narration", "text": "${native} description" },
+ { "type": "character", "speakerName": "Name", "text": "${lang} speech", "textSegments": [ /* JP only */ ], "translation": "${native} meaning" },
+ { "type": "user_choice", "text": "What do you say?", "options": [
+ { "text": "${lang} option", "textSegments": [ /* JP only */ ], "isCorrect": true, "feedback": "${native}: Perfect!" },
+ { "text": "${lang} option", "textSegments": [ /* JP only */ ], "isCorrect": false, "feedback": "${native}: Too formal" },
+ { "text": "${lang} option", "textSegments": [ /* JP only */ ], "isCorrect": false, "feedback": "${native}: Rude tone" }
+ ]}
+ ]
+}`;
+
+ const varietySeed = Math.random().toString(36).slice(2, 8);
+
+ const res = await hf.chatCompletion({
+ provider: HF_PROVIDER,
+ model: TEXT_MODEL,
+ messages: [
+ { role: "system", content: system },
+ {
+ role: "user",
+ content: `Create a fresh dialogue about: ${scenarioPrompt}.\n\nVariety seed (pick unrelated specifics for this run): ${varietySeed}.`,
+ },
+ ],
+ max_tokens: 1400,
+ temperature: 1.0,
+ top_p: 0.95,
+ response_format: { type: "json_object" },
+ });
+
+ const raw = res.choices?.[0]?.message?.content;
+ if (!raw) throw new Error("empty dialogue response");
+
+ const parsed = extractJson(raw);
+
+ const turns: DialogueTurn[] = Array.isArray(parsed.turns)
+ ? parsed.turns
+ .filter((t: unknown): t is Record => typeof t === "object" && t !== null)
+ .map((t) => ({
+ type: (t.type as DialogueTurn["type"]) ?? "narration",
+ text: String(t.text ?? ""),
+ textSegments: parseSegments(t.textSegments),
+ translation: t.translation ? String(t.translation) : undefined,
+ speakerName: t.speakerName ? String(t.speakerName) : undefined,
+ options: Array.isArray(t.options)
+ ? t.options
+ .filter((o: unknown): o is Record => typeof o === "object" && o !== null)
+ .map((o) => ({
+ text: String(o.text ?? ""),
+ textSegments: parseSegments(o.textSegments),
+ isCorrect: Boolean(o.isCorrect),
+ feedback: String(o.feedback ?? ""),
+ }))
+ : undefined,
+ }))
+ : [];
+
+ return {
+ title: String(parsed.title ?? "Dialogue"),
+ scenario: String(parsed.scenario ?? ""),
+ turns,
+ };
+}
diff --git a/lib/hf/gemma-vision.ts b/lib/hf/gemma-vision.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6219ee60cebd82201640ee88307bc593166efb1a
--- /dev/null
+++ b/lib/hf/gemma-vision.ts
@@ -0,0 +1,134 @@
+import { languageName, type Level } from "@/lib/languages";
+import type { FuriSegment } from "@/lib/types/dialogue";
+import { extractJson } from "@/lib/hf/json";
+import { parseSegments } from "@/lib/hf/segments";
+import { createInferenceClient } from "@/lib/hf/client";
+import { HF_PROVIDER, VISION_MODEL } from "@/lib/hf/models";
+
+export type VisionObject = {
+ labelEn: string;
+ labelTarget: string;
+ labelTargetSegments?: FuriSegment[];
+ romanized?: string;
+ box: [number, number, number, number];
+};
+
+export type VisionSentence = {
+ target: string;
+ targetSegments?: FuriSegment[];
+ gloss: string;
+ romanized?: string;
+};
+
+export type GemmaVisionResult = {
+ objects: VisionObject[];
+ sentences: VisionSentence[];
+ caption: string;
+};
+
+const LEVEL_SENTENCE_BRIEF: Record = {
+ A1: "very short, present tense, using only basic vocabulary",
+ A2: "simple past/present, basic connectives",
+ B1: "mixed tenses with a short dependent clause",
+ B2: "varied tenses, some idiom or descriptive language",
+ C1: "rich vocabulary, complex structure, nuanced meaning",
+};
+
+export async function analyzeWithGemma(opts: {
+ imageBase64: string;
+ mimeType: string;
+ targetLang: string;
+ nativeLang: string;
+ level: Level;
+ accessToken?: string;
+}): Promise {
+ const lang = languageName(opts.targetLang);
+ const brief = LEVEL_SENTENCE_BRIEF[opts.level];
+ const isJapanese = opts.targetLang === "ja";
+ const japaneseRule = isJapanese
+ ? `\n\nJAPANESE-SPECIFIC: for EVERY Japanese string ("labelTarget" on each object, "target" on each sentence), ALSO emit a parallel "labelTargetSegments" / "targetSegments" array on the same item. Each segment is { "text": , "reading": }. Concatenating each "text" reproduces the original Japanese string exactly. OMIT "reading" for pure-kana segments. Example: "自転車に乗る" → [{"text":"自転車","reading":"じてんしゃ"},{"text":"に"},{"text":"乗","reading":"の"},{"text":"る"}]. Do NOT add furigana parens inside the plain string fields. The "romanized" field stays as full romaji.`
+ : "";
+
+ const prompt = `You are a language-learning visual assistant. Analyze this image for a student learning ${lang} at CEFR ${opts.level}.
+
+Tasks:
+1. List all clearly visible objects (up to 10). For each provide:
+ - English label
+ - Label in ${lang}
+ - Romanized pronunciation of the ${lang} label (if ${lang} uses a non-Latin script, e.g., Korean, Chinese, Hindi, etc.)
+ - Bounding box as [x1, y1, x2, y2] with values 0–1 (normalized to image dimensions, origin top-left)
+
+2. Write sentences in ${lang} using the detected objects. Sentences MUST be ${brief}. Vocabulary, grammar, and length must stay within CEFR ${opts.level} — never push above it. A real ${opts.level} learner should grasp each sentence on first read. Each sentence needs an English translation and a romanized pronunciation (if applicable).
+
+3. Write a one-sentence English caption describing the overall scene.${japaneseRule}
+
+Output STRICT JSON:
+{
+ "caption": "English scene description",
+ "objects": [
+ { "labelEn": "dog", "labelTarget": "${lang} word", "labelTargetSegments": [ /* JP only */ ], "romanized": "Roman version of the ${lang} word", "box": [0.1, 0.2, 0.4, 0.8] }
+ ],
+ "sentences": [
+ { "target": "${lang} sentence", "targetSegments": [ /* JP only */ ], "romanized": "Roman version of the ${lang} sentence", "gloss": "English translation" }
+ ]
+}`;
+
+ const hf = createInferenceClient(opts.accessToken);
+ const res = await hf.chatCompletion({
+ provider: HF_PROVIDER,
+ model: VISION_MODEL,
+ messages: [
+ {
+ role: "user",
+ content: [
+ { type: "text", text: prompt },
+ {
+ type: "image_url",
+ image_url: { url: `data:${opts.mimeType};base64,${opts.imageBase64}` },
+ },
+ ],
+ },
+ ],
+ max_tokens: 1000,
+ temperature: 0.3,
+ response_format: { type: "json_object" },
+ });
+
+ const raw = res.choices?.[0]?.message?.content;
+ if (!raw) throw new Error("empty Gemma response");
+
+ const parsed = extractJson(raw);
+
+ const objects: VisionObject[] = Array.isArray(parsed.objects)
+ ? parsed.objects
+ .filter((o: unknown): o is Record => typeof o === "object" && o !== null)
+ .map((o) => ({
+ labelEn: String(o.labelEn ?? ""),
+ labelTarget: String(o.labelTarget ?? ""),
+ labelTargetSegments: parseSegments(o.labelTargetSegments),
+ romanized: o.romanized ? String(o.romanized) : undefined,
+ box: (Array.isArray(o.box) && o.box.length === 4
+ ? (o.box.map(Number) as [number, number, number, number])
+ : [0, 0, 1, 1]) as [number, number, number, number],
+ }))
+ .filter((o) => o.labelEn && o.labelTarget)
+ : [];
+
+ const sentences: VisionSentence[] = Array.isArray(parsed.sentences)
+ ? parsed.sentences
+ .filter((s: unknown): s is Record => typeof s === "object" && s !== null)
+ .map((s) => ({
+ target: String(s.target ?? ""),
+ targetSegments: parseSegments(s.targetSegments),
+ gloss: String(s.gloss ?? ""),
+ romanized: s.romanized ? String(s.romanized) : undefined,
+ }))
+ .filter((s) => s.target && s.gloss)
+ : [];
+
+ return {
+ objects,
+ sentences,
+ caption: String(parsed.caption ?? ""),
+ };
+}
diff --git a/lib/hf/inference.ts b/lib/hf/inference.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7d8ab1955131b93dbb8c84c821e9c9b71a883d6e
--- /dev/null
+++ b/lib/hf/inference.ts
@@ -0,0 +1,95 @@
+import { languageName, type Level } from "@/lib/languages";
+import { extractJson } from "@/lib/hf/json";
+import { createInferenceClient } from "@/lib/hf/client";
+import { HF_PROVIDER, TEXT_MODEL, TRANSLATE_MODEL } from "@/lib/hf/models";
+
+export type StoryVocab = { word: string; gloss: string };
+
+const LEVEL_BRIEFS: Record = {
+ A1: "very simple, present tense, short sentences, everyday vocabulary, 60-90 words.",
+ A2: "simple past/present, basic connectives, short paragraphs, 90-140 words.",
+ B1: "mixed tenses, modest idiom, a small plot or reflection, 140-220 words.",
+ B2: "varied tenses, some idiom, nuanced description, 220-320 words.",
+ C1: "rich vocabulary, complex tense sequencing, sophisticated prose, 280-400 words.",
+};
+
+export async function generateStory(opts: {
+ targetLang: string;
+ nativeLang: string;
+ level: Level;
+ topic?: string;
+ accessToken?: string;
+}): Promise<{ title: string; content: string; vocab: StoryVocab[] }> {
+ const lang = languageName(opts.targetLang);
+ const native = languageName(opts.nativeLang);
+ const brief = LEVEL_BRIEFS[opts.level];
+ const topic = opts.topic?.trim() || "an everyday scene";
+ const isJapanese = opts.targetLang === "ja";
+ const japaneseRule = isJapanese
+ ? ` JAPANESE-SPECIFIC: every kanji compound in "title", "content", and "word" must be followed immediately by its hiragana reading in full-width parentheses, e.g. 漢字(かんじ), 食(た)べる. Apply this consistently throughout the prose.`
+ : "";
+ const system =
+ `You are a language-learning story writer. Produce a short story in ${lang} at CEFR ${opts.level}. ` +
+ `Constraints: ${brief} Keep it engaging and coherent. Avoid offensive content. ` +
+ `LOCK every sentence to CEFR ${opts.level}: vocabulary, grammar tense complexity, and idiom load must all match. A real ${opts.level} learner should follow the story without reaching for a dictionary on every line. ` +
+ `Output STRICT JSON with keys: title (string in ${lang}), content (string in ${lang} - paragraphs separated by \\n\\n), vocab (array of 6-10 objects each with "word" in ${lang} and "gloss" in ${native}).${japaneseRule}`;
+ const user = `Write a story about: ${topic}.`;
+
+ const hf = createInferenceClient(opts.accessToken);
+ const res = await hf.chatCompletion({
+ provider: HF_PROVIDER,
+ model: TEXT_MODEL,
+ messages: [
+ { role: "system", content: system },
+ { role: "user", content: user },
+ ],
+ max_tokens: 900,
+ temperature: 0.7,
+ response_format: { type: "json_object" },
+ });
+
+ const raw = res.choices?.[0]?.message?.content;
+ if (!raw) throw new Error("empty story response");
+ const parsed = extractJson(raw);
+ return {
+ title: String(parsed.title ?? "Untitled"),
+ content: String(parsed.content ?? ""),
+ vocab: Array.isArray(parsed.vocab)
+ ? parsed.vocab
+ .filter((v: unknown): v is { word: unknown; gloss: unknown } => typeof v === "object" && v !== null)
+ .map((v) => ({ word: String(v.word ?? ""), gloss: String(v.gloss ?? "") }))
+ .filter((v) => v.word && v.gloss)
+ : [],
+ };
+}
+
+export async function translate(opts: {
+ text: string;
+ from: string;
+ to: string;
+ accessToken?: string;
+}): Promise {
+ const text = opts.text.trim();
+ if (!text) return "";
+ const hf = createInferenceClient(opts.accessToken);
+ const res = await hf.chatCompletion({
+ provider: HF_PROVIDER,
+ model: TRANSLATE_MODEL,
+ messages: [
+ {
+ role: "system",
+ content:
+ `You are a translator. Translate the user's text from ${languageName(opts.from)} to ${languageName(opts.to)}. ` +
+ `For a single word, give the most common dictionary translation. ` +
+ `Output STRICT JSON: { "translation": "the translated text" } — nothing else, no explanations.`,
+ },
+ { role: "user", content: text },
+ ],
+ max_tokens: 300,
+ temperature: 0,
+ response_format: { type: "json_object" },
+ });
+ const raw = res.choices?.[0]?.message?.content;
+ if (!raw) throw new Error("empty translation response");
+ return String(extractJson(raw).translation ?? "");
+}
diff --git a/lib/hf/json.ts b/lib/hf/json.ts
new file mode 100644
index 0000000000000000000000000000000000000000..48b302eed772920519f2c4a0088e715ac26c2750
--- /dev/null
+++ b/lib/hf/json.ts
@@ -0,0 +1,59 @@
+// Shared JSON extractor for HuggingFace chatCompletion responses.
+// Handles common Gemma malformations: trailing commas, unterminated strings,
+// unbalanced braces/brackets near the tail of the response.
+
+export function extractJson(raw: string): Record {
+ const trimmed = raw.trim();
+ const candidates = [trimmed];
+ const match = trimmed.match(/\{[\s\S]*\}/);
+ if (match) candidates.push(match[0]);
+
+ for (const candidate of candidates) {
+ try {
+ return JSON.parse(candidate);
+ } catch {
+ // try repaired version
+ }
+ try {
+ return JSON.parse(repairJson(candidate));
+ } catch {
+ // continue
+ }
+ }
+ throw new Error("could not parse JSON response");
+}
+
+function repairJson(input: string): string {
+ let s = input.trim();
+ s = s.replace(/,\s*([}\]])/g, "$1");
+
+ let inString = false;
+ let escape = false;
+ const stack: string[] = [];
+ for (let i = 0; i < s.length; i++) {
+ const ch = s[i];
+ if (escape) {
+ escape = false;
+ continue;
+ }
+ if (ch === "\\" && inString) {
+ escape = true;
+ continue;
+ }
+ if (ch === '"') {
+ inString = !inString;
+ continue;
+ }
+ if (inString) continue;
+ if (ch === "{" || ch === "[") stack.push(ch);
+ else if (ch === "}" && stack[stack.length - 1] === "{") stack.pop();
+ else if (ch === "]" && stack[stack.length - 1] === "[") stack.pop();
+ }
+ if (inString) s += '"';
+ s = s.replace(/,\s*$/, "");
+ while (stack.length) {
+ const open = stack.pop();
+ s += open === "{" ? "}" : "]";
+ }
+ return s;
+}
diff --git a/lib/hf/models.ts b/lib/hf/models.ts
new file mode 100644
index 0000000000000000000000000000000000000000..34755dadb79b7e75b3b829f9800e946c801d9812
--- /dev/null
+++ b/lib/hf/models.ts
@@ -0,0 +1,35 @@
+// Central model + provider selection for all HF inference calls.
+//
+// Speed strategy: the app previously ran EVERY task on google/gemma-4-31B-it
+// (dense 32.7B) through the deepinfra router — slow at token generation.
+//
+// We move to Qwen/Qwen3.5-35B-A3B: the current Qwen3.5 generation (Apr 2026),
+// natively multimodal (image-text-to-text), a Mixture-of-Experts with only
+// ~3B ACTIVE parameters per token (35.9B total capacity). Generation speed is
+// in the 3B range while quality is backed by a large model. It's live on the
+// same deepinfra provider, so no auth/billing change (still uses the signed-in
+// user's HF OAuth token), and being multimodal it serves BOTH vision marking
+// and text from a single model/provider.
+//
+// Alternatives verified live on the router if you want to trade off:
+// Qwen/Qwen3.5-35B-A3B MoE ~3B active, multimodal, newest (deepinfra, novita) — default
+// google/gemma-4-26B-A4B-it MoE ~4B active, multimodal (deepinfra, novita)
+// Qwen/Qwen3-VL-30B-A3B-Instruct dedicated-VL grounding (novita only)
+// google/gemma-4-31B-it dense 32.7B, highest quality, slow (deepinfra) — the old default
+
+export const HF_PROVIDER = "deepinfra" as const;
+
+/** Newest multimodal MoE (~3B active) — one model for vision + text. */
+const QWEN35 = "Qwen/Qwen3.5-35B-A3B";
+
+/** Vision marking (object detection + bounding boxes + captions). */
+export const VISION_MODEL = QWEN35;
+
+/** Creative/structured text (stories, dialogues, comprehension quizzes). */
+export const TEXT_MODEL = QWEN35;
+
+/** Dense linguistic analysis (phrase breakdown, kanji readings, related words). */
+export const ANALYSIS_MODEL = QWEN35;
+
+/** Word/phrase translation (story reader word taps). */
+export const TRANSLATE_MODEL = QWEN35;
diff --git a/lib/hf/phrases.ts b/lib/hf/phrases.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a10815efafa6c132c95ecee05243ae9d4db4156e
--- /dev/null
+++ b/lib/hf/phrases.ts
@@ -0,0 +1,186 @@
+import { languageName, type Level } from "@/lib/languages";
+import { extractJson } from "@/lib/hf/json";
+import { parseSegments } from "@/lib/hf/segments";
+import { createInferenceClient } from "@/lib/hf/client";
+import { ANALYSIS_MODEL, HF_PROVIDER } from "@/lib/hf/models";
+
+export type PhraseSentence = {
+ target: string;
+ targetSegments?: FuriSegment[];
+ translation: string;
+ note: string;
+};
+
+export type PhraseBreakdown = {
+ part: string;
+ explanation: string;
+};
+
+import type { FuriSegment } from "@/lib/types/dialogue";
+
+export type RelatedWord = {
+ word: string;
+ wordSegments?: FuriSegment[];
+ translation: string;
+ partOfSpeech: string;
+ note?: string;
+};
+
+export type KanjiInfo = {
+ kanji: string;
+ onyomi: string[];
+ kunyomi: string[];
+ meaning: string;
+ exampleWord?: string;
+ exampleWordReading?: string;
+};
+
+export type PhraseAnalysis = {
+ input: string;
+ inputSegments?: FuriSegment[];
+ translation: string;
+ partOfSpeech: string;
+ verbInfo?: string;
+ breakdown: PhraseBreakdown[];
+ sentences: PhraseSentence[];
+ tips: string[];
+ relatedWords: RelatedWord[];
+ kanjiInfo: KanjiInfo[];
+};
+
+export async function analyzePhrase(opts: {
+ text: string;
+ targetLang: string;
+ nativeLang: string;
+ level: Level;
+ accessToken?: string;
+}): Promise {
+ const hf = createInferenceClient(opts.accessToken);
+ const lang = languageName(opts.targetLang);
+ const native = languageName(opts.nativeLang);
+ const phrase = opts.text.trim();
+
+ const isJapanese = opts.targetLang === "ja";
+
+ const japaneseRules = isJapanese
+ ? `
+
+JAPANESE-SPECIFIC RULES (target language is Japanese):
+- For EACH Japanese string field listed below, ALSO emit a *Segments array beside it. Each segment is { "text": , "reading": }. Concatenating every "text" in order MUST exactly reproduce the original string. For pure-kana segments, OMIT the "reading" field. Examples:
+ • "私は猫が好きです" → segments: [{"text":"私","reading":"わたし"},{"text":"は"},{"text":"猫","reading":"ねこ"},{"text":"が"},{"text":"好","reading":"す"},{"text":"きです"}]
+ • "東京駅" → [{"text":"東京駅","reading":"とうきょうえき"}]
+ • "ありがとう" → [{"text":"ありがとう"}]
+ Emit *Segments for: inputSegments, every sentences[i].targetSegments, every relatedWords[i].wordSegments. Do NOT add furigana parens inside the plain "input" / "target" / "word" fields — keep those as the original Japanese string only. Furigana lives ONLY inside the segment objects.
+- Populate "kanjiInfo" with ONE entry per UNIQUE kanji character that appears in the input. For each kanji include:
+ - "kanji": the single kanji character
+ - "onyomi": array of common on'yomi readings written in KATAKANA (e.g. ["カン"])
+ - "kunyomi": array of common kun'yomi readings written in HIRAGANA, with okurigana shown after a dot if relevant (e.g. ["やま", "た.べる"])
+ - "meaning": short ${native} meaning of the kanji on its own
+ - "exampleWord": one short common Japanese word that uses this kanji, KANJI ONLY (no parens)
+ - "exampleWordReading": the hiragana reading of exampleWord
+- If the input has NO kanji (pure kana or romaji), return "kanjiInfo": [].`
+ : `\n- Return "kanjiInfo": [] and do not emit any *Segments fields.`;
+
+ const system = `You are a careful language tutor for ${lang}. Explain one learner phrase or word at CEFR ${opts.level}.
+
+Output STRICT JSON:
+{
+ "input": "the original phrase",
+ "inputSegments": [ /* see Japanese-specific rules; omit if not Japanese */ ],
+ "translation": "meaning in ${native}",
+ "partOfSpeech": "noun / verb / adjective / phrase / expression / mixed",
+ "verbInfo": "if there is a verb, explain infinitive, tense, subject agreement, and common forms in ${native}; otherwise empty string",
+ "breakdown": [
+ { "part": "word or morpheme", "explanation": "plain ${native} explanation" }
+ ],
+ "sentences": [
+ { "target": "${lang} sentence using the phrase", "targetSegments": [ /* JP only */ ], "translation": "${native} meaning", "note": "why this usage works" }
+ ],
+ "tips": ["short practical usage tip"],
+ "relatedWords": [
+ { "word": "${lang} word from the same topic or word family", "wordSegments": [ /* JP only */ ], "translation": "${native} meaning", "partOfSpeech": "noun/verb/adjective/...", "note": "short ${native} note about how it relates" }
+ ],
+ "kanjiInfo": [
+ { "kanji": "単", "onyomi": ["タン"], "kunyomi": ["ひと"], "meaning": "${native} meaning", "exampleWord": "簡単", "exampleWordReading": "かんたん" }
+ ]
+}
+
+Rules:
+- Create 5 example sentences.
+- LOCK every example sentence, related word, and explanation to CEFR ${opts.level}. Vocabulary, grammar, sentence length, and idiomaticity must all match this level — not above, not below. If a related word would force a learner above ${opts.level}, swap it for one at-level. Picture a real ${opts.level} learner reading this and ask: would they understand each line on first read?
+- If the input is already in ${native}, provide natural ${lang} equivalents.
+- If the input is in ${lang}, explain it in ${native}.
+- Include gender, register, collocations, and verb behavior when relevant.
+- "relatedWords": list 6–8 ${lang} words from the SAME topic or semantic field as the input — synonyms, antonyms, common collocations, or other vocabulary a learner would naturally meet alongside the input. Each word must be a single word or tight collocation, not a sentence.
+- Do not include markdown.${japaneseRules}`;
+
+ const res = await hf.chatCompletion({
+ provider: HF_PROVIDER,
+ model: ANALYSIS_MODEL,
+ messages: [
+ { role: "system", content: system },
+ { role: "user", content: `Analyze this phrase or word: ${phrase}` },
+ ],
+ max_tokens: 1800,
+ temperature: 0.35,
+ response_format: { type: "json_object" },
+ });
+
+ const raw = res.choices?.[0]?.message?.content;
+ if (!raw) throw new Error("empty phrase response");
+ const parsed = extractJson(raw);
+
+ return {
+ input: String(parsed.input ?? phrase),
+ inputSegments: parseSegments(parsed.inputSegments),
+ translation: String(parsed.translation ?? ""),
+ partOfSpeech: String(parsed.partOfSpeech ?? "phrase"),
+ verbInfo: parsed.verbInfo ? String(parsed.verbInfo) : undefined,
+ breakdown: Array.isArray(parsed.breakdown)
+ ? parsed.breakdown
+ .filter((item: unknown): item is Record => typeof item === "object" && item !== null)
+ .map((item) => ({
+ part: String(item.part ?? ""),
+ explanation: String(item.explanation ?? ""),
+ }))
+ .filter((item) => item.part || item.explanation)
+ : [],
+ sentences: Array.isArray(parsed.sentences)
+ ? parsed.sentences
+ .filter((item: unknown): item is Record => typeof item === "object" && item !== null)
+ .map((item) => ({
+ target: String(item.target ?? ""),
+ targetSegments: parseSegments(item.targetSegments),
+ translation: String(item.translation ?? ""),
+ note: String(item.note ?? ""),
+ }))
+ .filter((item) => item.target && item.translation)
+ : [],
+ tips: Array.isArray(parsed.tips) ? parsed.tips.map(String).filter(Boolean) : [],
+ relatedWords: Array.isArray(parsed.relatedWords)
+ ? parsed.relatedWords
+ .filter((item: unknown): item is Record => typeof item === "object" && item !== null)
+ .map((item) => ({
+ word: String(item.word ?? ""),
+ wordSegments: parseSegments(item.wordSegments),
+ translation: String(item.translation ?? ""),
+ partOfSpeech: String(item.partOfSpeech ?? ""),
+ note: item.note ? String(item.note) : undefined,
+ }))
+ .filter((item) => item.word && item.translation)
+ : [],
+ kanjiInfo: Array.isArray(parsed.kanjiInfo)
+ ? parsed.kanjiInfo
+ .filter((item: unknown): item is Record => typeof item === "object" && item !== null)
+ .map((item) => ({
+ kanji: String(item.kanji ?? ""),
+ onyomi: Array.isArray(item.onyomi) ? item.onyomi.map(String).filter(Boolean) : [],
+ kunyomi: Array.isArray(item.kunyomi) ? item.kunyomi.map(String).filter(Boolean) : [],
+ meaning: String(item.meaning ?? ""),
+ exampleWord: item.exampleWord ? String(item.exampleWord) : undefined,
+ exampleWordReading: item.exampleWordReading ? String(item.exampleWordReading) : undefined,
+ }))
+ .filter((item) => item.kanji && (item.onyomi.length || item.kunyomi.length))
+ : [],
+ };
+}
diff --git a/lib/hf/segments.ts b/lib/hf/segments.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ca10a126829ec929e3d73dac9cf05d0c45078d02
--- /dev/null
+++ b/lib/hf/segments.ts
@@ -0,0 +1,16 @@
+import type { FuriSegment } from "@/lib/types/dialogue";
+
+// Parses a model-emitted *Segments array (see the JAPANESE-SPECIFIC prompt
+// rules) into FuriSegment[]. Returns undefined when the payload is missing or
+// yields no usable segments so callers can fall back to the plain string.
+export function parseSegments(raw: unknown): FuriSegment[] | undefined {
+ if (!Array.isArray(raw)) return undefined;
+ const segments = raw
+ .filter((item): item is Record => typeof item === "object" && item !== null)
+ .map((item) => ({
+ text: String(item.text ?? ""),
+ reading: item.reading ? String(item.reading) : undefined,
+ }))
+ .filter((seg) => seg.text);
+ return segments.length ? segments : undefined;
+}
diff --git a/lib/hf/sentences.ts b/lib/hf/sentences.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d0a912472147d8cf660afba1b0e480ec609d2159
--- /dev/null
+++ b/lib/hf/sentences.ts
@@ -0,0 +1,53 @@
+import { languageName, type Level } from "@/lib/languages";
+import { createInferenceClient } from "@/lib/hf/client";
+
+const MODEL = "mistralai/Mistral-7B-Instruct-v0.3";
+
+export type ExampleSentence = { target: string; gloss: string };
+
+export async function generateSentencesForImage(opts: {
+ captionEn: string;
+ objectsEn: string[];
+ targetLang: string;
+ nativeLang: string;
+ level: Level;
+ accessToken?: string;
+}): Promise {
+ const target = languageName(opts.targetLang);
+ const native = languageName(opts.nativeLang);
+ const system =
+ `You are a language tutor. Given a photo caption and list of visible objects, produce example sentences in ${target} ` +
+ `at CEFR ${opts.level} level describing what's in the image. Each sentence must come with a natural ${native} gloss. ` +
+ `Output STRICT JSON: {"sentences": [{"target": string, "gloss": string}, ...]}.`;
+ const user = `Caption: ${opts.captionEn}\nObjects: ${opts.objectsEn.join(", ") || "(none)"}\n`;
+
+ const hf = createInferenceClient(opts.accessToken);
+ const res = await hf.chatCompletion({
+ provider: "novita",
+ model: MODEL,
+ messages: [
+ { role: "system", content: system },
+ { role: "user", content: user },
+ ],
+ max_tokens: 400,
+ temperature: 0.6,
+ response_format: { type: "json_object" },
+ });
+ const raw = res.choices?.[0]?.message?.content;
+ if (!raw) return [];
+ const parsed = safeParse(raw);
+ const arr = Array.isArray(parsed.sentences) ? parsed.sentences : [];
+ return arr
+ .filter((v: unknown): v is { target: unknown; gloss: unknown } => typeof v === "object" && v !== null)
+ .map((v) => ({ target: String(v.target ?? ""), gloss: String(v.gloss ?? "") }))
+ .filter((v) => v.target);
+}
+
+function safeParse(raw: string): Record {
+ try {
+ return JSON.parse(raw);
+ } catch {
+ const m = raw.match(/\{[\s\S]*\}/);
+ return m ? (JSON.parse(m[0]) as Record) : {};
+ }
+}
diff --git a/lib/languages.ts b/lib/languages.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ab5fbe6a23d66350b2e6d3765629a693638f7ab7
--- /dev/null
+++ b/lib/languages.ts
@@ -0,0 +1,59 @@
+export const LANGUAGES = [
+ { code: "en", name: "English", flag: "US" },
+ { code: "es", name: "Spanish", flag: "ES" },
+ { code: "fr", name: "French", flag: "FR" },
+ { code: "de", name: "German", flag: "DE" },
+ { code: "it", name: "Italian", flag: "IT" },
+ { code: "pt", name: "Portuguese", flag: "BR" },
+ { code: "ja", name: "Japanese", flag: "JP" },
+ { code: "ko", name: "Korean", flag: "KR" },
+ { code: "zh", name: "Mandarin", flag: "CN" },
+ { code: "yue", name: "Cantonese", flag: "HK" },
+ { code: "ar", name: "Arabic", flag: "SA" },
+ { code: "hi", name: "Hindi", flag: "IN" },
+ { code: "kn", name: "Kannada", flag: "IN" },
+ { code: "ml", name: "Malayalam", flag: "IN" },
+ { code: "vi", name: "Vietnamese", flag: "VN" },
+ { code: "ru", name: "Russian", flag: "RU" },
+ { code: "tr", name: "Turkish", flag: "TR" },
+ { code: "pl", name: "Polish", flag: "PL" },
+ { code: "nl", name: "Dutch", flag: "NL" },
+ { code: "sv", name: "Swedish", flag: "SE" },
+ { code: "el", name: "Greek", flag: "GR" },
+ { code: "th", name: "Thai", flag: "TH" },
+ { code: "id", name: "Indonesian", flag: "ID" },
+ { code: "bn", name: "Bengali", flag: "BD" },
+ { code: "ur", name: "Urdu", flag: "PK" },
+ { code: "sw", name: "Swahili", flag: "KE" },
+ { code: "he", name: "Hebrew", flag: "IL" },
+ { code: "fa", name: "Persian", flag: "IR" },
+ { code: "uk", name: "Ukrainian", flag: "UA" },
+ { code: "ro", name: "Romanian", flag: "RO" },
+] as const;
+
+export type LanguageCode = (typeof LANGUAGES)[number]["code"];
+
+export const LEVELS = ["A1", "A2", "B1", "B2", "C1"] as const;
+export type Level = (typeof LEVELS)[number];
+
+export function languageName(code: string): string {
+ return LANGUAGES.find((language) => language.code === code)?.name ?? code;
+}
+
+export function languageFlagCode(code: string): string | null {
+ return LANGUAGES.find((l) => l.code === code)?.flag ?? null;
+}
+
+export function flagEmoji(countryCode: string): string {
+ return [...countryCode.toUpperCase()]
+ .map((c) => String.fromCodePoint(c.charCodeAt(0) + 127397))
+ .join("");
+}
+
+export const LEVEL_DESCRIPTIONS: Record = {
+ A1: "Just starting with survival phrases.",
+ A2: "Basic everyday topics.",
+ B1: "Intermediate conversations.",
+ B2: "Fluent on most topics.",
+ C1: "Advanced, nuanced use.",
+};
diff --git a/lib/tts.ts b/lib/tts.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0a3322c926f77c4eb149dfeaaf5736183f7b7db8
--- /dev/null
+++ b/lib/tts.ts
@@ -0,0 +1,36 @@
+const LANG_MAP: Record = {
+ en: "en-US",
+ es: "es-ES",
+ fr: "fr-FR",
+ de: "de-DE",
+ it: "it-IT",
+ pt: "pt-BR",
+ ja: "ja-JP",
+ ko: "ko-KR",
+ zh: "zh-CN",
+ yue: "zh-HK",
+ ar: "ar-SA",
+ hi: "hi-IN",
+ kn: "kn-IN",
+ ml: "ml-IN",
+ vi: "vi-VN",
+ ru: "ru-RU",
+ tr: "tr-TR",
+ pl: "pl-PL",
+ nl: "nl-NL",
+ sv: "sv-SE",
+ el: "el-GR",
+ th: "th-TH",
+ id: "id-ID",
+ bn: "bn-BD",
+ ur: "ur-PK",
+ sw: "sw-KE",
+ he: "he-IL",
+ fa: "fa-IR",
+ uk: "uk-UA",
+ ro: "ro-RO",
+};
+
+export function ttsLang(code: string): string {
+ return LANG_MAP[code] ?? code;
+}
diff --git a/lib/types/dialogue.ts b/lib/types/dialogue.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9ebf77502f2b83f969b79572bf62e0d9c6a6b56e
--- /dev/null
+++ b/lib/types/dialogue.ts
@@ -0,0 +1,41 @@
+// One segment of a Japanese string. `reading` is the hiragana for any kanji
+// portion; omit it (or leave empty) for kana-only segments. Concatenating each
+// `text` reproduces the original string exactly.
+export type FuriSegment = { text: string; reading?: string };
+
+export type DialogueOption = {
+ text: string;
+ textSegments?: FuriSegment[];
+ isCorrect: boolean;
+ feedback: string;
+};
+
+export type DialogueTurn = {
+ type: "narration" | "character" | "user_choice";
+ text: string;
+ textSegments?: FuriSegment[];
+ translation?: string;
+ speakerName?: string;
+ options?: DialogueOption[];
+};
+
+export type Dialogue = {
+ id: string;
+ title: string;
+ scenario: string;
+ level: string;
+ targetLang: string;
+ nativeLang: string;
+ turns: DialogueTurn[];
+};
+
+export type Story = {
+ id: string;
+ title: string;
+ content: string;
+ vocab: Array<{ word: string; gloss: string }>;
+ targetLang: string;
+ nativeLang: string;
+ level: string;
+};
+
diff --git a/lib/utils.ts b/lib/utils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bd0c391ddd1088e9067844c48835bf4abcd61783
--- /dev/null
+++ b/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/lib/utils/shuffle.ts b/lib/utils/shuffle.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5fb6d086797fec76c2c9f61549af2aae954d68cb
--- /dev/null
+++ b/lib/utils/shuffle.ts
@@ -0,0 +1,9 @@
+// Fisher–Yates shuffle. Returns a new array; doesn't mutate the input.
+export function shuffle(input: readonly T[]): T[] {
+ const out = [...input];
+ for (let i = out.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [out[i], out[j]] = [out[j], out[i]];
+ }
+ return out;
+}
diff --git a/logo.png b/logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..26711a9b46467e0669f1eef520fe56de3458db7e
--- /dev/null
+++ b/logo.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3582419addd9425cc282fd5ce4153f5eb4216d1c04ab8e7f4d91b04fdebeb7df
+size 2432742
diff --git a/mobile/.gitignore b/mobile/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..6f0d0064ee471801b686548b5c1339b4628a82e6
--- /dev/null
+++ b/mobile/.gitignore
@@ -0,0 +1,45 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.build/
+.buildlog/
+.history
+.svn/
+.swiftpm/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins-dependencies
+.pub-cache/
+.pub/
+/build/
+/coverage/
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release
diff --git a/mobile/.metadata b/mobile/.metadata
new file mode 100644
index 0000000000000000000000000000000000000000..64fdf4ef7a5a8910d8da46d0078c77ebe8ff9339
--- /dev/null
+++ b/mobile/.metadata
@@ -0,0 +1,33 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+ base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+ - platform: android
+ create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+ base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+ - platform: ios
+ create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+ base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/mobile/README.md b/mobile/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..1dcefcd5691855f7a570f1638755f716482878cd
--- /dev/null
+++ b/mobile/README.md
@@ -0,0 +1,17 @@
+# langlearn
+
+A new Flutter project.
+
+## Getting Started
+
+This project is a starting point for a Flutter application.
+
+A few resources to get you started if this is your first Flutter project:
+
+- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
+- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
+- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
+
+For help getting started with Flutter development, view the
+[online documentation](https://docs.flutter.dev/), which offers tutorials,
+samples, guidance on mobile development, and a full API reference.
diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..d4e0f0c8bff70b511095207bf2c5fc9d696075f0
--- /dev/null
+++ b/mobile/analysis_options.yaml
@@ -0,0 +1,28 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+linter:
+ # The lint rules applied to this project can be customized in the
+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+ # included above or to enable additional rules. A list of all available lints
+ # and their documentation is published at https://dart.dev/lints.
+ #
+ # Instead of disabling a lint rule for the entire project in the
+ # section below, it can also be suppressed for a single line of code
+ # or a specific dart file by using the `// ignore: name_of_lint` and
+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+ # producing the lint.
+ rules:
+ # avoid_print: false # Uncomment to disable the `avoid_print` rule
+ # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..c9082585a535b3dee4052706248ac3782322b50a
--- /dev/null
+++ b/mobile/android/.gitignore
@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts
new file mode 100644
index 0000000000000000000000000000000000000000..353c3e94095a82a7334587f75eab50e680bfc063
--- /dev/null
+++ b/mobile/android/app/build.gradle.kts
@@ -0,0 +1,65 @@
+import java.util.Properties
+
+plugins {
+ id("com.android.application")
+ id("kotlin-android")
+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ id("dev.flutter.flutter-gradle-plugin")
+}
+
+val keystoreProperties = Properties()
+val keystorePropertiesFile = rootProject.file("key.properties")
+val hasReleaseKeystore = keystorePropertiesFile.exists()
+
+if (hasReleaseKeystore) {
+ keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
+}
+
+android {
+ namespace = "com.langlearn.langlearn"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+
+ defaultConfig {
+ // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+ applicationId = "com.langlearn.langlearn"
+ // You can update the following values to match your application needs.
+ // For more information, see: https://flutter.dev/to/review-gradle-config.
+ minSdk = flutter.minSdkVersion
+ targetSdk = flutter.targetSdkVersion
+ versionCode = flutter.versionCode
+ versionName = flutter.versionName
+ }
+
+ signingConfigs {
+ if (hasReleaseKeystore) {
+ create("release") {
+ keyAlias = keystoreProperties["keyAlias"] as String
+ keyPassword = keystoreProperties["keyPassword"] as String
+ storeFile = file(keystoreProperties["storeFile"] as String)
+ storePassword = keystoreProperties["storePassword"] as String
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ signingConfig =
+ if (hasReleaseKeystore) signingConfigs.getByName("release")
+ else signingConfigs.getByName("debug")
+ }
+ }
+}
+
+flutter {
+ source = "../.."
+}
diff --git a/mobile/android/app/src/debug/AndroidManifest.xml b/mobile/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000000000000000000000000000000000000..8ffe024642253939383a59fd6357dda4b34cb53b
--- /dev/null
+++ b/mobile/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c1220b1b4ab4e035ab0234cd08796b1e5d8b753b
--- /dev/null
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/kotlin/com/langlearn/langlearn/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/langlearn/langlearn/MainActivity.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f6fe471030dfbcbea02ebaf3cf424b94c66c538b
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/com/langlearn/langlearn/MainActivity.kt
@@ -0,0 +1,5 @@
+package com.langlearn.langlearn
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity : FlutterActivity()
diff --git a/mobile/android/app/src/main/res/drawable-v21/launch_background.xml b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000000000000000000000000000000000000..1cb7aa2f6e8c2438e50149b6ec567b515c243c88
--- /dev/null
+++ b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/res/drawable/launch_background.xml b/mobile/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000000000000000000000000000000000000..84037589b06bb17b24ba7acffee949159cd99a16
--- /dev/null
+++ b/mobile/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000000000000000000000000000000000000..a977833f561ebf3332577e74e0c36873d7d42dbf
Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000000000000000000000000000000000000..59bb014e15ed537e444bf0cf1d098585293ee779
Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000000000000000000000000000000000000..4293969e2f26114ea3b1c054610a051bb984137d
Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000000000000000000000000000000000..f763d0f24c6ff70c1c043f38ab37b93cb23ac4d1
Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000000000000000000000000000000000..3aebdb046ddcde40c2b8f4e0422034df8b996ff0
Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/mobile/android/app/src/main/res/values-night/styles.xml b/mobile/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000000000000000000000000000000000000..360a1605cd6d544ad23ddd7515862ff2c9df6ab8
--- /dev/null
+++ b/mobile/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5fac679616bbc0255412f37eea3eae0a3716e7d7
--- /dev/null
+++ b/mobile/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/profile/AndroidManifest.xml b/mobile/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000000000000000000000000000000000000..8ffe024642253939383a59fd6357dda4b34cb53b
--- /dev/null
+++ b/mobile/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/mobile/android/build.gradle.kts b/mobile/android/build.gradle.kts
new file mode 100644
index 0000000000000000000000000000000000000000..1f88145b6b91073cc0a28f44e1df8c8437669a02
--- /dev/null
+++ b/mobile/android/build.gradle.kts
@@ -0,0 +1,24 @@
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+val newBuildDir: Directory =
+ rootProject.layout.buildDirectory
+ .dir("../../build")
+ .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties
new file mode 100644
index 0000000000000000000000000000000000000000..83aa7b13906951170e30230874234522282565f0
--- /dev/null
+++ b/mobile/android/gradle.properties
@@ -0,0 +1,8 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
+
+# Kotlin incremental compilation cannot compute relocatable paths when project
+# and pub cache live on different Windows drives (E:\ vs C:\Users\...\Pub\Cache).
+# Disabling avoids the "different roots" cache-close errors and the silent
+# fallback to non-incremental compilation.
+kotlin.incremental=false
diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000000000000000000000000000000000000..db3f45316544d29546c0ce7b5419bc079fe7c87d
--- /dev/null
+++ b/mobile/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
diff --git a/mobile/android/settings.gradle.kts b/mobile/android/settings.gradle.kts
new file mode 100644
index 0000000000000000000000000000000000000000..4dcef4bf1b67b397d33946b7b7f1e0a46514e582
--- /dev/null
+++ b/mobile/android/settings.gradle.kts
@@ -0,0 +1,26 @@
+pluginManagement {
+ val flutterSdkPath =
+ run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "8.11.1" apply false
+ id("org.jetbrains.kotlin.android") version "2.2.20" apply false
+}
+
+include(":app")
diff --git a/mobile/assets/logo.png b/mobile/assets/logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..26711a9b46467e0669f1eef520fe56de3458db7e
--- /dev/null
+++ b/mobile/assets/logo.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3582419addd9425cc282fd5ce4153f5eb4216d1c04ab8e7f4d91b04fdebeb7df
+size 2432742
diff --git a/mobile/ios/.gitignore b/mobile/ios/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..ad322bc09d2bbd018667440c592d2a42d1ef6805
--- /dev/null
+++ b/mobile/ios/.gitignore
@@ -0,0 +1,34 @@
+**/dgph
+*.mode1v3
+*.mode2v3
+*.moved-aside
+*.pbxuser
+*.perspectivev3
+**/*sync/
+.sconsign.dblite
+.tags*
+**/.vagrant/
+**/DerivedData/
+Icon?
+**/Pods/
+**/.symlinks/
+profile
+xcuserdata
+**/.generated/
+Flutter/App.framework
+Flutter/Flutter.framework
+Flutter/Flutter.podspec
+Flutter/Generated.xcconfig
+Flutter/ephemeral/
+Flutter/app.flx
+Flutter/app.zip
+Flutter/flutter_assets/
+Flutter/flutter_export_environment.sh
+ServiceDefinitions.json
+Runner/GeneratedPluginRegistrant.*
+
+# Exceptions to above rules.
+!default.mode1v3
+!default.mode2v3
+!default.pbxuser
+!default.perspectivev3
diff --git a/mobile/ios/Flutter/AppFrameworkInfo.plist b/mobile/ios/Flutter/AppFrameworkInfo.plist
new file mode 100644
index 0000000000000000000000000000000000000000..256cf28a09f6a7e8a6a9f586d60fb4e12bf954bd
--- /dev/null
+++ b/mobile/ios/Flutter/AppFrameworkInfo.plist
@@ -0,0 +1,24 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ App
+ CFBundleIdentifier
+ io.flutter.flutter.app
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ App
+ CFBundlePackageType
+ FMWK
+ CFBundleShortVersionString
+ 1.0
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ 1.0
+
+
diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig
new file mode 100644
index 0000000000000000000000000000000000000000..0b2d479c08504549ca6e74f0743bd584f068f9bd
--- /dev/null
+++ b/mobile/ios/Flutter/Debug.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig
new file mode 100644
index 0000000000000000000000000000000000000000..0b2d479c08504549ca6e74f0743bd584f068f9bd
--- /dev/null
+++ b/mobile/ios/Flutter/Release.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/mobile/ios/Podfile b/mobile/ios/Podfile
new file mode 100644
index 0000000000000000000000000000000000000000..6a365c3e30c9f61c7a3489e74a7a6b9d0a24a08e
--- /dev/null
+++ b/mobile/ios/Podfile
@@ -0,0 +1,49 @@
+# Uncomment this line to define a global platform for your project
+platform :ios, '13.0'
+
+# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
+ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+
+project 'Runner', {
+ 'Debug' => :debug,
+ 'Profile' => :release,
+ 'Release' => :release,
+}
+
+def flutter_root
+ generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
+ unless File.exist?(generated_xcode_build_settings_path)
+ raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
+ end
+
+ File.foreach(generated_xcode_build_settings_path) do |line|
+ matches = line.match(/FLUTTER_ROOT\=(.*)/)
+ return matches[1].strip if matches
+ end
+ raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
+end
+
+require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
+
+flutter_ios_podfile_setup
+
+target 'Runner' do
+ use_frameworks!
+
+ flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
+
+ target 'RunnerTests' do
+ inherit! :search_paths
+ end
+end
+
+post_install do |installer|
+ installer.pods_project.targets.each do |target|
+ flutter_additional_ios_build_settings(target)
+ target.build_configurations.each do |config|
+ # Keep every pod on the same minimum iOS version as the Runner app so
+ # CocoaPods never errors with "requires a higher minimum deployment target".
+ config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0'
+ end
+ end
+end
diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 0000000000000000000000000000000000000000..aa06c7c23af0d154e89e498ae56d19ed9ae7568f
--- /dev/null
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -0,0 +1,620 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
+ 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 97C146E61CF9000F007C117D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 97C146ED1CF9000F007C117D;
+ remoteInfo = Runner;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 9705A1C41CF9048500538489 /* Embed Frameworks */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ );
+ name = "Embed Frameworks";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; };
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
+ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; };
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; };
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; };
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; };
+ 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
+ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 97C146EB1CF9000F007C117D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 331C8082294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXGroup;
+ children = (
+ 331C807B294A618700263BE5 /* RunnerTests.swift */,
+ );
+ path = RunnerTests;
+ sourceTree = "";
+ };
+ 9740EEB11CF90186004384FC /* Flutter */ = {
+ isa = PBXGroup;
+ children = (
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */,
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */,
+ );
+ name = Flutter;
+ sourceTree = "";
+ };
+ 97C146E51CF9000F007C117D = {
+ isa = PBXGroup;
+ children = (
+ 9740EEB11CF90186004384FC /* Flutter */,
+ 97C146F01CF9000F007C117D /* Runner */,
+ 97C146EF1CF9000F007C117D /* Products */,
+ 331C8082294A63A400263BE5 /* RunnerTests */,
+ );
+ sourceTree = "";
+ };
+ 97C146EF1CF9000F007C117D /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146EE1CF9000F007C117D /* Runner.app */,
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 97C146F01CF9000F007C117D /* Runner */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146FA1CF9000F007C117D /* Main.storyboard */,
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */,
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
+ 97C147021CF9000F007C117D /* Info.plist */,
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
+ 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
+ );
+ path = Runner;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 331C8080294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
+ buildPhases = (
+ 331C807D294A63A400263BE5 /* Sources */,
+ 331C807F294A63A400263BE5 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */,
+ );
+ name = RunnerTests;
+ productName = RunnerTests;
+ productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 97C146ED1CF9000F007C117D /* Runner */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
+ buildPhases = (
+ 9740EEB61CF901F6004384FC /* Run Script */,
+ 97C146EA1CF9000F007C117D /* Sources */,
+ 97C146EB1CF9000F007C117D /* Frameworks */,
+ 97C146EC1CF9000F007C117D /* Resources */,
+ 9705A1C41CF9048500538489 /* Embed Frameworks */,
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Runner;
+ productName = Runner;
+ productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 97C146E61CF9000F007C117D /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1510;
+ ORGANIZATIONNAME = "";
+ TargetAttributes = {
+ 331C8080294A63A400263BE5 = {
+ CreatedOnToolsVersion = 14.0;
+ TestTargetID = 97C146ED1CF9000F007C117D;
+ };
+ 97C146ED1CF9000F007C117D = {
+ CreatedOnToolsVersion = 7.3.1;
+ LastSwiftMigration = 1100;
+ };
+ };
+ };
+ buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
+ compatibilityVersion = "Xcode 9.3";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 97C146E51CF9000F007C117D;
+ productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 97C146ED1CF9000F007C117D /* Runner */,
+ 331C8080294A63A400263BE5 /* RunnerTests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 331C807F294A63A400263BE5 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EC1CF9000F007C117D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
+ );
+ name = "Thin Binary";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
+ };
+ 9740EEB61CF901F6004384FC /* Run Script */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "Run Script";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 331C807D294A63A400263BE5 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EA1CF9000F007C117D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
+ 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 97C146ED1CF9000F007C117D /* Runner */;
+ targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+ 97C146FA1CF9000F007C117D /* Main.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C146FB1CF9000F007C117D /* Base */,
+ );
+ name = Main.storyboard;
+ sourceTree = "";
+ };
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C147001CF9000F007C117D /* Base */,
+ );
+ name = LaunchScreen.storyboard;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 249021D3217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Profile;
+ };
+ 249021D4217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.langlearn.langlearn;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Profile;
+ };
+ 331C8088294A63A400263BE5 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.langlearn.langlearn.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Debug;
+ };
+ 331C8089294A63A400263BE5 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.langlearn.langlearn.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Release;
+ };
+ 331C808A294A63A400263BE5 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.langlearn.langlearn.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Profile;
+ };
+ 97C147031CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 97C147041CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 97C147061CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.langlearn.langlearn;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Debug;
+ };
+ 97C147071CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.langlearn.langlearn;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 331C8088294A63A400263BE5 /* Debug */,
+ 331C8089294A63A400263BE5 /* Release */,
+ 331C808A294A63A400263BE5 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147031CF9000F007C117D /* Debug */,
+ 97C147041CF9000F007C117D /* Release */,
+ 249021D3217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147061CF9000F007C117D /* Debug */,
+ 97C147071CF9000F007C117D /* Release */,
+ 249021D4217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 97C146E61CF9000F007C117D /* Project object */;
+}
diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000000000000000000000000000000000000..c4b79bd8c0e5b685bc62bc82f93335b2a95fd7fd
--- /dev/null
+++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000000000000000000000000000000000000..fc6bf8074854f8fd9fea8d9cc80d5ca1b8d932db
--- /dev/null
+++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000000000000000000000000000000000000..af0309c4dc26b0880121296bc3fd0bd14aad8420
--- /dev/null
+++ b/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
new file mode 100644
index 0000000000000000000000000000000000000000..bbabc4e461f2641f43460c76ea459eb7be2f7231
--- /dev/null
+++ b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata b/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000000000000000000000000000000000000..59c6d39465edc08bd1e919a6248c68b1b72cfd33
--- /dev/null
+++ b/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000000000000000000000000000000000000..fc6bf8074854f8fd9fea8d9cc80d5ca1b8d932db
--- /dev/null
+++ b/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000000000000000000000000000000000000..af0309c4dc26b0880121296bc3fd0bd14aad8420
--- /dev/null
+++ b/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift
new file mode 100644
index 0000000000000000000000000000000000000000..ed1c097e68a6fa0e56041c21e9c43761e9e4335f
--- /dev/null
+++ b/mobile/ios/Runner/AppDelegate.swift
@@ -0,0 +1,16 @@
+import Flutter
+import UIKit
+
+@main
+@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
+ override func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+ ) -> Bool {
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions)
+ }
+
+ func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
+ GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
+ }
+}
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000000000000000000000000000000000000..d0d98aa177c36f0fbd7e4b1ae01435e00ccc6fbf
--- /dev/null
+++ b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1 @@
+{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
\ No newline at end of file
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
new file mode 100644
index 0000000000000000000000000000000000000000..d2b9bbe35588df635d3b28c15e5167f0b74ea95e
--- /dev/null
+++ b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:08fb892f5e009e79be2dfc8072006497ff229b0103aeb24c8bffa7d9979b1ddc
+size 1647220
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
new file mode 100644
index 0000000000000000000000000000000000000000..46932ba6b3503b91d1e30ece6750e97d43e4c9b0
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..96593f02db2a69c2ec57589ed1bcca3757dabf97
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
new file mode 100644
index 0000000000000000000000000000000000000000..aba02fc2a0d06b634c346a1b72038b5ca291fcdb
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
new file mode 100644
index 0000000000000000000000000000000000000000..93d4ca7e41ec7ad94ce5edb95346060b82252ee6
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..fb19ee35d663956792e6dfa2eef9dc360ab3bfc7
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
new file mode 100644
index 0000000000000000000000000000000000000000..16f82e98a88561d24ca724a7c712205582faec4c
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
new file mode 100644
index 0000000000000000000000000000000000000000..96593f02db2a69c2ec57589ed1bcca3757dabf97
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..9176d97630d2da4a401a0bd7d2d0eb1ca31baed4
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
new file mode 100644
index 0000000000000000000000000000000000000000..14806d9d964da7f39489256cfc1b346da4d01c98
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png
new file mode 100644
index 0000000000000000000000000000000000000000..fe2a9bbd796a931b89ff5673995dc1e646379162
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..220761e386334faf7e544298af513bee2ab2d7a1
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png
new file mode 100644
index 0000000000000000000000000000000000000000..11e1cb5caf1632f4ea180e11ade28fa3a16918eb
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..c02f2b1a783df884c2eed30fc165b3e2910ea241
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..14806d9d964da7f39489256cfc1b346da4d01c98
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
new file mode 100644
index 0000000000000000000000000000000000000000..c1cd9387c430f96440d4959312b68db7520702bf
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png
new file mode 100644
index 0000000000000000000000000000000000000000..a977833f561ebf3332577e74e0c36873d7d42dbf
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..f763d0f24c6ff70c1c043f38ab37b93cb23ac4d1
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
new file mode 100644
index 0000000000000000000000000000000000000000..f434cb4c0f636637b1fb2f0dc62bb9cf94aa053f
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..132908e6879ad726c7d7e510fe815fe9b4db55bc
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..bce57454d954e7fe38fb4a3a7078c38e4d098176
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
new file mode 100644
index 0000000000000000000000000000000000000000..d08a4de320fe20e3d3b11b519313713413c7e21e
--- /dev/null
+++ b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@3x.png",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
new file mode 100644
index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
new file mode 100644
index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838
Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ
diff --git a/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..65a94b5db54ec02257c0fd4e74ebea1a7a34e881
--- /dev/null
+++ b/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
@@ -0,0 +1,5 @@
+# Launch Screen Assets
+
+You can customize the launch screen with your own desired assets by replacing the image files in this directory.
+
+You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
\ No newline at end of file
diff --git a/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard b/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 0000000000000000000000000000000000000000..497371ea23a88889b582a79836d65568268f5fd3
--- /dev/null
+++ b/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/ios/Runner/Base.lproj/Main.storyboard b/mobile/ios/Runner/Base.lproj/Main.storyboard
new file mode 100644
index 0000000000000000000000000000000000000000..bbb83caaec5481ea63968cdfdf6e574aa9584a1b
--- /dev/null
+++ b/mobile/ios/Runner/Base.lproj/Main.storyboard
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist
new file mode 100644
index 0000000000000000000000000000000000000000..21014c263c5bc11295fadb801abd6ce65b64d491
--- /dev/null
+++ b/mobile/ios/Runner/Info.plist
@@ -0,0 +1,89 @@
+
+
+
+
+ ITSAppUsesNonExemptEncryption
+
+ CADisableMinimumFrameDurationOnPhone
+
+ CFBundleURLTypes
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLName
+ com.langlearn.oauth
+ CFBundleURLSchemes
+
+ langlearn
+
+
+
+ NSCameraUsageDescription
+ Used to take photos that we translate into your learning language.
+ NSPhotoLibraryUsageDescription
+ Used to pick photos that we translate into your learning language.
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ PraxaLing
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ PraxaLing
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSRequiresIPhoneOS
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneClassName
+ UIWindowScene
+ UISceneConfigurationName
+ flutter
+ UISceneDelegateClassName
+ $(PRODUCT_MODULE_NAME).SceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
+
+ UIApplicationSupportsIndirectInputEvents
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
diff --git a/mobile/ios/Runner/PrivacyInfo.xcprivacy b/mobile/ios/Runner/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000000000000000000000000000000000000..14843101c0c802482d3053e13dff75204a3e8883
--- /dev/null
+++ b/mobile/ios/Runner/PrivacyInfo.xcprivacy
@@ -0,0 +1,19 @@
+
+
+
+
+
+ NSPrivacyTracking
+
+ NSPrivacyTrackingDomains
+
+
+ NSPrivacyCollectedDataTypes
+
+
+ NSPrivacyAccessedAPITypes
+
+
+
diff --git a/mobile/ios/Runner/Runner-Bridging-Header.h b/mobile/ios/Runner/Runner-Bridging-Header.h
new file mode 100644
index 0000000000000000000000000000000000000000..fae207f9e2b9302728c2199314243bda2088bb8b
--- /dev/null
+++ b/mobile/ios/Runner/Runner-Bridging-Header.h
@@ -0,0 +1 @@
+#import "GeneratedPluginRegistrant.h"
diff --git a/mobile/ios/Runner/SceneDelegate.swift b/mobile/ios/Runner/SceneDelegate.swift
new file mode 100644
index 0000000000000000000000000000000000000000..b79be9bdfe815bb1f5f0ea10679dfee5f099f98e
--- /dev/null
+++ b/mobile/ios/Runner/SceneDelegate.swift
@@ -0,0 +1,6 @@
+import Flutter
+import UIKit
+
+class SceneDelegate: FlutterSceneDelegate {
+
+}
diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift
new file mode 100644
index 0000000000000000000000000000000000000000..4d206ded9e1cc1a76c409afcfc90f8de4e7c99ec
--- /dev/null
+++ b/mobile/ios/RunnerTests/RunnerTests.swift
@@ -0,0 +1,12 @@
+import Flutter
+import UIKit
+import XCTest
+
+class RunnerTests: XCTestCase {
+
+ func testExample() {
+ // If you add code to the Runner application, consider adding tests here.
+ // See https://developer.apple.com/documentation/xctest for more information about using XCTest.
+ }
+
+}
diff --git a/mobile/lib/auth/auth_provider.dart b/mobile/lib/auth/auth_provider.dart
new file mode 100644
index 0000000000000000000000000000000000000000..59d40ead38486fc5a35ed3977c7a5b1833da0a6d
--- /dev/null
+++ b/mobile/lib/auth/auth_provider.dart
@@ -0,0 +1,248 @@
+import 'package:dio/dio.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
+
+import '../core/api_client.dart';
+import '../core/env.dart';
+import 'session_store.dart';
+
+class AuthState {
+ const AuthState({required this.status, this.user, this.profile, this.streakCount = 0});
+
+ final AuthStatus status;
+ final User? user;
+ final Profile? profile;
+ final int streakCount;
+
+ AuthState copyWith({AuthStatus? status, User? user, Profile? profile, int? streakCount}) =>
+ AuthState(
+ status: status ?? this.status,
+ user: user ?? this.user,
+ profile: profile ?? this.profile,
+ streakCount: streakCount ?? this.streakCount,
+ );
+}
+
+enum AuthStatus { unknown, signedOut, noProfile, ready }
+
+class User {
+ const User({required this.id, required this.hfUsername, this.email, this.avatarUrl});
+ final String id;
+ final String hfUsername;
+ final String? email;
+ final String? avatarUrl;
+
+ factory User.fromJson(Map j) => User(
+ id: (j['id'] ?? j['hfId']) as String,
+ hfUsername: j['hfUsername'] as String,
+ email: j['email'] as String?,
+ avatarUrl: j['avatarUrl'] as String?,
+ );
+}
+
+class Profile {
+ const Profile({
+ required this.nativeLang,
+ required this.targetLang,
+ required this.targetLangs,
+ required this.level,
+ });
+ final String nativeLang;
+ final String targetLang;
+ final List targetLangs;
+ final String level;
+
+ factory Profile.fromJson(Map j) {
+ final tg = j['targetLang'] as String;
+ final list = (j['targetLangs'] as List?)?.map((e) => e.toString()).toList();
+ return Profile(
+ nativeLang: j['nativeLang'] as String,
+ targetLang: tg,
+ targetLangs: (list == null || list.isEmpty) ? [tg] : list,
+ level: j['level'] as String,
+ );
+ }
+
+ Map toJson() => {
+ 'nativeLang': nativeLang,
+ 'targetLang': targetLang,
+ 'targetLangs': targetLangs,
+ 'level': level,
+ };
+
+ Profile copyWith({String? nativeLang, String? targetLang, List? targetLangs, String? level}) =>
+ Profile(
+ nativeLang: nativeLang ?? this.nativeLang,
+ targetLang: targetLang ?? this.targetLang,
+ targetLangs: targetLangs ?? this.targetLangs,
+ level: level ?? this.level,
+ );
+}
+
+class AuthController extends StateNotifier {
+ AuthController(this._api, this._store) : super(const AuthState(status: AuthStatus.unknown)) {
+ _initFromCache();
+ }
+
+ final ApiClient _api;
+ final SessionStore _store;
+
+ /// On startup: immediately restore any cached profile so the UI is not blank,
+ /// then kick off a background refresh from the server.
+ Future _initFromCache() async {
+ final token = await _store.read();
+ if (token == null) {
+ state = const AuthState(status: AuthStatus.signedOut);
+ return;
+ }
+ // Try to restore profile from local cache for instant UI.
+ final cached = await _store.readProfile();
+ if (cached != null) {
+ final profile = Profile.fromJson(cached);
+ state = AuthState(status: AuthStatus.ready, profile: profile);
+ }
+ // Always refresh from server in the background.
+ await refresh();
+ }
+
+ Future refresh() async {
+ final token = await _store.read();
+ if (token == null) {
+ state = const AuthState(status: AuthStatus.signedOut);
+ return;
+ }
+ try {
+ final res = await _api.dio.get