diff --git a/.gitignore b/.gitignore
index 040a8cd..1b2de80 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,5 @@ local.properties
+/docs/
+/docs/
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 639c779..ad569b6 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -11,6 +11,7 @@
+
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index c8f004d..c767393 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -2,5 +2,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/markdown.xml b/.idea/markdown.xml
new file mode 100644
index 0000000..c61ea33
--- /dev/null
+++ b/.idea/markdown.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.kotlin/errors/errors-1781225331360.log b/.kotlin/errors/errors-1781225331360.log
new file mode 100644
index 0000000..06b94a6
--- /dev/null
+++ b/.kotlin/errors/errors-1781225331360.log
@@ -0,0 +1,75 @@
+kotlin version: 2.0.0
+error message: Daemon compilation failed: null
+java.lang.Exception
+ at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:69)
+ at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:65)
+ at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:240)
+ at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159)
+ at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111)
+ at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:76)
+ at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:63)
+ at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:66)
+ at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:62)
+ at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:100)
+ at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:62)
+ at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44)
+ at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:199)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:157)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
+ at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
+ at org.gradle.internal.operations.DefaultBuildOperationExecutor.call(DefaultBuildOperationExecutor.java:73)
+ at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41)
+ at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:59)
+ at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:170)
+ at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
+ at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:187)
+ at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:120)
+ at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:162)
+ at org.gradle.internal.Factories$1.create(Factories.java:31)
+ at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:264)
+ at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:128)
+ at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:133)
+ at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:157)
+ at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:126)
+ at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Unknown Source)
+ at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
+ at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
+ at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:47)
+ at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
+ at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
+ at java.base/java.lang.Thread.run(Unknown Source)
+Caused by: java.nio.file.NoSuchFileException: /var/folders/z7/hgmk9tl93xxf7j7n8565rn3m0000gn/T/kotlin-backups10052692937434047068/6.backup -> /Users/kunchoktashi/dev/android/Tibetan-Keyboard/app/build/tmp/kotlin-classes/debug/com/kharagedition/tibetankeyboard/ui/keyboard/AIKeyboardView$loadSuggestionEngine$1$2.class
+ at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:92)
+ at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:106)
+ at java.base/sun.nio.fs.UnixCopyFile.move(UnixCopyFile.java:477)
+ at java.base/sun.nio.fs.UnixFileSystemProvider.move(UnixFileSystemProvider.java:266)
+ at java.base/java.nio.file.Files.move(Files.java:1432)
+ at org.jetbrains.kotlin.incremental.RecoverableCompilationTransaction.revertChanges(CompilationTransaction.kt:231)
+ at org.jetbrains.kotlin.incremental.RecoverableCompilationTransaction.close(CompilationTransaction.kt:256)
+ at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.tryCompileIncrementally(IncrementalCompilerRunner.kt:747)
+ at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:120)
+ at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:676)
+ at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92)
+ at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1661)
+ at jdk.internal.reflect.GeneratedMethodAccessor15.invoke(Unknown Source)
+ at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
+ at java.base/java.lang.reflect.Method.invoke(Method.java:569)
+ at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360)
+ at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200)
+ at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197)
+ at java.base/java.security.AccessController.doPrivileged(AccessController.java:712)
+ at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196)
+ at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:587)
+ at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:828)
+ at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:705)
+ at java.base/java.security.AccessController.doPrivileged(AccessController.java:399)
+ at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:704)
+ at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136)
+ at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:635)
+ at java.base/java.lang.Thread.run(Thread.java:840)
+
+
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..9107741
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,175 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Build & Development Commands
+
+> [!IMPORTANT]
+> **Builds require JDK 17.** The machine default may be newer (e.g. JDK 23), which Gradle 8.6
+> rejects ("Unsupported class file major version 67"). Prefix every Gradle command:
+> ```bash
+> JAVA_HOME=/path/to/jdk-17 ./gradlew
+> ```
+> Do **not** hardcode `org.gradle.java.home` in the committed `gradle.properties` (Android Studio
+> uses its own JDK). A clean full build takes ~6–7 min cold, seconds when warm.
+
+```bash
+./gradlew :app:assembleDebug # Debug APK (does NOT compile unit tests)
+./gradlew :app:compileDebugKotlin # Fast Kotlin-only check while iterating
+./gradlew :app:installDebug # Install to connected device
+./gradlew :botok:test # The real unit suite (~198 tokenizer tests)
+./gradlew lint
+
+# Backend (api-backend/)
+npm run build # Compile TypeScript
+npm run deploy # Deploy to Firebase Functions (asia-south1)
+```
+
+> [!NOTE]
+> `:app:testDebugUnitTest` now compiles and runs (the stale `AIGrammarViewModelTest`, which
+> referenced deleted Grammar classes, was removed). It holds **pure JVM tests** for the
+> conversion-critical decision logic — `ProStripState` (free-vs-PRO strip), `UnlockRouter`
+> (auth→paywall routing), `PostLoginDestination` (post-login routing), and `KeyboardLayoutTest`
+> (guards the removed "+"/`-20` key across all `res/xml` layouts). Keep view/IME/RevenueCat-SDK
+> behaviour out of these (no mockito/Robolectric); test it by extracting the decision into a pure
+> unit (see those classes) or via on-device verification. `:botok:test` remains the tokenizer suite.
+
+## Architecture Overview
+
+**MVVM** for app screens (Jetpack **Compose** UI) + **View-based IME** for the keyboard itself.
+
+```
+com.kharagedition.tibetankeyboard/
+├── app/ # Application (global night mode, RevenueCat, FCM init)
+├── service/ # TibetanKeyboard.kt — the IME InputMethodService
+├── ui/
+│ ├── compose/
+│ │ ├── theme/ # Color, Tokens (gradients/radii), Type, Theme — design system
+│ │ └── components/# AppIcons, Components (IconTile, GoldToggle, BackHeader…), ScreenScaffold
+│ ├── keyboard/ # TibetanKeyboardView, AIKeyboardView, EmojiKeyboardView, SuggestionStripView
+│ ├── home/ # HomeActivity + HomeViewModel + HomeScreen (Compose)
+│ ├── settings/ # SettingsActivity + SettingsViewModel + SettingsScreen + SettingsPrefs
+│ ├── chat/ # ChatActivity + ChatViewModel + ChatScreen
+│ ├── subscription/ # PremiumActivity + PremiumViewModel + PremiumScreen
+│ ├── about/ # AboutActivity + AboutScreen
+│ ├── login/ # LoginActivity + LoginViewModel + LoginScreen
+│ └── splash/ # SplashScreenActivity (Compose)
+├── data/
+│ ├── repository/ # ChatRepository, UserRepository, RevenueCatManager, PremiumFeatureManager,
+│ │ # subscriptionCallback() helper
+│ ├── remote/ # RetrofitClient singleton + API interfaces
+│ ├── local/ # UserPreferences (SharedPreferences wrapper)
+│ └── model/ # Data classes
+├── auth/ # AuthManager (Firebase Auth)
+└── util/ # TKExtension (showToast, showConfirmationDialog, …), CommonUtils, AppConstant
+```
+
+### UI: Jetpack Compose
+
+All app screens are **Compose** (Splash, Login, Home, Settings, Chat, Premium, About). The only
+View-based UI left is the **IME keyboard** (see below) and ad views embedded via `AndroidView`.
+
+**Design system** — `ui/compose/theme` mirrors `docs/tibetan-keyboard/project/tokens.css`
+(warm brown/gold "monk-robe" palette):
+- `TibetanColors` — the full colour ramp. `TibetanTokens` — gradients, radii, elevation.
+- `TibetanKeyboardTheme { … }` wraps every screen's `setContent`. The app is a single
+ premium **dark-warm** look (forced light mode globally in `Application`).
+- Tibetan text uses **Uchen** (block) via the **system font** (`FontFamily.Default` → Noto Sans
+ Tibetan). Do NOT bundle `jomolhari_regular.ttf` (it's a 0-byte/corrupt file and crashes
+ Compose's font loader); never use the cursive Ume face.
+
+### MVVM conventions (follow these for new screens)
+
+- **ViewModels own UI state** as `StateFlow`; screens collect via
+ `collectAsStateWithLifecycle()`. Premium status is observed inside the VM from
+ `RevenueCatManager.isPremiumUser` (LiveData) with `observeForever` + `removeObserver` in
+ `onCleared()`.
+- **Composable screens are stateless**: take a `state` object + an `actions` class of lambdas.
+ No business logic in composables.
+- **Activities hold only framework glue** that genuinely needs an Activity: `InputMethodManager`
+ queries, `ActivityResultLauncher` (Google sign-in), `RevenueCatManager.purchasePremium(activity)`,
+ ad `AndroidView`s, intents/navigation. This is correct MVVM — those APIs require an Activity, so
+ they do **not** belong in a ViewModel.
+- Reuse `ScreenScaffold { … }` for scrollable screens (handles full-bleed background + status/nav
+ bar insets + scroll). `targetSdk 36` forces edge-to-edge, so **always** consume insets or content
+ draws under the status bar.
+- Build `RevenueCatManager.SubscriptionCallback`s with `subscriptionCallback(onSuccess =, onError =)`
+ rather than hand-written anonymous objects.
+- **Strings**: user-facing UI chrome lives in `res/values/strings.xml` via `stringResource(...)`.
+
+### Data Flow
+
+```
+Compose screen (collectAsStateWithLifecycle)
+ ↑ StateFlow ↓ actions (lambdas)
+ViewModel ──── Repository.apiCall() [Coroutine on IO] ──→ Retrofit → Backend
+```
+
+### IME Service Flow (View-based, NOT Compose)
+
+`TibetanKeyboard` (InputMethodService) rebuilds the keyboard on every `onStartInputView`, so
+preference changes apply the next time the keyboard opens.
+- `AIKeyboardView` — top toolbar (AI grammar/translate/rephrase) + suggestion strip + the keyboard
+ container. AI features run **inside** this panel (`showAIInterface()`), not as separate screens.
+- `TibetanKeyboardView extends KeyboardView` — the typing surface. Custom `onDraw` paints the
+ **Enter key gold** on top of any layout (visual only; key codes untouched).
+- `SuggestionStripView` — Botok autocomplete; top suggestion gold, rest cream.
+- `KeyboardMode` enum: `NORMAL`, `AI_GRAMMAR`, `AI_REPHRASE`.
+
+**Keyboard layouts & theming** are driven by `PreferenceManager.getDefaultSharedPreferences`
+(the same store the Compose Settings screen writes — see `SettingsPrefs`):
+- `keyboard_style`: `modern` (flat), `classic` (raised gradient caps), `borderless` (glyphs only).
+ Selected in Settings → Keyboard Layout. Brown key drawables: `normal/pressed_{classic,modern}_brown`,
+ `key_background_borderless`.
+- `colors`: brown/black/green; the IME darkens the chosen colour to an espresso surface at runtime
+ (`darkenColor`, and `AIKeyboardView.applyTheme`) so brown caps pop.
+- `vibrate`, `sound`, `event_notification`: behaviour flags read by the IME.
+- Keyboard key XML layouts live in `res/xml/` (tibetan_uchen_alphabet_1/2, qwerty, etc.) — **never
+ change key codes** there; they drive `onKey()`.
+
+### Premium Feature Gating
+
+`PremiumFeatureManager` + `RevenueCatManager` (entitlement `"pro"`). Free-tier daily limits in
+Firestore: Grammar 10/day, Transliteration 20/day, Chat 100 messages.
+
+### Backend (`api-backend/`)
+
+Node.js/TypeScript Express on Firebase Functions. AI: Gemini 2.0 Flash. Translation: Google
+Translate. Auth middleware: API key + per-user Firestore usage limits.
+Endpoints: `POST /chat`, `POST /translate` (grammar/transliterate endpoints exist server-side but
+the in-app Grammar/Transliteration screens were removed as dead code).
+
+### Key Libraries
+
+| Purpose | Library |
+|---------|---------|
+| UI | Jetpack Compose (BOM 2024.09.00, Material3) + Compose compiler plugin 2.0.0 |
+| Compose↔ViewModel | lifecycle-viewmodel-compose / lifecycle-runtime-compose 2.8.7, runtime-livedata |
+| Images | Coil 2.7.0 (Compose) + Glide 4.16.0 (GIF in `AndroidView`) |
+| HTTP | Retrofit 2 + OkHttp3 |
+| Async | Kotlin Coroutines 1.8.1 |
+| Subscriptions | RevenueCat 9.2.0 |
+| Ads | AdMob 23.6.0 (`TemplateView` native, `AdView` banner via `AndroidView`) |
+| Tokenizer | `:botok` module |
+
+### Build Configuration
+
+- **App ID**: `com.kharagedition.tibetankeyboard` · **Min SDK** 23 · **Target/Compile SDK** 36
+- **Kotlin** 2.0.0 · **Java** 17 · **AGP** 8.3.2 · **Gradle** 8.6
+- `buildFeatures { compose = true; viewBinding = true; buildConfig = true }`
+- Release: ProGuard + resource shrinking; `app/lint-baseline.xml` suppresses known lint issues.
+
+## Conventions & gotchas (future reference)
+
+- **Reskinning a screen?** Keep the ViewModel/repository; only swap the composable. State stays
+ hoisted to the VM.
+- **New screen?** `XxxActivity` (glue) + `XxxViewModel` (StateFlow state) + `XxxScreen` (stateless
+ composable in `TibetanKeyboardTheme` + `ScreenScaffold`). Wrap callbacks in an `XxxActions` class.
+- **Touching the keyboard?** It "functions exact" — restyle drawables/`onDraw`/colours only; never
+ alter key codes, `onKey()` routing, or the `res/xml` key definitions.
+- **Adding strings?** Check `strings.xml` for an existing key first (duplicate names fail the
+ resource merge).
+- **Always device-verify Compose changes** (`installDebug` + screenshot), not just `assembleDebug` —
+ a green build can still crash at runtime (e.g. the empty-font bug).
+- A few inline strings remain (Premium feature descriptions, quick-action labels, Tibetan
+ placeholders) — externalize if expanding i18n.
diff --git a/TEST_CMD.md b/TEST_CMD.md
new file mode 100644
index 0000000..e69de29
diff --git a/api-backend/README.md b/api-backend/README.md
index 861b8c2..6aa0d5a 100644
--- a/api-backend/README.md
+++ b/api-backend/README.md
@@ -11,14 +11,33 @@ A secure, production-ready Cloud Function providing translation and grammar chec
## Features
-- 🔐 Secure API key authentication
-- 🌍 Multi-language translation via Google Translate
+- 🤖 Chat & translation powered by **Claude** (`claude-haiku-4-5` / `claude-sonnet-4-6`, user-switchable per request)
+- ⚡ Two-tier response cache: in-memory → Firestore (`ai_cache`) → Claude
+- 🛡️ Per-IP rate limiting on the AI endpoints (30 req/min) + global limiter
- ✍️ Grammar checking and correction
-- 📊 User usage tracking and daily limits
-- 🛡️ Rate limiting and security middleware
- 🔥 Firestore integration for user management
+- 🧪 Unit tests (`npm test`) + structured logging (`firebase-functions/logger`)
- 📝 TypeScript for type safety
+## Claude (Anthropic) configuration
+
+`/chat` and `/translate` call Claude via the official `@anthropic-ai/sdk`. The
+model is chosen per request via a `model` field/header (`claude-haiku-4-5` or
+`claude-sonnet-4-6`; defaults to Haiku). Set the API key as a Firebase secret —
+**do not commit it**:
+
+```bash
+firebase functions:secrets:set ANTHROPIC_API_KEY
+```
+
+For local emulation, put `ANTHROPIC_API_KEY=...` in `.env` (gitignored).
+
+Run the unit tests (cache flow + model resolution + session helpers):
+
+```bash
+npm test
+```
+
## Setup
1. **Install dependencies:**
diff --git a/api-backend/package-lock.json b/api-backend/package-lock.json
index 84866d3..f909c4f 100644
--- a/api-backend/package-lock.json
+++ b/api-backend/package-lock.json
@@ -8,6 +8,7 @@
"name": "tibetan-keyboard-api-service",
"version": "1.0.0",
"dependencies": {
+ "@anthropic-ai/sdk": "^0.104.1",
"@google-cloud/translate": "^8.0.2",
"@google/generative-ai": "^0.24.1",
"axios": "^1.6.2",
@@ -24,12 +25,34 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.8.7",
+ "tsx": "^4.22.4",
"typescript": "^5.2.2"
},
"engines": {
"node": "22"
}
},
+ "node_modules/@anthropic-ai/sdk": {
+ "version": "0.104.1",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.1.tgz",
+ "integrity": "sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "json-schema-to-ts": "^3.1.1",
+ "standardwebhooks": "^1.0.0"
+ },
+ "bin": {
+ "anthropic-ai-sdk": "bin/cli"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "zod": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
@@ -66,6 +89,15 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/types": {
"version": "7.28.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz",
@@ -80,6 +112,448 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@fastify/busboy": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-1.2.1.tgz",
@@ -799,6 +1273,12 @@
"integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==",
"license": "BSD-3-Clause"
},
+ "node_modules/@stablelib/base64": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
+ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
+ "license": "MIT"
+ },
"node_modules/@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@@ -1627,6 +2107,48 @@
"node": ">= 0.4"
}
},
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1849,6 +2371,12 @@
"license": "MIT",
"optional": true
},
+ "node_modules/fast-sha256": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
+ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
+ "license": "Unlicense"
+ },
"node_modules/fast-text-encoding": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz",
@@ -2022,6 +2550,21 @@
"license": "ISC",
"optional": true
},
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -2779,6 +3322,19 @@
"bignumber.js": "^9.0.0"
}
},
+ "node_modules/json-schema-to-ts": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
+ "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.3",
+ "ts-algebra": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
@@ -3713,6 +4269,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/standardwebhooks": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
+ "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
+ "license": "MIT",
+ "dependencies": {
+ "@stablelib/base64": "^1.0.0",
+ "fast-sha256": "^1.3.0"
+ }
+ },
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -3864,12 +4430,37 @@
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
+ "node_modules/ts-algebra": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
+ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
+ "license": "MIT"
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
+ "node_modules/tsx": {
+ "version": "4.22.4",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
+ "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.28.0"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
"node_modules/type-check": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
diff --git a/api-backend/package.json b/api-backend/package.json
index fc57981..18c4fa8 100644
--- a/api-backend/package.json
+++ b/api-backend/package.json
@@ -5,6 +5,7 @@
"main": "dist/index.js",
"scripts": {
"build": "tsc",
+ "test": "node --import tsx --test src/__tests__/*.test.ts",
"lint": "echo 'Linting skipped'",
"serve": "lsof -ti :5001 | xargs kill -9 && npm run build && firebase emulators:start --only functions",
"shell": "npm run build && firebase functions:shell",
@@ -16,6 +17,7 @@
"node": "22"
},
"dependencies": {
+ "@anthropic-ai/sdk": "^0.104.1",
"@google-cloud/translate": "^8.0.2",
"@google/generative-ai": "^0.24.1",
"axios": "^1.6.2",
@@ -32,6 +34,7 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.8.7",
+ "tsx": "^4.22.4",
"typescript": "^5.2.2"
}
}
diff --git a/api-backend/src/__tests__/anthropicService.test.ts b/api-backend/src/__tests__/anthropicService.test.ts
new file mode 100644
index 0000000..2d14cc0
--- /dev/null
+++ b/api-backend/src/__tests__/anthropicService.test.ts
@@ -0,0 +1,29 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ resolveModel,
+ HAIKU_MODEL,
+ SONNET_MODEL,
+ DEFAULT_MODEL,
+} from "../services/anthropicService";
+
+test("resolveModel accepts the two allowed Claude model ids", () => {
+ assert.equal(resolveModel(HAIKU_MODEL), HAIKU_MODEL);
+ assert.equal(resolveModel(SONNET_MODEL), SONNET_MODEL);
+});
+
+test("resolveModel accepts friendly aliases (case-insensitive)", () => {
+ assert.equal(resolveModel("haiku"), HAIKU_MODEL);
+ assert.equal(resolveModel("Haiku"), HAIKU_MODEL);
+ assert.equal(resolveModel("sonnet"), SONNET_MODEL);
+ assert.equal(resolveModel("SONNET"), SONNET_MODEL);
+});
+
+test("resolveModel falls back to the default for unknown/empty input", () => {
+ assert.equal(DEFAULT_MODEL, HAIKU_MODEL);
+ assert.equal(resolveModel(undefined), DEFAULT_MODEL);
+ assert.equal(resolveModel(null), DEFAULT_MODEL);
+ assert.equal(resolveModel(""), DEFAULT_MODEL);
+ assert.equal(resolveModel("gpt-4"), DEFAULT_MODEL);
+ assert.equal(resolveModel("claude-opus-4-8"), DEFAULT_MODEL); // not offered in-app
+});
diff --git a/api-backend/src/__tests__/cacheService.test.ts b/api-backend/src/__tests__/cacheService.test.ts
new file mode 100644
index 0000000..2e1813e
--- /dev/null
+++ b/api-backend/src/__tests__/cacheService.test.ts
@@ -0,0 +1,102 @@
+import { test, beforeEach } from "node:test";
+import assert from "node:assert/strict";
+import {
+ cachedCompute,
+ makeCacheKey,
+ __clearMemoryCache,
+} from "../services/cacheService";
+
+// These tests exercise the memory tier only. The Admin SDK is intentionally not
+// initialised, so the Firestore tier always throws and is treated as a miss —
+// which is exactly the behaviour cacheService promises.
+
+beforeEach(() => {
+ __clearMemoryCache();
+});
+
+test("makeCacheKey is deterministic and order-independent", () => {
+ const a = makeCacheKey("translate", { text: "hi", targetLang: "bo", model: "m" });
+ const b = makeCacheKey("translate", { model: "m", targetLang: "bo", text: "hi" });
+ assert.equal(a, b);
+ assert.match(a, /^translate_[0-9a-f]{64}$/);
+});
+
+test("different inputs produce different keys", () => {
+ const a = makeCacheKey("translate", { text: "hi", model: "m" });
+ const b = makeCacheKey("translate", { text: "bye", model: "m" });
+ assert.notEqual(a, b);
+});
+
+test("nested object content differentiates keys (chat messages)", () => {
+ // Regression: the array-replacer form of JSON.stringify stripped nested keys,
+ // collapsing every first-turn chat message onto one cache key.
+ const a = makeCacheKey("chat", {
+ model: "m",
+ system: "sys",
+ messages: [{ role: "user", content: "hello" }],
+ });
+ const b = makeCacheKey("chat", {
+ model: "m",
+ system: "sys",
+ messages: [{ role: "user", content: "what is the weather?" }],
+ });
+ assert.notEqual(a, b);
+});
+
+test("nested keys are order-independent", () => {
+ const a = makeCacheKey("chat", {
+ messages: [{ role: "user", content: "hi" }],
+ model: "m",
+ });
+ const b = makeCacheKey("chat", {
+ model: "m",
+ messages: [{ content: "hi", role: "user" }],
+ });
+ assert.equal(a, b);
+});
+
+test("first call hits provider, second identical call hits memory", async () => {
+ let calls = 0;
+ const provider = async () => {
+ calls += 1;
+ return `value-${calls}`;
+ };
+ const key = makeCacheKey("translate", { text: "hello", model: "haiku" });
+
+ const first = await cachedCompute(key, provider);
+ assert.equal(first.source, "provider");
+ assert.equal(first.value, "value-1");
+
+ const second = await cachedCompute(key, provider);
+ assert.equal(second.source, "memory");
+ assert.equal(second.value, "value-1"); // same cached value
+ assert.equal(calls, 1, "provider must only be called once");
+});
+
+test("distinct keys each invoke the provider", async () => {
+ let calls = 0;
+ const provider = async () => {
+ calls += 1;
+ return `value-${calls}`;
+ };
+
+ await cachedCompute(makeCacheKey("translate", { text: "a" }), provider);
+ await cachedCompute(makeCacheKey("translate", { text: "b" }), provider);
+
+ assert.equal(calls, 2);
+});
+
+test("provider rejection is propagated and not cached", async () => {
+ const key = makeCacheKey("translate", { text: "boom" });
+ await assert.rejects(
+ cachedCompute(key, async () => {
+ throw new Error("provider failed");
+ }),
+ /provider failed/
+ );
+
+ // A subsequent successful call must run the provider (nothing was cached).
+ const ok = await cachedCompute(key, async () => "recovered");
+ assert.equal(ok.source, "provider");
+ assert.equal(ok.value, "recovered");
+});
diff --git a/api-backend/src/__tests__/chatSessionManager.test.ts b/api-backend/src/__tests__/chatSessionManager.test.ts
new file mode 100644
index 0000000..25936bd
--- /dev/null
+++ b/api-backend/src/__tests__/chatSessionManager.test.ts
@@ -0,0 +1,52 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ ChatSessionManager,
+ generateSessionId,
+} from "../manager/ChatSessionManager";
+
+// Pure, network-free behaviour. The Claude call in sendMessage is covered by
+// the live smoke test, not unit tests.
+
+test("getOrCreateSession is idempotent and starts with empty history", () => {
+ const id = "session-test-1";
+ ChatSessionManager.resetSession(id);
+ const a = ChatSessionManager.getOrCreateSession(id);
+ const b = ChatSessionManager.getOrCreateSession(id);
+ assert.equal(a, b, "same session object is reused");
+ assert.deepEqual(a.history, []);
+ assert.equal(a.mode, "general");
+ ChatSessionManager.resetSession(id);
+});
+
+test("resetSession clears state", () => {
+ const id = "session-test-2";
+ const session = ChatSessionManager.getOrCreateSession(id);
+ session.history.push({ role: "user", content: "hi" });
+ assert.equal(ChatSessionManager.getSessionInfo(id)?.history.length, 1);
+ ChatSessionManager.resetSession(id);
+ assert.equal(ChatSessionManager.getSessionInfo(id), undefined);
+});
+
+test("updateSessionMode switches mode and tutoring level", () => {
+ const id = "session-test-3";
+ ChatSessionManager.resetSession(id);
+ ChatSessionManager.updateSessionMode(id, "tutoring", "beginner");
+ const info = ChatSessionManager.getSessionInfo(id);
+ assert.equal(info?.mode, "tutoring");
+ assert.equal(info?.tutoringLevel, "beginner");
+ ChatSessionManager.resetSession(id);
+});
+
+test("getTutoringCurriculum returns the curriculum for a level", () => {
+ const beginner = ChatSessionManager.getTutoringCurriculum("beginner");
+ assert.equal(beginner.currentLesson, "lesson_1_alphabet");
+ assert.ok(beginner.focusAreas.includes("alphabet"));
+});
+
+test("generateSessionId produces unique prefixed ids", () => {
+ const a = generateSessionId();
+ const b = generateSessionId();
+ assert.match(a, /^chat_/);
+ assert.notEqual(a, b);
+});
diff --git a/api-backend/src/__tests__/translateProvider.test.ts b/api-backend/src/__tests__/translateProvider.test.ts
new file mode 100644
index 0000000..a06445e
--- /dev/null
+++ b/api-backend/src/__tests__/translateProvider.test.ts
@@ -0,0 +1,34 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ resolveEngine,
+ ENGINE_AZURE,
+ DEFAULT_ENGINE,
+ AZURE_FALLBACK_MODEL,
+} from "../services/translateProvider";
+import { HAIKU_MODEL, SONNET_MODEL } from "../services/anthropicService";
+
+test("resolveEngine accepts the three allowed engine ids", () => {
+ assert.equal(resolveEngine(ENGINE_AZURE), ENGINE_AZURE);
+ assert.equal(resolveEngine(HAIKU_MODEL), HAIKU_MODEL);
+ assert.equal(resolveEngine(SONNET_MODEL), SONNET_MODEL);
+});
+
+test("resolveEngine accepts friendly aliases (case-insensitive)", () => {
+ assert.equal(resolveEngine("azure"), ENGINE_AZURE);
+ assert.equal(resolveEngine("Microsoft"), ENGINE_AZURE);
+ assert.equal(resolveEngine("haiku"), HAIKU_MODEL);
+ assert.equal(resolveEngine("SONNET"), SONNET_MODEL);
+});
+
+test("resolveEngine falls back to the default for unknown/empty input", () => {
+ assert.equal(DEFAULT_ENGINE, ENGINE_AZURE);
+ assert.equal(resolveEngine(undefined), DEFAULT_ENGINE);
+ assert.equal(resolveEngine(null), DEFAULT_ENGINE);
+ assert.equal(resolveEngine(""), DEFAULT_ENGINE);
+ assert.equal(resolveEngine("gpt-4"), DEFAULT_ENGINE);
+});
+
+test("Azure falls back to Claude Haiku", () => {
+ assert.equal(AZURE_FALLBACK_MODEL, HAIKU_MODEL);
+});
diff --git a/api-backend/src/config/constants.ts b/api-backend/src/config/constants.ts
index f047269..5a1e38e 100644
--- a/api-backend/src/config/constants.ts
+++ b/api-backend/src/config/constants.ts
@@ -1,8 +1,51 @@
export const LIMITS = {
+ // Free-tier daily CHARACTER cap for /translate (pro users are unlimited).
+ // Enforced per-user in enforceFreeLimit("translation").
DAILY_TRANSLATION_CHARS: 5000,
- DAILY_GRAMMAR_CHARS: 3000,
- MAX_TEXT_LENGTH: 1000,
+ // Free-tier daily MESSAGE cap for /chat (pro users are unlimited).
+ // Enforced per-user in enforceFreeLimit("chat").
+ FREE_DAILY_CHAT_MESSAGES: 100,
+ // Hard per-request input cap, applied GLOBALLY to every text-bearing field
+ // (text / message / query / documentContext) on every endpoint. This is the
+ // abuse guard against someone pasting a whole document into a single
+ // chat/translate call and burning paid AI tokens. Single source of truth —
+ // the Joi schema, the chat check and the global middleware all read it.
+ MAX_INPUT_CHARS: 2000,
MIN_TEXT_LENGTH: 1,
};
+/**
+ * Abuse / spend controls for the paid AI endpoints (/translate, /chat).
+ * `userid` is a client-supplied header (not an authenticated identity), so the
+ * per-user free caps only shape honest traffic — these IP- and project-level
+ * limits are what actually bound the bill against a scripted/anonymous caller.
+ * All values are env-overridable so you can tune without a code change.
+ */
+function envInt(name: string, fallback: number): number {
+ const raw = process.env[name];
+ const n = raw ? parseInt(raw, 10) : NaN;
+ return Number.isFinite(n) && n > 0 ? n : fallback;
+}
+
+export const RATE = {
+ // Per-IP, per-minute burst cap on the AI endpoints.
+ AI_PER_MINUTE: envInt("AI_RATE_PER_MINUTE", 20),
+ // Per-IP, per-DAY ceiling on the AI endpoints — bounds a single IP even if it
+ // rotates the `userid` header to dodge the per-user free caps.
+ AI_PER_DAY_PER_IP: envInt("AI_RATE_PER_DAY_PER_IP", 600),
+ // Project-wide hard ceiling on total AI calls per UTC day. The kill-switch:
+ // once hit, every AI request gets a clean 503 until the next day, so a
+ // distributed (many-IP) attack can never run the bill past this point. Set
+ // generously above real daily usage; tune via env GLOBAL_DAILY_AI_LIMIT.
+ GLOBAL_DAILY_AI_CALLS: envInt("GLOBAL_DAILY_AI_LIMIT", 20000),
+ // For anonymous / unidentified callers we still apply the free daily caps,
+ // but keyed by IP (see enforceFreeLimit) so they can't get unlimited access.
+ ANON_DAILY_TRANSLATION_CHARS: envInt("ANON_DAILY_TRANSLATION_CHARS", 2000),
+ ANON_DAILY_CHAT_MESSAGES: envInt("ANON_DAILY_CHAT_MESSAGES", 20),
+};
+
export const SUPPORTED_LANGUAGES = ["en", "bo", "zh-CN"];
+
+// The RevenueCat entitlement that grants pro access. Must match the app
+// (RevenueCatManager.PREMIUM_ENTITLEMENT_ID) and the RevenueCat dashboard.
+export const PRO_ENTITLEMENT_ID = "pro";
diff --git a/api-backend/src/index.ts b/api-backend/src/index.ts
index 8ef1947..9da647f 100644
--- a/api-backend/src/index.ts
+++ b/api-backend/src/index.ts
@@ -5,35 +5,35 @@ import express from "express";
import cors from "cors";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
-import { checkGrammar } from "./services/grammarService";
import advancedGrammarService from "./services/advancedGrammarService";
import transliterationService from "./services/transliterationService";
-import {
- validateApiKey,
- checkUserLimits,
- updateUserUsage,
-} from "./middleware/auth";
import {
validateTranslateRequest,
- validateGrammarRequest,
validateChatRequest,
+ enforceGlobalCharLimit,
} from "./middleware/validation";
import { errorHandler } from "./middleware/errorHandler";
import {
ApiResponse,
TranslateRequest,
- GrammarRequest,
GeminiChatRequest,
GeminiChatResponse,
} from "./types";
-import { translateText } from "./services/translationService";
-import { freeTranslateText } from "./services/freeTranslationService";
import { log } from "console";
import {
ChatSessionManager,
generateSessionId,
} from "./manager/ChatSessionManager";
-import { user } from "firebase-functions/v1/auth";
+import { resolveModel } from "./services/anthropicService";
+import { resolveEngine, translate } from "./services/translateProvider";
+import { cachedCompute, makeCacheKey } from "./services/cacheService";
+import { attachProStatus, enforceFreeLimit } from "./middleware/proStatus";
+import { enforceGlobalDailyBudget } from "./middleware/globalBudget";
+import { sendFailure, USER_MESSAGES } from "./middleware/responses";
+import { RATE } from "./config/constants";
+import { revenueCatWebhook } from "./webhooks/revenueCatWebhook";
+import { defineSecret } from "firebase-functions/params";
+import * as logger from "firebase-functions/logger";
// Set global options for all functions
setGlobalOptions({
@@ -46,8 +46,41 @@ setGlobalOptions({
// Initialize Firebase Admin
admin.initializeApp();
+// Anthropic API key — set with: firebase functions:secrets:set ANTHROPIC_API_KEY
+const anthropicApiKey = defineSecret("ANTHROPIC_API_KEY");
+
+// Azure Translator subscription key — powers the "azure" translation engine.
+// Set with: firebase functions:secrets:set AZURE_TRANSLATOR_KEY
+// Region/endpoint are non-secret and read from env (see .env), defaulting to
+// eastus + the global text endpoint when unset.
+const azureTranslatorKey = defineSecret("AZURE_TRANSLATOR_KEY");
+
+// RevenueCat secrets:
+// - REVENUECAT_WEBHOOK_SECRET: the "Authorization header value" you set in the
+// RevenueCat dashboard webhook config; we reject any webhook call that doesn't
+// send exactly this value in its Authorization header.
+// - REVENUECAT_API_KEY: a RevenueCat *secret* REST key (sk_...) used only as a
+// live fallback when the Firestore pro mirror says "not pro".
+// Set both with: firebase functions:secrets:set REVENUECAT_WEBHOOK_SECRET
+// firebase functions:secrets:set REVENUECAT_API_KEY
+const revenueCatWebhookSecret = defineSecret("REVENUECAT_WEBHOOK_SECRET");
+const revenueCatApiKey = defineSecret("REVENUECAT_API_KEY");
+
+// Reusable middleware that sets req.isPro (Firestore mirror + RC REST fallback).
+const attachPro = attachProStatus(() => {
+ try {
+ return revenueCatApiKey.value();
+ } catch {
+ return undefined;
+ }
+});
+
const app = express();
+// Behind the Cloud Functions / Hosting proxy, trust the first forwarded hop so
+// express-rate-limit sees the real client IP.
+app.set("trust proxy", 1);
+
// Security middleware
app.use(helmet());
app.use(
@@ -59,6 +92,22 @@ app.use(
})
);
+// RevenueCat webhook — registered BEFORE the global IP rate limiter (it's
+// authenticated by a shared secret, and renewal spikes shouldn't be throttled)
+// and with its own JSON parser (RevenueCat payloads can exceed the 10kb cap we
+// apply to the AI endpoints).
+app.post(
+ "/revenuecat-webhook",
+ express.json({ limit: "64kb" }),
+ revenueCatWebhook(() => {
+ try {
+ return revenueCatWebhookSecret.value();
+ } catch {
+ return undefined;
+ }
+ })
+);
+
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
@@ -71,94 +120,163 @@ const limiter = rateLimit({
app.use(limiter);
app.use(express.json({ limit: "10kb" }));
+// Global hard cap on user-typed input (text / message / query / documentContext)
+// for EVERY endpoint — the front-line guard against pasting a whole document
+// into one call to burn paid AI tokens. Runs after JSON parsing, before routes.
+app.use(enforceGlobalCharLimit);
+
+// Stricter per-IP limiter for the AI (Claude/Azure) endpoints — protects spend
+// and blocks burst abuse on the paid provider calls.
+const aiLimiter = rateLimit({
+ windowMs: 60 * 1000, // 1 minute
+ max: RATE.AI_PER_MINUTE,
+ message: {
+ success: false,
+ error: "rate_limited",
+ message:
+ "You're sending requests too quickly. Please slow down and try again in a moment.",
+ },
+ standardHeaders: true,
+ legacyHeaders: false,
+});
+
+// Per-IP DAILY ceiling on the AI endpoints. The per-minute limiter stops bursts;
+// this bounds a single IP that drips requests all day (e.g. rotating the
+// `userid` header to dodge the per-user free caps).
+const aiDailyLimiter = rateLimit({
+ windowMs: 24 * 60 * 60 * 1000, // 24 hours
+ max: RATE.AI_PER_DAY_PER_IP,
+ message: {
+ success: false,
+ error: "rate_limited",
+ message:
+ "You've reached the daily request limit for this device. Please try again tomorrow.",
+ },
+ standardHeaders: true,
+ legacyHeaders: false,
+});
+
// Health check endpoint
app.get("/health", (req, res) => {
res.json({ status: "healthy", timestamp: new Date().toISOString() });
});
-// Translation endpoint
+// Translation endpoint — Claude-powered, cached (memory -> Firestore -> Claude).
app.post(
"/translate",
- //validateApiKey,
+ aiLimiter,
+ aiDailyLimiter,
validateTranslateRequest,
- //checkUserLimits("translation"),
+ attachPro,
+ enforceFreeLimit("translation"),
+ enforceGlobalDailyBudget,
async (req, res) => {
try {
- console.log("Received translation request:", req.body);
-
const { text, sourceLang, targetLang }: TranslateRequest = req.body;
- //const userId = (req as any).userId;
- const userId = req.headers["userid"] as string;
-
- console.log(
- `Translating text for user ${userId} from ${sourceLang} to ${targetLang}`
+ const userId = (req.headers["userid"] as string) || "anonymous";
+ // The client sends the chosen translation engine in `model` (azure /
+ // claude-haiku-4-5 / claude-sonnet-4-6); x-model header is a fallback.
+ const engine = resolveEngine(
+ (req.body.model as string) || (req.headers["x-model"] as string)
);
- //const translatedText = await translateText(text, from, to);
- const translatedText = await freeTranslateText(
+ logger.info("Translate request", {
+ userId,
+ isPro: (req as any).isPro === true,
+ engine,
+ sourceLang,
+ targetLang,
+ chars: text.length,
+ });
+
+ const cacheKey = makeCacheKey("translate", {
+ engine,
text,
sourceLang,
- targetLang
+ targetLang,
+ });
+
+ const { value: translatedText, source } = await cachedCompute(
+ cacheKey,
+ async () =>
+ (await translate(engine, text, sourceLang, targetLang)).text,
+ { type: "translate", engine, sourceLang, targetLang }
);
- console.log("Translation successful:", translatedText);
- // Update user usage
- //await updateUserUsage(userId, "translation", text.length);
+ logger.info("Translate success", { userId, engine, source });
+
+ // `remainingCredits` is only set for tracked (non-pro, identified/anon)
+ // users; pro users are unmetered. Report it only when we actually have it.
+ const remainingCredits = (req as any).remainingCredits;
+ const usage =
+ typeof remainingCredits === "number"
+ ? {
+ charactersUsed: text.length,
+ remainingCharacters: Math.max(0, remainingCredits),
+ }
+ : { charactersUsed: text.length };
const response: ApiResponse<{ translatedText: string }> = {
success: true,
data: { translatedText },
- usage: {
- charactersUsed: text.length,
- remainingCharacters: (req as any).remainingCredits - text.length,
- },
+ usage,
};
res.json(response);
} catch (error) {
- console.error("Translation error:", error);
- res.status(500).json({
- success: false,
- error: "Translation failed",
- message: error instanceof Error ? error.message : "Unknown error",
+ // Log the real error; return only a clean, user-facing message.
+ sendFailure(res, 500, {
+ error: "translation_failed",
+ message: USER_MESSAGES.TRANSLATE_FAILED,
+ cause: error,
+ logContext: { path: "/translate" },
});
}
}
);
-// Chat with gemini
-
+// Chat with Claude (Tibetan assistant). Model is user-switchable per request.
app.post(
"/chat",
- //validateApiKey,
+ aiLimiter,
+ aiDailyLimiter,
validateChatRequest,
- //checkUserLimits("translation"),
+ attachPro,
+ enforceFreeLimit("chat"),
+ enforceGlobalDailyBudget,
async (req, res) => {
try {
- console.log("Received chat request:", req.body);
-
const { message, sessionId, resetChat }: GeminiChatRequest = req.body;
const userId = req.headers["userid"] as string;
+ const model = resolveModel(
+ (req.body.model as string) || (req.headers["x-model"] as string)
+ );
// Generate or use provided session ID
//const currentSessionId = sessionId || generateSessionId();
const currentSessionId = userId;
- console.log(
- `Processing chat for user ${userId}, session: ${currentSessionId}`
- );
+ logger.info("Chat request", {
+ userId,
+ isPro: (req as any).isPro === true,
+ sessionId: currentSessionId,
+ model,
+ chars: message.length,
+ resetChat: !!resetChat,
+ });
// Reset chat session if requested
if (resetChat) {
ChatSessionManager.resetSession(currentSessionId);
}
- // Send message to Gemini and get Tibetan response
+ // Send message to Claude and get a Tibetan response
const tibetanResponse = await ChatSessionManager.sendMessage(
currentSessionId,
- message
+ message,
+ { model }
);
- console.log("Chat response generated successfully");
+ logger.info("Chat success", { userId, model });
// Calculate usage
const charactersUsed = message.length + tibetanResponse.length;
@@ -181,13 +299,12 @@ app.post(
res.json(response);
} catch (error) {
- console.error("Chat error:", error);
-
- res.status(500).json({
- success: false,
- error: "Chat failed",
- message:
- error instanceof Error ? error.message : "Unknown error occurred",
+ // Log the real error; return only a clean, user-facing message.
+ sendFailure(res, 500, {
+ error: "chat_failed",
+ message: USER_MESSAGES.CHAT_FAILED,
+ cause: error,
+ logContext: { path: "/chat" },
});
}
}
@@ -220,49 +337,12 @@ app.post("/chat/reset", async (req, res) => {
}
});
-// Grammar check endpoint
-app.post(
- "/grammar",
- //validateApiKey,
- validateGrammarRequest,
- checkUserLimits("grammar"),
- async (req, res) => {
- try {
- const { text }: GrammarRequest = req.body;
- const userId = (req as any).userId;
-
- const grammarResult = await checkGrammar(text);
-
- // Update user usage
- //await updateUserUsage(userId, "grammar", text.length);
-
- const response: ApiResponse = {
- success: true,
- data: grammarResult,
- usage: {
- charactersUsed: text.length,
- remainingCharacters: (req as any).remainingCredits - text.length,
- },
- };
-
- res.json(response);
- } catch (error) {
- console.error("Grammar check error:", error);
- res.status(500).json({
- success: false,
- error: "Grammar check failed",
- message: error instanceof Error ? error.message : "Unknown error",
- });
- }
- }
-);
-
// ============================================
// NEW PREMIUM FEATURE ENDPOINTS
// ============================================
// Advanced Tibetan Grammar Analysis Endpoint
-app.post("/api/grammar/analyze", async (req, res) => {
+app.post("/api/grammar/analyze", aiLimiter, async (req, res) => {
try {
const { text, mode = "realtime", style = "formal", contextualInfo } = req.body;
const userId = req.headers["userid"] as string;
@@ -320,7 +400,7 @@ app.post("/api/grammar/analyze", async (req, res) => {
});
// Tone Alternatives Endpoint
-app.post("/api/grammar/suggestions", async (req, res) => {
+app.post("/api/grammar/suggestions", aiLimiter, async (req, res) => {
try {
const { text, correctionId, type } = req.body;
@@ -458,7 +538,7 @@ app.post("/api/transliterate/database/lookup", async (req, res) => {
});
// Enhanced Chat with Tutoring Support
-app.post("/api/chat/message", async (req, res) => {
+app.post("/api/chat/message", aiLimiter, async (req, res) => {
try {
const {
sessionId,
@@ -487,11 +567,14 @@ app.post("/api/chat/message", async (req, res) => {
documentContext
);
- // Send message to Gemini
+ // Send message to Claude
+ const model = resolveModel(
+ (req.body.model as string) || (req.headers["x-model"] as string)
+ );
const tibetanResponse = await ChatSessionManager.sendMessage(
currentSessionId,
message,
- conversationMode as any
+ { model, mode: conversationMode as any }
);
// Save message to Firestore
@@ -578,7 +661,7 @@ app.get("/api/chat/history", async (req, res) => {
}
const snapshot = await query.get();
- const conversations = snapshot.docs.map((doc) => ({
+ const conversations = snapshot.docs.map((doc: any) => ({
conversationId: doc.id,
...doc.data(),
}));
@@ -644,6 +727,12 @@ export const api = onRequest(
maxInstances: 10,
timeoutSeconds: 60,
memory: "256MiB",
+ secrets: [
+ anthropicApiKey,
+ azureTranslatorKey,
+ revenueCatWebhookSecret,
+ revenueCatApiKey,
+ ],
// Add additional options if needed
// invoker: 'public', // Makes function publicly accessible
// secrets: [], // Add secrets if needed
diff --git a/api-backend/src/manager/ChatSessionManager.ts b/api-backend/src/manager/ChatSessionManager.ts
index 9d9f246..28d2eab 100644
--- a/api-backend/src/manager/ChatSessionManager.ts
+++ b/api-backend/src/manager/ChatSessionManager.ts
@@ -1,9 +1,17 @@
-import { GoogleGenerativeAI } from "@google/generative-ai";
+import {
+ ClaudeMessage,
+ createMessage,
+ resolveModel,
+ DEFAULT_MODEL,
+} from "../services/anthropicService";
+
+type ChatMode = "general" | "tutoring" | "translation";
+type TutoringLevel = "beginner" | "intermediate" | "advanced";
interface ChatSession {
- chat: any;
- mode: "general" | "tutoring" | "translation";
- tutoringLevel?: "beginner" | "intermediate" | "advanced";
+ history: ClaudeMessage[];
+ mode: ChatMode;
+ tutoringLevel?: TutoringLevel;
documentContext?: string;
createdAt: Date;
}
@@ -15,88 +23,88 @@ interface TutoringCurriculum {
focusAreas: string[];
}
+interface SendOptions {
+ model?: string;
+ mode?: ChatMode;
+}
+
+// Keep conversation context bounded to control token usage.
+const MAX_HISTORY_MESSAGES = 20;
+
class ChatSessionManager {
private static sessions = new Map();
- private static genAI = new GoogleGenerativeAI(
- process.env.GEMINI_API_KEY || "AIzaSyCxUMaoBVH5SIII7Wa0uQYvjrjI9IjV9cg"
- );
- static async getOrCreateSession(
+ static getOrCreateSession(
sessionId: string,
- mode: "general" | "tutoring" | "translation" = "general",
- tutoringLevel?: "beginner" | "intermediate" | "advanced",
+ mode: ChatMode = "general",
+ tutoringLevel?: TutoringLevel,
documentContext?: string
- ) {
- if (!this.sessions.has(sessionId)) {
- const model = this.genAI.getGenerativeModel({
- model: "gemini-2.0-flash",
- generationConfig: {
- temperature: 0.7,
- topK: 40,
- topP: 0.95,
- maxOutputTokens: 800,
- },
- });
-
- const chat = model.startChat();
-
- // Generate system instructions based on mode
- const systemInstructions = this.getSystemInstructions(
+ ): ChatSession {
+ let session = this.sessions.get(sessionId);
+ if (!session) {
+ session = {
+ history: [],
mode,
tutoringLevel,
- documentContext
- );
-
- try {
- // Initialize with system instructions
- await chat.sendMessage(systemInstructions);
- this.sessions.set(sessionId, {
- chat,
- mode,
- tutoringLevel,
- documentContext,
- createdAt: new Date(),
- });
- console.log(`New chat session created: ${sessionId}, mode: ${mode}`);
- } catch (error) {
- console.error("Failed to initialize chat session:", error);
- throw new Error("Failed to initialize chat session");
- }
+ documentContext,
+ createdAt: new Date(),
+ };
+ this.sessions.set(sessionId, session);
+ console.log(`New chat session created: ${sessionId}, mode: ${mode}`);
}
-
- return this.sessions.get(sessionId)?.chat;
+ return session;
}
static async sendMessage(
sessionId: string,
message: string,
- mode?: "general" | "tutoring" | "translation"
+ options: SendOptions = {}
): Promise {
- try {
- const chat = await this.getOrCreateSession(sessionId, mode);
- const result = await chat.sendMessage(message);
- let response = result.response.text().trim();
+ const session = this.getOrCreateSession(sessionId, options.mode);
+ if (options.mode) session.mode = options.mode;
+
+ const model = resolveModel(options.model);
+ const system = this.getSystemInstructions(
+ session.mode,
+ session.tutoringLevel,
+ session.documentContext
+ );
- // Fallback for empty responses
+ const userTurn: ClaudeMessage = { role: "user", content: message };
+ const requestMessages = [...session.history, userTurn].slice(
+ -MAX_HISTORY_MESSAGES
+ );
+
+ try {
+ // Chat is intentionally NOT cached: every turn is context-dependent, so we
+ // always call the AI for a fresh, conversation-correct reply. (Translation
+ // is deterministic and still uses the cache in index.ts.)
+ let response = await createMessage(model, system, requestMessages, 1024);
if (!response || response.length === 0) {
response =
"དགོངས་དག། ལན་འདེབས་དཀའ་ངལ་འཕྲད་སོང་། ཡང་བསྐྱར་འབད་བརྩོན་གནང་རོགས།";
}
+ console.log(`Chat (${model}) responded`);
+
+ // Persist the turn for conversation continuity.
+ const assistantTurn: ClaudeMessage = { role: "assistant", content: response };
+ session.history = [...requestMessages, assistantTurn].slice(
+ -MAX_HISTORY_MESSAGES
+ );
return response;
} catch (error) {
- console.error("Error sending message to Gemini:", error);
- // Return Tibetan error message
+ console.error("Error sending message to Claude:", error);
return "དགོངས་དག། ཕྱི་ཕྱོགས་དང་འབྲེལ་བའི་དཀའ་ངལ་ཞིག་འཕྲད་སོང་། ཡང་བསྐྱར་འབད་བརྩོན་གནང་རོགས།";
}
}
- static resetSession(sessionId: string) {
+ static resetSession(sessionId: string): void {
this.sessions.delete(sessionId);
console.log(`Chat session reset: ${sessionId}`);
}
- static removeSession(sessionId: string) {
+ static removeSession(sessionId: string): void {
this.sessions.delete(sessionId);
}
@@ -106,33 +114,36 @@ class ChatSessionManager {
static updateSessionMode(
sessionId: string,
- mode: "general" | "tutoring" | "translation",
- tutoringLevel?: "beginner" | "intermediate" | "advanced"
- ) {
- const session = this.sessions.get(sessionId);
- if (session) {
- session.mode = mode;
- session.tutoringLevel = tutoringLevel;
- }
+ mode: ChatMode,
+ tutoringLevel?: TutoringLevel
+ ): void {
+ const session = this.getOrCreateSession(sessionId);
+ session.mode = mode;
+ session.tutoringLevel = tutoringLevel;
}
- static setDocumentContext(sessionId: string, context: string) {
- const session = this.sessions.get(sessionId);
- if (session) {
- session.documentContext = context;
- }
+ static setDocumentContext(sessionId: string, context: string): void {
+ const session = this.getOrCreateSession(sessionId);
+ session.documentContext = context;
}
private static getSystemInstructions(
- mode: "general" | "tutoring" | "translation",
- tutoringLevel?: "beginner" | "intermediate" | "advanced",
+ mode: ChatMode,
+ tutoringLevel?: TutoringLevel,
documentContext?: string
): string {
- let baseInstructions = `Instructions:
+ const baseInstructions = `Instructions:
- You are Lundup, an expert in Tibetan language and culture.
- You must respond ONLY in Tibetan script (བོད་ཡིག་).
- Do not respond in any language other than Tibetan script.
-- Be respectful and culturally sensitive.`;
+- Be respectful and culturally sensitive.
+- Answer the user's actual question directly and precisely. Stay strictly on topic.
+- Be concise: respond in as few words as the question needs. For a greeting or a
+ simple question, reply with a single short sentence.
+- Do NOT add preambles, filler, restatements of the question, disclaimers, or
+ follow-up offers unless the user asks for them.
+- Only give a long, detailed answer when the user explicitly asks to explain,
+ elaborate, or for a list/steps.`;
let modeSpecific = "";
@@ -178,10 +189,9 @@ class ChatSessionManager {
case "general":
default:
modeSpecific = `\n\nGeneral Chat Mode:
-- Engage in helpful, friendly conversation.
-- Answer questions about Tibetan language and culture.
-- Provide information and guidance.
-- Be conversational and natural.`;
+- Answer questions about Tibetan language and culture directly and to the point.
+- Keep replies short and natural; match the length of the response to the question.
+- Skip greetings-padding, summaries, and "let me know if…" closers.`;
}
let documentInfo = "";
@@ -195,8 +205,8 @@ Reference this context when relevant to their questions.`;
return baseInstructions + modeSpecific + documentInfo;
}
- static getTutoringCurriculum(level: "beginner" | "intermediate" | "advanced"): TutoringCurriculum {
- const curricula = {
+ static getTutoringCurriculum(level: TutoringLevel): TutoringCurriculum {
+ const curricula: Record = {
beginner: {
currentLesson: "lesson_1_alphabet",
nextLesson: "lesson_2_basic_verbs",
@@ -207,13 +217,23 @@ Reference this context when relevant to their questions.`;
currentLesson: "lesson_5_complex_grammar",
nextLesson: "lesson_6_idioms",
progressPercentage: 50,
- focusAreas: ["complex_grammar", "verb_forms", "idiomatic_expressions", "writing_styles"],
+ focusAreas: [
+ "complex_grammar",
+ "verb_forms",
+ "idiomatic_expressions",
+ "writing_styles",
+ ],
},
advanced: {
currentLesson: "lesson_10_classical_tibetan",
nextLesson: "lesson_11_regional_dialects",
progressPercentage: 85,
- focusAreas: ["classical_literature", "dialects", "academic_writing", "cultural_nuances"],
+ focusAreas: [
+ "classical_literature",
+ "dialects",
+ "academic_writing",
+ "cultural_nuances",
+ ],
},
};
@@ -226,4 +246,4 @@ function generateSessionId(): string {
return `chat_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
}
-export { ChatSessionManager, generateSessionId };
+export { ChatSessionManager, generateSessionId, DEFAULT_MODEL };
diff --git a/api-backend/src/middleware/auth.ts b/api-backend/src/middleware/auth.ts
deleted file mode 100644
index f3529ca..0000000
--- a/api-backend/src/middleware/auth.ts
+++ /dev/null
@@ -1,174 +0,0 @@
-import * as admin from "firebase-admin";
-import { Request, Response, NextFunction } from "express";
-import { LIMITS } from "../config/constants";
-
-export async function validateApiKey(
- req: Request,
- res: Response,
- next: NextFunction
-): Promise {
- try {
- const apiKey = req.headers["x-api-key"] as string;
-
- if (!apiKey) {
- res.status(401).json({
- success: false,
- error: "API key required",
- message: "Please provide a valid API key in the x-api-key header",
- });
- return;
- }
-
- // Verify API key against your authentication system
- // This could be JWT token validation, database lookup, etc.
- const userId = await verifyApiKey(apiKey);
-
- if (!userId) {
- res.status(401).json({
- success: false,
- error: "Invalid API key",
- message: "The provided API key is invalid or expired",
- });
- return;
- }
-
- (req as any).userId = userId;
- next();
- } catch (error) {
- console.error("Auth error:", error);
- res.status(500).json({
- success: false,
- error: "Authentication failed",
- message: "Internal authentication error",
- });
- }
-}
-
-export function checkUserLimits(serviceType: "translation" | "grammar") {
- return async (
- req: Request,
- res: Response,
- next: NextFunction
- ): Promise => {
- try {
- const userId = (req as any).userId;
- const textLength = req.body.text?.length || 0;
-
- const userLimits = await getUserLimits(userId);
- const dailyLimit =
- serviceType === "translation"
- ? LIMITS.DAILY_TRANSLATION_CHARS
- : LIMITS.DAILY_GRAMMAR_CHARS;
-
- const usedToday =
- serviceType === "translation"
- ? userLimits.translationUsed
- : userLimits.grammarUsed;
-
- if (usedToday + textLength > dailyLimit) {
- res.status(429).json({
- success: false,
- error: "Daily limit exceeded",
- message: `Daily ${serviceType} limit of ${dailyLimit} characters exceeded`,
- usage: {
- used: usedToday,
- limit: dailyLimit,
- remaining: Math.max(0, dailyLimit - usedToday),
- },
- });
- return;
- }
-
- (req as any).remainingCredits = dailyLimit - usedToday;
- next();
- } catch (error) {
- console.error("Limit check error:", error);
- res.status(500).json({
- success: false,
- error: "Limit check failed",
- message: "Unable to verify usage limits",
- });
- }
- };
-}
-
-async function verifyApiKey(apiKey: string): Promise {
- try {
- // Simple API key verification - replace with your preferred method
- // This could be JWT verification, database lookup, etc.
-
- const db = admin.firestore();
- const apiKeyDoc = await db.collection("api_keys").doc(apiKey).get();
-
- if (!apiKeyDoc.exists) {
- return null;
- }
-
- const keyData = apiKeyDoc.data();
- if (
- !keyData?.active ||
- (keyData.expiresAt && keyData.expiresAt.toDate() < new Date())
- ) {
- return null;
- }
-
- return keyData.userId;
- } catch (error) {
- console.error("API key verification error:", error);
- return null;
- }
-}
-
-async function getUserLimits(userId: string) {
- const db = admin.firestore();
- const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
-
- const userDoc = await db.collection("users").doc(userId).get();
-
- if (!userDoc.exists) {
- // Create new user with default limits
- const defaultLimits = {
- translationLimit: LIMITS.DAILY_TRANSLATION_CHARS,
- grammarLimit: LIMITS.DAILY_GRAMMAR_CHARS,
- translationUsed: 0,
- grammarUsed: 0,
- resetDate: today,
- };
-
- await db.collection("users").doc(userId).set(defaultLimits);
- return defaultLimits;
- }
-
- const userData = userDoc.data();
-
- // Reset limits if it's a new day
- if (userData?.resetDate !== today) {
- const resetLimits = {
- ...userData,
- translationUsed: 0,
- grammarUsed: 0,
- resetDate: today,
- };
-
- await db.collection("users").doc(userId).update(resetLimits);
- return resetLimits;
- }
-
- return userData;
-}
-
-export async function updateUserUsage(
- userId: string,
- serviceType: "translation" | "grammar",
- charactersUsed: number
-) {
- const db = admin.firestore();
- const userRef = db.collection("users").doc(userId);
-
- const updateField =
- serviceType === "translation" ? "translationUsed" : "grammarUsed";
-
- await userRef.update({
- [updateField]: admin.firestore.FieldValue.increment(charactersUsed),
- });
-}
diff --git a/api-backend/src/middleware/globalBudget.ts b/api-backend/src/middleware/globalBudget.ts
new file mode 100644
index 0000000..2538262
--- /dev/null
+++ b/api-backend/src/middleware/globalBudget.ts
@@ -0,0 +1,73 @@
+import * as admin from "firebase-admin";
+import { Request, Response, NextFunction } from "express";
+import * as logger from "firebase-functions/logger";
+import { RATE } from "../config/constants";
+import { sendFailure, USER_MESSAGES } from "./responses";
+
+/**
+ * Project-wide daily kill-switch for the paid AI endpoints.
+ *
+ * Per-IP limits don't stop a distributed (many-IP) attack — this does. We keep a
+ * single counter per UTC day in `system_usage/{YYYY-MM-DD}` and refuse all AI
+ * traffic once it crosses RATE.GLOBAL_DAILY_AI_CALLS, so the bill has a hard
+ * ceiling no matter how the load is spread.
+ *
+ * Cost: one read + one increment per AI request. The counter is read BEFORE the
+ * provider call (to block) and incremented in the same step. Fails OPEN — a
+ * Firestore blip must not take the whole API down — because the per-IP limiter
+ * and `maxInstances` still bound spend in that window.
+ */
+const COLLECTION = "system_usage";
+
+function todayUtc(): string {
+ return new Date().toISOString().split("T")[0]; // YYYY-MM-DD (UTC)
+}
+
+export async function enforceGlobalDailyBudget(
+ _req: Request,
+ res: Response,
+ next: NextFunction
+): Promise {
+ const db = admin.firestore();
+ const ref = db.collection(COLLECTION).doc(todayUtc());
+
+ try {
+ const snap = await ref.get();
+ const used = (snap.data()?.aiCalls as number) || 0;
+
+ if (used >= RATE.GLOBAL_DAILY_AI_CALLS) {
+ logger.error("global-budget: BLOCKED (daily AI ceiling reached)", {
+ used,
+ limit: RATE.GLOBAL_DAILY_AI_CALLS,
+ });
+ sendFailure(res, 503, {
+ error: "service_unavailable",
+ message: USER_MESSAGES.SERVER_BUSY,
+ });
+ return;
+ }
+
+ // Count this request. Best-effort — don't block the user on the write.
+ ref
+ .set(
+ {
+ aiCalls: admin.firestore.FieldValue.increment(1),
+ updatedAt: admin.firestore.FieldValue.serverTimestamp(),
+ },
+ { merge: true }
+ )
+ .catch((err) =>
+ logger.warn("global-budget: counter increment failed (non-fatal)", {
+ error: err instanceof Error ? err.message : String(err),
+ })
+ );
+
+ next();
+ } catch (error) {
+ // Fail open: the per-IP limiter + maxInstances still cap spend.
+ logger.warn("global-budget: read failed, allowing request (fail-open)", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ next();
+ }
+}
diff --git a/api-backend/src/middleware/proStatus.ts b/api-backend/src/middleware/proStatus.ts
new file mode 100644
index 0000000..901d98a
--- /dev/null
+++ b/api-backend/src/middleware/proStatus.ts
@@ -0,0 +1,256 @@
+import * as admin from "firebase-admin";
+import { Request, Response, NextFunction } from "express";
+import * as logger from "firebase-functions/logger";
+import { getProFromRevenueCat } from "../services/revenueCatService";
+import { LIMITS, RATE } from "../config/constants";
+
+/**
+ * Backend pro-status checks.
+ *
+ * RevenueCat is the source of truth. The ONLY writer of pro state into Firestore
+ * is this backend (via the Admin SDK): the RevenueCat webhook on every event,
+ * plus the REST fallback below. The app never writes these fields, so there's a
+ * single, clean, server-owned namespace on `users/{uid}`:
+ *
+ * isPro: boolean
+ * proExpiresAt: Timestamp | null (null = lifetime / non-expiring)
+ * proUpdatedAt: Timestamp (audit only)
+ *
+ * As a safety net we hit the RevenueCat REST API live when the mirror says
+ * "not pro" — this covers the brief window between a purchase completing and the
+ * webhook arriving, so we never depend on the client telling us it paid.
+ *
+ * Logging: every line carries `userId`, so in Google Cloud Logging you can
+ * filter `jsonPayload.userId=""` and read the whole story for one user.
+ * All messages are prefixed `pro-status:` for easy text search.
+ */
+
+type Doc = FirebaseFirestore.DocumentData | undefined;
+
+/** How a user's pro status was decided — handy when tracing in the logs. */
+export type ProSource = "mirror" | "rc_fallback" | "none";
+
+export interface ProResolution {
+ isPro: boolean;
+ source: ProSource;
+}
+
+function toMillis(value: unknown): number {
+ if (value == null) return Number.POSITIVE_INFINITY; // no expiry => never expires
+ if (typeof (value as any)?.toMillis === "function") return (value as any).toMillis();
+ if (typeof value === "number") return value;
+ const parsed = new Date(value as string).getTime();
+ return Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY;
+}
+
+/** Is the Firestore mirror currently flagging this user as pro (and unexpired)? */
+function isProFromMirror(data: Doc): boolean {
+ if (data?.isPro !== true) return false;
+ // proExpiresAt null/absent => non-expiring (lifetime) => +Infinity => true.
+ return toMillis(data.proExpiresAt) > Date.now();
+}
+
+/**
+ * Resolve pro status for a user: fast Firestore mirror first, then a live
+ * RevenueCat REST fallback (and backfill the mirror on a fallback hit so the
+ * next request is a cheap read). Returns both the answer and how it was
+ * reached, so callers can log a single, traceable line.
+ */
+export async function isUserPro(
+ userId: string,
+ rcApiKey?: string
+): Promise {
+ if (!userId || userId === "anonymous") {
+ return { isPro: false, source: "none" };
+ }
+ const db = admin.firestore();
+
+ try {
+ const doc = await db.collection("users").doc(userId).get();
+ if (isProFromMirror(doc.data())) {
+ return { isPro: true, source: "mirror" };
+ }
+ } catch (error) {
+ logger.warn("pro-status: Firestore mirror read failed", {
+ userId,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+
+ if (rcApiKey) {
+ try {
+ const rc = await getProFromRevenueCat(userId, rcApiKey);
+ if (rc.isPro) {
+ // Webhook hasn't landed yet — backfill so the next read is cheap.
+ await db.collection("users").doc(userId).set(
+ {
+ isPro: true,
+ proExpiresAt: rc.expiresAtMs
+ ? admin.firestore.Timestamp.fromMillis(rc.expiresAtMs)
+ : null,
+ proUpdatedAt: admin.firestore.FieldValue.serverTimestamp(),
+ },
+ { merge: true }
+ );
+ logger.info("pro-status: granted via RevenueCat REST fallback (mirror was stale)", {
+ userId,
+ });
+ return { isPro: true, source: "rc_fallback" };
+ }
+ } catch (error) {
+ logger.warn("pro-status: RevenueCat REST fallback failed", {
+ userId,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+
+ return { isPro: false, source: "none" };
+}
+
+/**
+ * Express middleware that sets `req.isPro` and `req.userId`.
+ * Pass a getter so the secret key is read lazily at request time.
+ */
+export function attachProStatus(getRcApiKey: () => string | undefined) {
+ return async (
+ req: Request,
+ _res: Response,
+ next: NextFunction
+ ): Promise => {
+ const userId =
+ ((req as any).userId as string) ||
+ (req.headers["userid"] as string) ||
+ "anonymous";
+ (req as any).userId = userId;
+
+ try {
+ const { isPro, source } = await isUserPro(userId, getRcApiKey());
+ (req as any).isPro = isPro;
+ logger.info("pro-status: resolved", {
+ userId,
+ path: req.path,
+ isPro,
+ source,
+ });
+ } catch (error) {
+ (req as any).isPro = false;
+ logger.error("pro-status: check threw, treating user as free", {
+ userId,
+ path: req.path,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ next();
+ };
+}
+
+/** Firestore-safe doc id from a client IP (no "/" allowed in doc ids). */
+function ipDocId(ip: string | undefined): string {
+ return (ip || "unknown").replace(/[/]/g, "_");
+}
+
+/**
+ * Enforce free-tier daily limits. Pro users (req.isPro) are skipped entirely.
+ * Requires `attachProStatus` to have run first.
+ *
+ * - "chat": counts 1 per message, cap = FREE_DAILY_CHAT_MESSAGES.
+ * - "translation": counts text.length chars, cap = DAILY_TRANSLATION_CHARS.
+ *
+ * Identified users are tracked on `users/{uid}`. Anonymous/unidentified callers
+ * are NOT skipped — they'd otherwise get unlimited paid access by simply omitting
+ * the `userid` header — they're tracked per IP in `anon_usage/{ip}` with the
+ * (lower) anonymous daily caps. Counters reset daily via `resetDate` (UTC date).
+ * Fails OPEN — a Firestore blip won't block users.
+ */
+export function enforceFreeLimit(service: "chat" | "translation") {
+ return async (
+ req: Request,
+ res: Response,
+ next: NextFunction
+ ): Promise => {
+ const userId = (req as any).userId as string;
+
+ if ((req as any).isPro) {
+ // Explicit confirmation that a paying user reached the unmetered branch.
+ logger.info("free-limit: skipped (pro user, unmetered)", { userId, service });
+ next();
+ return;
+ }
+
+ const isAnon = !userId || userId === "anonymous";
+ const usedField = service === "chat" ? "chatUsed" : "translationUsed";
+ const cost = service === "chat" ? 1 : req.body?.text?.length || 0;
+ const limit = isAnon
+ ? service === "chat"
+ ? RATE.ANON_DAILY_CHAT_MESSAGES
+ : RATE.ANON_DAILY_TRANSLATION_CHARS
+ : service === "chat"
+ ? LIMITS.FREE_DAILY_CHAT_MESSAGES
+ : LIMITS.DAILY_TRANSLATION_CHARS;
+
+ const db = admin.firestore();
+ const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD (UTC)
+ const userRef = isAnon
+ ? db.collection("anon_usage").doc(ipDocId(req.ip))
+ : db.collection("users").doc(userId);
+
+ try {
+ const snap = await userRef.get();
+ const data = snap.data() || {};
+ const isNewDay = data.resetDate !== today;
+ const used = isNewDay ? 0 : data[usedField] || 0;
+
+ if (used + cost > limit) {
+ logger.warn("free-limit: BLOCKED (daily cap reached)", {
+ userId,
+ service,
+ used,
+ cost,
+ limit,
+ });
+ res.status(429).json({
+ success: false,
+ error: "Daily free limit reached",
+ message: `You've reached today's free ${service} limit. Upgrade to Pro for unlimited access.`,
+ usage: { used, limit, remaining: Math.max(0, limit - used) },
+ });
+ return;
+ }
+
+ if (isNewDay) {
+ await userRef.set(
+ {
+ resetDate: today,
+ chatUsed: service === "chat" ? cost : 0,
+ translationUsed: service === "translation" ? cost : 0,
+ grammarUsed: 0,
+ },
+ { merge: true }
+ );
+ } else {
+ await userRef.set(
+ { [usedField]: admin.firestore.FieldValue.increment(cost) },
+ { merge: true }
+ );
+ }
+
+ (req as any).remainingCredits = limit - used - cost;
+ logger.debug("free-limit: allowed", {
+ userId,
+ service,
+ used: used + cost,
+ limit,
+ });
+ next();
+ } catch (error) {
+ // Fail open: a Firestore hiccup must not block a paying-adjacent flow.
+ logger.warn("free-limit: check failed, allowing request (fail-open)", {
+ userId,
+ service,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ next();
+ }
+ };
+}
diff --git a/api-backend/src/middleware/responses.ts b/api-backend/src/middleware/responses.ts
new file mode 100644
index 0000000..aae6459
--- /dev/null
+++ b/api-backend/src/middleware/responses.ts
@@ -0,0 +1,51 @@
+import { Response } from "express";
+import * as logger from "firebase-functions/logger";
+
+/**
+ * Standard, user-facing error envelope.
+ *
+ * Two rules, enforced in one place so every endpoint behaves the same:
+ * 1. The client only ever sees a clean, professional `message` we wrote — the
+ * raw provider/internal error (e.g. "ANTHROPIC_API_KEY is not configured",
+ * an Axios stack, a Firestore code) is logged server-side and NEVER sent
+ * back. This avoids leaking internals and gives users a readable message.
+ * 2. The shape is always `{ success: false, error, message }` so the app can
+ * parse it uniformly.
+ */
+export interface FailureOptions {
+ /** Short machine-ish code for the app to branch on (e.g. "rate_limited"). */
+ error: string;
+ /** Clean, user-facing sentence. */
+ message: string;
+ /** The real error to log (never returned to the client). */
+ cause?: unknown;
+ /** Context for the log line (userId, engine, path, …). */
+ logContext?: Record;
+}
+
+export function sendFailure(
+ res: Response,
+ status: number,
+ { error, message, cause, logContext }: FailureOptions
+): void {
+ if (cause !== undefined) {
+ logger.error(`response: ${error}`, {
+ status,
+ ...logContext,
+ cause: cause instanceof Error ? cause.message : String(cause),
+ });
+ }
+ res.status(status).json({ success: false, error, message });
+}
+
+/** Reusable user-facing copy. Keep these clean, calm and non-technical. */
+export const USER_MESSAGES = {
+ TRANSLATE_FAILED:
+ "We couldn't complete the translation right now. Please try again in a moment.",
+ CHAT_FAILED:
+ "We couldn't get a response right now. Please try again in a moment.",
+ SERVER_BUSY:
+ "Our service is experiencing high demand right now. Please try again shortly.",
+ UNEXPECTED:
+ "Something went wrong on our end. Please try again in a moment.",
+} as const;
diff --git a/api-backend/src/middleware/validation.ts b/api-backend/src/middleware/validation.ts
index 16934a8..c0431da 100644
--- a/api-backend/src/middleware/validation.ts
+++ b/api-backend/src/middleware/validation.ts
@@ -2,14 +2,47 @@ import { Request, Response, NextFunction } from "express";
import Joi from "joi";
import { SUPPORTED_LANGUAGES, LIMITS } from "../config/constants";
+// Fields that carry user-typed text across the API. The global cap inspects
+// every one of these on every request body, so no endpoint (including ones that
+// skip the Joi schemas) can be fed an oversized payload.
+const TEXT_FIELDS = ["text", "message", "query", "documentContext"] as const;
+
+/**
+ * Global hard cap on user input length. Mounted once for the whole app, BEFORE
+ * any route handler, so a single oversized field (e.g. a pasted document) is
+ * rejected up front instead of reaching a paid AI provider. Defence-in-depth on
+ * top of the per-endpoint Joi schemas and the 10kb JSON body limit.
+ */
+export function enforceGlobalCharLimit(
+ req: Request,
+ res: Response,
+ next: NextFunction
+): void {
+ const body = req.body;
+ if (body && typeof body === "object") {
+ for (const field of TEXT_FIELDS) {
+ const value = (body as Record)[field];
+ if (typeof value === "string" && value.length > LIMITS.MAX_INPUT_CHARS) {
+ res.status(400).json({
+ success: false,
+ error: "Input too long",
+ message: `'${field}' must not exceed ${LIMITS.MAX_INPUT_CHARS} characters (received ${value.length}).`,
+ });
+ return;
+ }
+ }
+ }
+ next();
+}
+
const translateSchema = Joi.object({
text: Joi.string()
.min(LIMITS.MIN_TEXT_LENGTH)
- .max(LIMITS.MAX_TEXT_LENGTH)
+ .max(LIMITS.MAX_INPUT_CHARS)
.required()
.messages({
"string.min": `Text must be at least ${LIMITS.MIN_TEXT_LENGTH} character`,
- "string.max": `Text must not exceed ${LIMITS.MAX_TEXT_LENGTH} characters`,
+ "string.max": `Text must not exceed ${LIMITS.MAX_INPUT_CHARS} characters`,
"any.required": "Text is required",
}),
sourceLang: Joi.string()
@@ -30,18 +63,7 @@ const translateSchema = Joi.object({
)}`,
"any.required": "Target language is required",
}),
-});
-
-const grammarSchema = Joi.object({
- text: Joi.string()
- .min(LIMITS.MIN_TEXT_LENGTH)
- .max(LIMITS.MAX_TEXT_LENGTH)
- .required()
- .messages({
- "string.min": `Text must be at least ${LIMITS.MIN_TEXT_LENGTH} character`,
- "string.max": `Text must not exceed ${LIMITS.MAX_TEXT_LENGTH} characters`,
- "any.required": "Text is required",
- }),
+ model: Joi.string().optional(),
});
export function validateTranslateRequest(
@@ -49,31 +71,25 @@ export function validateTranslateRequest(
res: Response,
next: NextFunction
): void {
- const { error } = translateSchema.validate(req.body);
-
- if (error) {
+ // Require the userid header to be present (the app always sends it — the
+ // Firebase UID, or "anonymous" when logged out). This rejects header-less
+ // scripted calls; abuse from "anonymous" is still bounded per-IP downstream.
+ const userId = req.headers["userid"];
+ if (userId == null || userId === "" || userId === "undefined") {
res.status(400).json({
success: false,
- error: "Validation error",
- message: error.details[0].message,
+ error: "invalid_request",
+ message: "User ID is required in headers",
});
return;
}
- next();
-}
-
-export function validateGrammarRequest(
- req: Request,
- res: Response,
- next: NextFunction
-): void {
- const { error } = grammarSchema.validate(req.body);
+ const { error } = translateSchema.validate(req.body);
if (error) {
res.status(400).json({
success: false,
- error: "Validation error",
+ error: "validation_error",
message: error.details[0].message,
});
return;
@@ -87,8 +103,6 @@ export function validateChatRequest(
res: Response,
next: NextFunction
): void {
- console.log("Header userId:", req.headers["userid"]);
-
if (req.headers["userid"] == null || req.headers["userid"] === "undefined") {
res.status(400).json({
success: false,
@@ -105,14 +119,16 @@ export function validateChatRequest(
error: "Invalid request",
message: "Message is required and must be a non-empty string",
});
+ return;
}
- if (message.length > 5000) {
+ if (message.length > LIMITS.MAX_INPUT_CHARS) {
res.status(400).json({
success: false,
error: "Message too long",
- message: "Message must be less than 5000 characters",
+ message: `Message must not exceed ${LIMITS.MAX_INPUT_CHARS} characters.`,
});
+ return;
}
next();
diff --git a/api-backend/src/services/advancedGrammarService.ts b/api-backend/src/services/advancedGrammarService.ts
index d5ca773..9e094d1 100644
--- a/api-backend/src/services/advancedGrammarService.ts
+++ b/api-backend/src/services/advancedGrammarService.ts
@@ -53,9 +53,10 @@ class AdvancedGrammarService {
private genAI: GoogleGenerativeAI;
constructor() {
- this.genAI = new GoogleGenerativeAI(
- process.env.GEMINI_API_KEY || "AIzaSyCxUMaoBVH5SIII7Wa0uQYvjrjI9IjV9cg"
- );
+ // Read the key from the environment ONLY. Never hardcode a fallback — a
+ // committed key is a leaked credential (rotate the old one in Google AI
+ // Studio). If unset, calls throw and the route returns a clean error.
+ this.genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || "");
}
async analyzeTibetanGrammar(
@@ -153,7 +154,7 @@ Respond with ONLY the 3 versions, one per line, in Tibetan script.`;
return {
corrections: [],
toneAnalysis: {
- detectedTone: "neutral",
+ detectedTone: "casual",
score: 0.7,
suggestions: [],
},
diff --git a/api-backend/src/services/anthropicService.ts b/api-backend/src/services/anthropicService.ts
new file mode 100644
index 0000000..5a2415b
--- /dev/null
+++ b/api-backend/src/services/anthropicService.ts
@@ -0,0 +1,92 @@
+import Anthropic from "@anthropic-ai/sdk";
+
+/**
+ * Thin wrapper around the Anthropic SDK for the two user-switchable Claude
+ * models. Chat and translation both run through here.
+ */
+
+export const HAIKU_MODEL = "claude-haiku-4-5";
+export const SONNET_MODEL = "claude-sonnet-4-6";
+export const DEFAULT_MODEL = HAIKU_MODEL;
+
+const ALLOWED_MODELS = new Set([HAIKU_MODEL, SONNET_MODEL]);
+
+/** Map a client-supplied model id/alias to an allowed Claude model. */
+export function resolveModel(requested?: string | null): string {
+ if (!requested) return DEFAULT_MODEL;
+ const value = requested.trim();
+ if (ALLOWED_MODELS.has(value)) return value;
+ if (value.toLowerCase() === "haiku") return HAIKU_MODEL;
+ if (value.toLowerCase() === "sonnet") return SONNET_MODEL;
+ return DEFAULT_MODEL;
+}
+
+let client: Anthropic | null = null;
+
+function getClient(): Anthropic {
+ if (!client) {
+ const apiKey = process.env.ANTHROPIC_API_KEY;
+ if (!apiKey) {
+ throw new Error("ANTHROPIC_API_KEY is not configured");
+ }
+ client = new Anthropic({ apiKey });
+ }
+ return client;
+}
+
+export interface ClaudeMessage {
+ role: "user" | "assistant";
+ content: string;
+}
+
+/** Single Messages API call returning the concatenated text output. */
+export async function createMessage(
+ model: string,
+ system: string,
+ messages: ClaudeMessage[],
+ maxTokens = 2048
+): Promise {
+ const response = await getClient().messages.create({
+ model,
+ max_tokens: maxTokens,
+ system,
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
+ });
+
+ return response.content
+ .map((block) => (block.type === "text" ? block.text : ""))
+ .join("")
+ .trim();
+}
+
+const LANGUAGE_NAMES: Record = {
+ en: "English",
+ bo: "Tibetan",
+ "zh-CN": "Chinese (Simplified)",
+};
+
+function languageName(code: string): string {
+ return LANGUAGE_NAMES[code] || code;
+}
+
+/** Translate text between languages, returning only the translated text. */
+export async function translateWithClaude(
+ model: string,
+ text: string,
+ sourceLang: string,
+ targetLang: string
+): Promise {
+ const from =
+ sourceLang === "auto"
+ ? "its source language (auto-detect it)"
+ : languageName(sourceLang);
+ const to = languageName(targetLang);
+
+ const system =
+ `You are an expert translator. Translate the user's text from ${from} ` +
+ `into ${to}. Preserve meaning, tone and cultural nuance. ` +
+ `Respond with ONLY the translated text — no preamble, no quotes, ` +
+ `no explanations, no transliteration.`;
+
+ return createMessage(model, system, [{ role: "user", content: text }], 2048);
+}
diff --git a/api-backend/src/services/azureTranslateService.ts b/api-backend/src/services/azureTranslateService.ts
new file mode 100644
index 0000000..ef81c66
--- /dev/null
+++ b/api-backend/src/services/azureTranslateService.ts
@@ -0,0 +1,83 @@
+import axios from "axios";
+
+/**
+ * Azure Translator (Cognitive Services) Text Translation v3.0.
+ *
+ * Configured via env (injected from the AZURE_TRANSLATOR_KEY Firebase secret +
+ * plain env for the non-secret region/endpoint):
+ * AZURE_TRANSLATOR_KEY — subscription key (secret)
+ * AZURE_TRANSLATOR_REGION — resource region, e.g. "eastus"
+ * AZURE_TRANSLATOR_ENDPOINT — global text endpoint
+ *
+ * Tibetan (`bo`) is supported by Azure NMT text translation incl. auto-detect,
+ * so this can serve the app's primary bo<->en use case; on any failure the
+ * caller (translateProvider) falls back to Claude.
+ */
+
+const DEFAULT_ENDPOINT = "https://api.cognitive.microsofttranslator.com";
+const DEFAULT_REGION = "eastus";
+
+/** Map the app's language codes to Azure Translator codes. */
+function toAzureLang(code: string): string | undefined {
+ switch (code) {
+ case "auto":
+ return undefined; // omit `from` → Azure auto-detects
+ case "zh-CN":
+ return "zh-Hans"; // Azure uses zh-Hans for Simplified Chinese
+ default:
+ return code; // en, bo, … pass through
+ }
+}
+
+export function isAzureConfigured(): boolean {
+ return !!process.env.AZURE_TRANSLATOR_KEY;
+}
+
+interface AzureTranslationItem {
+ translations: Array<{ text: string; to: string }>;
+}
+
+/**
+ * Translate `text` with Azure Translator. Throws on any error (missing config,
+ * HTTP failure, unexpected shape) so the provider layer can fall back to Claude.
+ */
+export async function translateWithAzure(
+ text: string,
+ sourceLang: string,
+ targetLang: string
+): Promise {
+ const key = process.env.AZURE_TRANSLATOR_KEY;
+ if (!key) throw new Error("AZURE_TRANSLATOR_KEY is not configured");
+
+ const region = process.env.AZURE_TRANSLATOR_REGION || DEFAULT_REGION;
+ const endpoint = (
+ process.env.AZURE_TRANSLATOR_ENDPOINT || DEFAULT_ENDPOINT
+ ).replace(/\/+$/, "");
+
+ const to = toAzureLang(targetLang);
+ if (!to) throw new Error(`Unsupported Azure target language: ${targetLang}`);
+ const from = toAzureLang(sourceLang);
+
+ const params: Record = { "api-version": "3.0", to };
+ if (from) params.from = from;
+
+ const response = await axios.post(
+ `${endpoint}/translate`,
+ [{ Text: text }],
+ {
+ params,
+ headers: {
+ "Ocp-Apim-Subscription-Key": key,
+ "Ocp-Apim-Subscription-Region": region,
+ "Content-Type": "application/json",
+ },
+ timeout: 15000,
+ }
+ );
+
+ const translated = response.data?.[0]?.translations?.[0]?.text;
+ if (typeof translated !== "string" || translated.length === 0) {
+ throw new Error("Azure Translator returned an empty result");
+ }
+ return translated;
+}
diff --git a/api-backend/src/services/cacheService.ts b/api-backend/src/services/cacheService.ts
new file mode 100644
index 0000000..10f9bfd
--- /dev/null
+++ b/api-backend/src/services/cacheService.ts
@@ -0,0 +1,164 @@
+import * as admin from "firebase-admin";
+import * as crypto from "crypto";
+
+/**
+ * Two-tier cache for deterministic AI responses (translation, repeated chat turns).
+ *
+ * Standard flow:
+ * Memory cache -> HIT -> return
+ * | MISS
+ * Firestore -> HIT -> save to memory -> return
+ * | MISS
+ * AI provider -> save to Firestore -> save to memory -> return
+ *
+ * The Firestore tier is best-effort: any error (e.g. Admin SDK not initialised
+ * in a unit test) is swallowed and treated as a miss, so the memory tier alone
+ * keeps working.
+ */
+
+interface MemoryEntry {
+ value: string;
+ expiresAt: number;
+}
+
+const MEMORY_TTL_MS = 60 * 60 * 1000; // 1 hour
+const MEMORY_MAX_ENTRIES = 1000;
+const FIRESTORE_COLLECTION = "ai_cache";
+const FIRESTORE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
+
+// Process-local cache. Persists across warm Cloud Function invocations.
+const memoryCache = new Map();
+
+export type CacheSource = "memory" | "firestore" | "provider";
+
+export interface CachedResult {
+ value: string;
+ source: CacheSource;
+}
+
+/**
+ * Stable JSON serialization: object keys are sorted recursively at every depth,
+ * so two structurally-equal inputs always produce the same string.
+ *
+ * NB: do NOT use `JSON.stringify(value, keys.sort())` for this. The array form
+ * of the replacer is a property *allowlist* applied recursively, so any nested
+ * object whose keys aren't in the top-level list (e.g. each chat message's
+ * `role`/`content`) is silently stripped to `{}` — collapsing distinct inputs
+ * onto the same cache key.
+ */
+function stableStringify(value: unknown): string {
+ if (value === null || typeof value !== "object") {
+ return JSON.stringify(value) ?? "null";
+ }
+ if (Array.isArray(value)) {
+ return `[${value.map(stableStringify).join(",")}]`;
+ }
+ const obj = value as Record;
+ const entries = Object.keys(obj)
+ .sort()
+ .map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`);
+ return `{${entries.join(",")}}`;
+}
+
+/** Deterministic cache key from a namespace + the inputs that define the output. */
+export function makeCacheKey(
+ namespace: string,
+ parts: Record
+): string {
+ const canonical = stableStringify(parts);
+ const hash = crypto
+ .createHash("sha256")
+ .update(`${namespace}:${canonical}`)
+ .digest("hex");
+ return `${namespace}_${hash}`;
+}
+
+function readMemory(key: string): string | null {
+ const entry = memoryCache.get(key);
+ if (!entry) return null;
+ if (entry.expiresAt < Date.now()) {
+ memoryCache.delete(key);
+ return null;
+ }
+ // Refresh LRU recency.
+ memoryCache.delete(key);
+ memoryCache.set(key, entry);
+ return entry.value;
+}
+
+function writeMemory(key: string, value: string): void {
+ if (memoryCache.size >= MEMORY_MAX_ENTRIES) {
+ const oldest = memoryCache.keys().next().value;
+ if (oldest !== undefined) memoryCache.delete(oldest);
+ }
+ memoryCache.set(key, { value, expiresAt: Date.now() + MEMORY_TTL_MS });
+}
+
+async function readFirestore(key: string): Promise {
+ try {
+ const doc = await admin
+ .firestore()
+ .collection(FIRESTORE_COLLECTION)
+ .doc(key)
+ .get();
+ if (!doc.exists) return null;
+ const data = doc.data();
+ if (!data || typeof data.value !== "string") return null;
+ const createdAtMs: number = data.createdAtMs || 0;
+ if (createdAtMs && Date.now() - createdAtMs > FIRESTORE_TTL_MS) return null;
+ return data.value;
+ } catch (error) {
+ console.warn("ai_cache: Firestore read failed (treating as miss):", error);
+ return null;
+ }
+}
+
+async function writeFirestore(
+ key: string,
+ value: string,
+ meta: Record
+): Promise {
+ try {
+ await admin
+ .firestore()
+ .collection(FIRESTORE_COLLECTION)
+ .doc(key)
+ .set({
+ ...meta,
+ value,
+ createdAtMs: Date.now(),
+ createdAt: admin.firestore.FieldValue.serverTimestamp(),
+ });
+ } catch (error) {
+ console.warn("ai_cache: Firestore write failed (non-fatal):", error);
+ }
+}
+
+/**
+ * Resolve `key` through memory -> Firestore -> `provider`, populating the upper
+ * tiers on a miss. Returns the value and which tier served it.
+ */
+export async function cachedCompute(
+ key: string,
+ provider: () => Promise,
+ meta: Record = {}
+): Promise {
+ const fromMemory = readMemory(key);
+ if (fromMemory !== null) return { value: fromMemory, source: "memory" };
+
+ const fromFirestore = await readFirestore(key);
+ if (fromFirestore !== null) {
+ writeMemory(key, fromFirestore);
+ return { value: fromFirestore, source: "firestore" };
+ }
+
+ const value = await provider();
+ await writeFirestore(key, value, meta);
+ writeMemory(key, value);
+ return { value, source: "provider" };
+}
+
+/** Test helper — clears the in-memory tier. */
+export function __clearMemoryCache(): void {
+ memoryCache.clear();
+}
diff --git a/api-backend/src/services/freeTranslationService.ts b/api-backend/src/services/freeTranslationService.ts
deleted file mode 100644
index 2847bba..0000000
--- a/api-backend/src/services/freeTranslationService.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { translate } from "google-translate-api-x";
-
-export async function freeTranslateText(
- text: string,
- from: string,
- to: string
-): Promise {
- try {
- const res = await translate(text, { to: to, from: from });
- const translation = res.text;
- return Array.isArray(translation) ? translation[0] : translation;
- } catch (error) {
- console.error("Free Translate API error:", error);
- throw new Error("Translation service unavailable");
- }
-}
diff --git a/api-backend/src/services/grammarService.ts b/api-backend/src/services/grammarService.ts
deleted file mode 100644
index c9a9e13..0000000
--- a/api-backend/src/services/grammarService.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import axios from "axios";
-import { GrammarResponse } from "../types";
-
-// Using a simple grammar checking approach
-// In production, you might want to integrate with services like:
-// - LanguageTool API
-// - Grammarly API
-// - OpenAI API for grammar checking
-
-export async function checkGrammar(text: string): Promise {
- try {
- // Placeholder implementation - replace with actual grammar checking service
- // For now, returning a mock response structure
-
- // Simple basic corrections (this is a placeholder)
- const corrections = performBasicCorrections(text);
-
- return {
- correctedText: corrections.correctedText,
- corrections: corrections.corrections,
- confidence: 0.85,
- };
- } catch (error) {
- console.error("Grammar service error:", error);
- throw new Error("Grammar checking service unavailable");
- }
-}
-
-function performBasicCorrections(text: string) {
- let correctedText = text;
- const corrections: Array<{
- original: string;
- corrected: string;
- type: string;
- position: number;
- }> = [];
-
- // Basic corrections (expand this with actual grammar checking logic)
- const basicRules = [
- { pattern: /\bi\b/g, replacement: "I", type: "capitalization" },
- { pattern: /\s+/g, replacement: " ", type: "spacing" },
- { pattern: /^\s+|\s+$/g, replacement: "", type: "trimming" },
- ];
-
- basicRules.forEach((rule) => {
- correctedText = correctedText.replace(rule.pattern, rule.replacement);
- });
-
- // Add more sophisticated grammar checking here
- // This is where you'd integrate with LanguageTool, Grammarly, or OpenAI
-
- return {
- correctedText,
- corrections,
- };
-}
diff --git a/api-backend/src/services/revenueCatService.ts b/api-backend/src/services/revenueCatService.ts
new file mode 100644
index 0000000..66f30a6
--- /dev/null
+++ b/api-backend/src/services/revenueCatService.ts
@@ -0,0 +1,50 @@
+import axios from "axios";
+import { PRO_ENTITLEMENT_ID } from "../config/constants";
+
+const RC_API_BASE = "https://api.revenuecat.com/v1";
+
+export interface RevenueCatProStatus {
+ isPro: boolean;
+ /** Entitlement expiry in epoch ms, or null for a non-expiring (lifetime) one. */
+ expiresAtMs: number | null;
+}
+
+/**
+ * Live "is this user pro right now?" check against RevenueCat's REST API.
+ *
+ * This is the FALLBACK path: it's only used when the Firestore mirror says
+ * "not pro" (e.g. a purchase that just completed but whose webhook hasn't
+ * landed yet). It guarantees correctness on that edge without adding a network
+ * call to every request.
+ *
+ * Requires a RevenueCat *secret* API key (starts with "sk_"), NOT the public
+ * SDK key the app ships with. Create one in the RevenueCat dashboard and set:
+ * firebase functions:secrets:set REVENUECAT_API_KEY
+ *
+ * @param appUserId must equal the Firebase UID (the app sets this via
+ * Purchases.configure(...).appUserID(firebaseUid)).
+ */
+export async function getProFromRevenueCat(
+ appUserId: string,
+ apiKey: string
+): Promise {
+ if (!apiKey || !appUserId || appUserId === "anonymous") {
+ return { isPro: false, expiresAtMs: null };
+ }
+
+ const url = `${RC_API_BASE}/subscribers/${encodeURIComponent(appUserId)}`;
+ const { data } = await axios.get(url, {
+ headers: { Authorization: `Bearer ${apiKey}` },
+ timeout: 5000,
+ });
+
+ const entitlement = data?.subscriber?.entitlements?.[PRO_ENTITLEMENT_ID];
+ if (!entitlement) return { isPro: false, expiresAtMs: null };
+
+ // expires_date is an ISO string, or null/absent for a lifetime entitlement.
+ const expires: string | null = entitlement.expires_date ?? null;
+ if (!expires) return { isPro: true, expiresAtMs: null };
+
+ const expiresAtMs = new Date(expires).getTime();
+ return { isPro: expiresAtMs > Date.now(), expiresAtMs };
+}
diff --git a/api-backend/src/services/translateProvider.ts b/api-backend/src/services/translateProvider.ts
new file mode 100644
index 0000000..6f566f5
--- /dev/null
+++ b/api-backend/src/services/translateProvider.ts
@@ -0,0 +1,95 @@
+import {
+ HAIKU_MODEL,
+ SONNET_MODEL,
+ translateWithClaude,
+} from "./anthropicService";
+import { isAzureConfigured, translateWithAzure } from "./azureTranslateService";
+import * as logger from "firebase-functions/logger";
+
+/**
+ * Translation engine orchestration. The client picks one of three engines; the
+ * Azure engine transparently falls back to Claude on any failure (or when no
+ * Azure key is configured yet), so translation keeps working end-to-end.
+ */
+
+export const ENGINE_AZURE = "azure";
+export type TranslateEngine =
+ | typeof ENGINE_AZURE
+ | typeof HAIKU_MODEL
+ | typeof SONNET_MODEL;
+
+/** Default engine — Azure first, Claude as the safety net. */
+export const DEFAULT_ENGINE: TranslateEngine = ENGINE_AZURE;
+
+/** Claude model used when Azure is selected but fails/unconfigured. */
+export const AZURE_FALLBACK_MODEL = HAIKU_MODEL;
+
+const ALLOWED_ENGINES = new Set([
+ ENGINE_AZURE,
+ HAIKU_MODEL,
+ SONNET_MODEL,
+]);
+
+/** Map a client-supplied engine id/alias to an allowed engine. */
+export function resolveEngine(requested?: string | null): TranslateEngine {
+ if (!requested) return DEFAULT_ENGINE;
+ const value = requested.trim();
+ if (ALLOWED_ENGINES.has(value)) return value as TranslateEngine;
+ const lower = value.toLowerCase();
+ if (lower === "azure" || lower === "microsoft") return ENGINE_AZURE;
+ if (lower === "haiku") return HAIKU_MODEL;
+ if (lower === "sonnet") return SONNET_MODEL;
+ return DEFAULT_ENGINE;
+}
+
+export interface TranslateOutcome {
+ /** The translated text. */
+ text: string;
+ /** Engine that actually produced the text (differs from the request on fallback). */
+ engineUsed: string;
+ /** True when an Azure request fell back to Claude. */
+ fellBack: boolean;
+}
+
+/**
+ * Translate `text` using the requested engine.
+ * - "azure": Azure Translator, falling back to Claude on any error.
+ * - a Claude model id: Claude directly.
+ */
+export async function translate(
+ engine: TranslateEngine,
+ text: string,
+ sourceLang: string,
+ targetLang: string
+): Promise {
+ if (engine === ENGINE_AZURE) {
+ if (isAzureConfigured()) {
+ try {
+ const azureText = await translateWithAzure(text, sourceLang, targetLang);
+ return { text: azureText, engineUsed: ENGINE_AZURE, fellBack: false };
+ } catch (error) {
+ logger.warn("Azure translate failed, falling back to Claude", {
+ message: error instanceof Error ? error.message : String(error),
+ });
+ }
+ } else {
+ logger.info("Azure not configured, using Claude for translation");
+ }
+ const claudeText = await translateWithClaude(
+ AZURE_FALLBACK_MODEL,
+ text,
+ sourceLang,
+ targetLang
+ );
+ return { text: claudeText, engineUsed: AZURE_FALLBACK_MODEL, fellBack: true };
+ }
+
+ // A Claude model was explicitly requested.
+ const claudeText = await translateWithClaude(
+ engine,
+ text,
+ sourceLang,
+ targetLang
+ );
+ return { text: claudeText, engineUsed: engine, fellBack: false };
+}
diff --git a/api-backend/src/services/translationService.ts b/api-backend/src/services/translationService.ts
deleted file mode 100644
index 073c24c..0000000
--- a/api-backend/src/services/translationService.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { Translate } from "@google-cloud/translate/build/src/v2";
-
-const translate = new Translate({
- projectId: process.env.GOOGLE_CLOUD_PROJECT_ID,
- keyFilename: process.env.GOOGLE_APPLICATION_CREDENTIALS,
-});
-
-export async function translateText(
- text: string,
- from: string,
- to: string
-): Promise {
- try {
- const [translation] = await translate.translate(text, {
- from: from === "auto" ? undefined : from,
- to,
- });
-
- return Array.isArray(translation) ? translation[0] : translation;
- } catch (error) {
- console.error("Google Translate API error:", error);
- throw new Error("Translation service unavailable");
- }
-}
diff --git a/api-backend/src/types/index.ts b/api-backend/src/types/index.ts
index 9b6e527..00e4c4c 100644
--- a/api-backend/src/types/index.ts
+++ b/api-backend/src/types/index.ts
@@ -5,7 +5,7 @@ export interface ApiResponse {
message?: string;
usage?: {
charactersUsed: number;
- remainingCharacters: number;
+ remainingCharacters?: number;
};
}
@@ -42,6 +42,7 @@ export interface GeminiChatRequest {
message: string;
sessionId?: string;
resetChat?: boolean;
+ model?: string;
}
export interface GeminiChatResponse {
@@ -49,11 +50,12 @@ export interface GeminiChatResponse {
data?: {
response: string;
sessionId: string;
+ messageId?: string;
};
error?: string;
message?: string;
usage?: {
charactersUsed: number;
- remainingCharacters: number;
+ remainingCharacters?: number;
};
}
diff --git a/api-backend/src/webhooks/revenueCatWebhook.ts b/api-backend/src/webhooks/revenueCatWebhook.ts
new file mode 100644
index 0000000..1d98e8f
--- /dev/null
+++ b/api-backend/src/webhooks/revenueCatWebhook.ts
@@ -0,0 +1,173 @@
+import * as admin from "firebase-admin";
+import { Request, Response } from "express";
+import * as logger from "firebase-functions/logger";
+import { PRO_ENTITLEMENT_ID } from "../config/constants";
+
+/**
+ * RevenueCat webhook handler.
+ *
+ * RevenueCat POSTs an event here whenever a subscription starts, renews,
+ * cancels, expires, etc. We mirror the resulting pro state into the user's
+ * Firestore doc so the rest of the backend can read it cheaply.
+ *
+ * Auth: RevenueCat sends a fixed value in the `Authorization` header (set under
+ * Dashboard -> Integrations -> Webhooks -> "Authorization header value"). We
+ * compare it against the REVENUECAT_WEBHOOK_SECRET secret. This is the only
+ * thing standing between this endpoint and the public internet — keep it set.
+ *
+ * Logging: all lines are prefixed `rc-webhook:` and carry `userId` + `type`
+ * (+ `environment` = SANDBOX/PRODUCTION). In Google Cloud Logging, search the
+ * text "rc-webhook" to see every event, or filter `jsonPayload.userId=""`
+ * to trace one user. `environment` makes it obvious when a test purchase isn't
+ * showing up because it's a SANDBOX event.
+ */
+
+// Events that grant / keep access.
+const GRANT_EVENTS = new Set([
+ "INITIAL_PURCHASE",
+ "RENEWAL",
+ "UNCANCELLATION",
+ "PRODUCT_CHANGE",
+ "NON_RENEWING_PURCHASE",
+ "SUBSCRIPTION_EXTENDED",
+]);
+
+// Events that revoke access immediately.
+const REVOKE_EVENTS = new Set(["EXPIRATION", "SUBSCRIPTION_PAUSED"]);
+
+export function revenueCatWebhook(getAuthSecret: () => string | undefined) {
+ return async (req: Request, res: Response): Promise => {
+ const expected = getAuthSecret();
+ const got = req.headers["authorization"];
+
+ if (!expected) {
+ // Misconfiguration: refuse rather than accept unauthenticated writes.
+ logger.error("rc-webhook: REVENUECAT_WEBHOOK_SECRET is not set — refusing");
+ res.status(500).json({ success: false, error: "Webhook not configured" });
+ return;
+ }
+ if (got !== expected) {
+ logger.warn("rc-webhook: rejected (bad/missing Authorization header)");
+ res.status(401).json({ success: false, error: "Unauthorized" });
+ return;
+ }
+
+ const event = req.body?.event;
+ if (!event || !event.type) {
+ logger.warn("rc-webhook: rejected (missing event/type in body)");
+ res.status(400).json({ success: false, error: "Missing event" });
+ return;
+ }
+
+ const type: string = event.type;
+ const environment: string = event.environment || "UNKNOWN";
+ const appUserId: string | undefined =
+ event.app_user_id || event.original_app_user_id;
+ const db = admin.firestore();
+
+ logger.info("rc-webhook: received", {
+ type,
+ environment,
+ userId: appUserId,
+ entitlements: event.entitlement_ids || event.entitlement_id,
+ expirationAtMs: event.expiration_at_ms,
+ });
+
+ try {
+ // TRANSFER moves entitlements between app user IDs (no app_user_id field).
+ if (type === "TRANSFER") {
+ const to: string[] = event.transferred_to || [];
+ const from: string[] = event.transferred_from || [];
+ await Promise.all([
+ ...to.map((id) => writeProState(db, id, true, event)),
+ ...from.map((id) => writeProState(db, id, false, event)),
+ ]);
+ logger.info("rc-webhook: TRANSFER applied", { to, from, environment });
+ res.json({ success: true });
+ return;
+ }
+
+ if (!appUserId) {
+ logger.warn("rc-webhook: rejected (missing app_user_id)", { type, environment });
+ res.status(400).json({ success: false, error: "Missing app_user_id" });
+ return;
+ }
+
+ // If the event names entitlements, only react to ours.
+ const ents: string[] =
+ event.entitlement_ids ||
+ (event.entitlement_id ? [event.entitlement_id] : []);
+ if (ents.length > 0 && !ents.includes(PRO_ENTITLEMENT_ID)) {
+ logger.info("rc-webhook: ignored (entitlement not 'pro')", {
+ userId: appUserId,
+ type,
+ entitlements: ents,
+ });
+ res.json({ success: true, ignored: `entitlement ${ents.join(",")}` });
+ return;
+ }
+
+ let isPro: boolean;
+ if (REVOKE_EVENTS.has(type)) {
+ isPro = false;
+ } else if (GRANT_EVENTS.has(type) || type === "CANCELLATION") {
+ // CANCELLATION = auto-renew turned off (or refund); access lasts until
+ // expiration_at_ms. A later EXPIRATION event flips it to false.
+ const exp: number | undefined = event.expiration_at_ms;
+ isPro = exp ? exp > Date.now() : true;
+ } else {
+ // BILLING_ISSUE, TEST, and anything new: don't change stored state.
+ logger.info("rc-webhook: no-op (state unchanged)", {
+ userId: appUserId,
+ type,
+ environment,
+ });
+ res.json({ success: true, ignored: type });
+ return;
+ }
+
+ await writeProState(db, appUserId, isPro, event);
+ logger.info("rc-webhook: applied", {
+ userId: appUserId,
+ type,
+ isPro,
+ environment,
+ });
+ res.json({ success: true });
+ } catch (error) {
+ logger.error("rc-webhook: processing failed (RevenueCat will retry)", {
+ userId: appUserId,
+ type,
+ environment,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ // 5xx makes RevenueCat retry — desirable for a transient Firestore error.
+ res.status(500).json({ success: false, error: "Processing failed" });
+ }
+ };
+}
+
+/**
+ * Single, server-owned pro state on `users/{uid}`. We deliberately write a
+ * minimal, namespaced field set (isPro / proExpiresAt / proUpdatedAt) and
+ * nothing else — the app never writes these, so there are no competing writers
+ * and no field sprawl. Event type / environment live in the logs, not the doc.
+ */
+async function writeProState(
+ db: FirebaseFirestore.Firestore,
+ userId: string,
+ isPro: boolean,
+ event: any
+): Promise {
+ if (!userId) return;
+ const expMs: number | undefined = event.expiration_at_ms;
+
+ await db.collection("users").doc(userId).set(
+ {
+ isPro,
+ proExpiresAt: expMs ? admin.firestore.Timestamp.fromMillis(expMs) : null,
+ proUpdatedAt: admin.firestore.FieldValue.serverTimestamp(),
+ },
+ { merge: true }
+ );
+}
diff --git a/api-backend/tsconfig.json b/api-backend/tsconfig.json
index fbced3d..a31326b 100644
--- a/api-backend/tsconfig.json
+++ b/api-backend/tsconfig.json
@@ -20,5 +20,5 @@
"resolveJsonModule": true
},
"include": ["src/**/*"],
- "exclude": ["node_modules", "lib"]
+ "exclude": ["node_modules", "lib", "src/__tests__/**", "**/*.test.ts"]
}
diff --git a/app/build.gradle b/app/build.gradle
index 346fcd6..b9ad347 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -4,6 +4,7 @@ plugins {
id 'com.google.gms.google-services'
id 'com.google.firebase.crashlytics'
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.10'
+ id 'org.jetbrains.kotlin.plugin.compose'
}
android {
@@ -13,8 +14,8 @@ android {
applicationId "com.kharagedition.tibetankeyboard"
minSdkVersion 23
targetSdkVersion 36
- versionCode 45
- versionName "2.1.15"
+ versionCode 52
+ versionName "2.2.10"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
multiDexEnabled true
@@ -41,6 +42,13 @@ android {
buildFeatures {
viewBinding = true
buildConfig = true
+ compose = true
+ }
+ packaging {
+ jniLibs {
+ // Store native libs uncompressed + 16 KB page-aligned (Android 15+ requirement).
+ useLegacyPackaging = false
+ }
}
configurations {
configureEach {
@@ -86,5 +94,22 @@ dependencies {
implementation 'com.airbnb.android:lottie:6.6.2'
+ implementation 'com.google.android.play:app-update-ktx:2.1.0'
implementation 'com.revenuecat.purchases:purchases:9.2.0'
+ implementation project(':botok')
+
+ // Jetpack Compose
+ implementation platform('androidx.compose:compose-bom:2024.09.00')
+ implementation 'androidx.compose.ui:ui'
+ implementation 'androidx.compose.ui:ui-graphics'
+ implementation 'androidx.compose.ui:ui-tooling-preview'
+ implementation 'androidx.compose.foundation:foundation'
+ implementation 'androidx.compose.material3:material3'
+ implementation 'androidx.compose.material:material-icons-extended'
+ implementation 'androidx.activity:activity-compose:1.9.2'
+ implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7'
+ implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.8.7'
+ implementation 'androidx.compose.runtime:runtime-livedata'
+ implementation 'io.coil-kt:coil-compose:2.7.0'
+ debugImplementation 'androidx.compose.ui:ui-tooling'
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index adb13fa..e80c976 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -11,7 +11,7 @@
+
+ android:parentActivityName=".ui.home.HomeActivity" />
+
+
@@ -54,7 +67,7 @@
@@ -63,7 +76,7 @@
- isPremiumUser = isPremium;
- }
- }
-
- private fun setupView() {
- LayoutInflater.from(context).inflate(R.layout.ai_keyboard_layout, this, true)
- authManager = AuthManager(context)
-
- initializeViews()
- setupClickListeners()
- updateLanguageLabels()
- }
-
- private fun initializeViews() {
- aiToolbar = findViewById(R.id.ai_toolbar)
- aiOptionsIcon = findViewById(R.id.ai_options_icon)
- aiOptionsContainer = findViewById(R.id.ai_options_container)
- grammarBtn = findViewById(R.id.grammar_btn)
- translateBtn = findViewById(R.id.translate_btn)
- rephraseBtn = findViewById(R.id.rephrase_btn)
- normalKeyboardContainer = findViewById(R.id.normal_keyboard_container)
- aiInterfaceContainer = findViewById(R.id.ai_interface_container)
- aiBackBtn = findViewById(R.id.ai_back_btn)
- aiTitleText = findViewById(R.id.ai_title_text)
- aiInrBtn = findViewById(R.id.ai_inner_btn)
- originalTextView = findViewById(R.id.original_text_view)
- suggestedTextView = findViewById(R.id.suggested_text_view)
- aiSummaryText = findViewById(R.id.ai_summary_text)
- aiReplaceBtn = findViewById(R.id.ai_replace_btn)
- aiCancelBtn = findViewById(R.id.ai_cancel_btn)
- loadingProgressBar = findViewById(R.id.loading_progress_bar)
- translateSwapBtn = findViewById(R.id.translate_swap_btn)
- sourceLanguageText = findViewById(R.id.source_language_text)
- targetLanguageText = findViewById(R.id.target_language_text)
- }
-
- private fun setupClickListeners() {
- aiOptionsIcon.setOnClickListener {
-
- // Check authentication first
- if (!authManager.isUserAuthenticated() || !isPremiumUser) {
- authManager.redirectToLogin()
- }else{
- toggleAIOptions()
- }
-
- }
-
- grammarBtn.setOnClickListener {
-// Toast.makeText(context, "Coming in next release....", Toast.LENGTH_SHORT).show()
-// return@setOnClickListener;
- val currentText = getCurrentInputText()
- if (currentText.isEmpty()) {
- Toast.makeText(context, "Input text is empty", Toast.LENGTH_SHORT).show()
- return@setOnClickListener
- }
- handleGrammarClick(currentText)
- showAIInterface()
- }
-
- translateBtn.setOnClickListener {
- val currentText = getCurrentInputText()
- if(currentText.isEmpty()) {
- Toast.makeText(context, "Input text is empty", Toast.LENGTH_SHORT).show()
- return@setOnClickListener
- }
- handleTranslateClick(currentText)
- showAIInterface()
- }
-
- rephraseBtn.setOnClickListener {
- val currentText = getCurrentInputText()
- if(currentText.isEmpty()) {
- Toast.makeText(context, "Input text is empty", Toast.LENGTH_SHORT).show()
- return@setOnClickListener
- }
- handleRephraseClick(currentText)
- showAIInterface()
- }
- aiInrBtn.setOnClickListener() {
- val currentText = getCurrentInputText()
- if(currentText.isEmpty()) {
- Toast.makeText(context, "Input text is empty", Toast.LENGTH_SHORT).show()
- return@setOnClickListener
- }
- when (aiInrBtn.text.toString()) {
- context.resources.getString(R.string.fix) -> handleGrammarClick(currentText)
- context.resources.getString(R.string.translate) -> handleTranslateClick(currentText)
- context.resources.getString(R.string.rephrase) -> handleRephraseClick(currentText)
- }
- }
-
- aiBackBtn.setOnClickListener {
- showNormalKeyboard()
- aiKeyboardInterface?.onAICancel()
- }
-
- aiReplaceBtn.setOnClickListener {
- if (currentSuggestedText.isNotEmpty()) {
- when (aiReplaceBtn.text.toString()) {
- context.resources.getString(R.string.apply_corrections) -> aiKeyboardInterface?.onGrammarReplace(currentOriginalText, currentSuggestedText)
- context.resources.getString(R.string.use_translation) -> aiKeyboardInterface?.onTranslateReplace(currentOriginalText, currentSuggestedText)
- context.resources.getString(R.string.use_rephrase) -> aiKeyboardInterface?.onRephraseReplace(currentOriginalText, currentSuggestedText)
- }
- }
- }
-
- aiCancelBtn.setOnClickListener {
- showNormalKeyboard()
- aiKeyboardInterface?.onAICancel()
- }
-
- translateSwapBtn.setOnClickListener {
- swapTranslationLanguages()
- }
- }
-
- private fun handleRephraseClick(currentText: String) {
-
- showRephraseInterface(currentText)
- hideAIOptions()
- }
-
- private fun handleTranslateClick(currentText: String) {
-
- showTranslateInterface(currentText)
- hideAIOptions()
- }
-
- private fun handleGrammarClick(currentText: String) {
-
- showGrammarInterface(currentText)
- hideAIOptions()
- }
-
- private fun getCurrentInputText(): String {
- val text = aiKeyboardInterface?.getCurrentText() ?: ""
- return text.trim()
- }
-
- fun setAIKeyboardInterface(aiKeyboardInterface: AIKeyboardInterface) {
- this.aiKeyboardInterface = aiKeyboardInterface
- }
-
- fun setThemeColor(color: String) {
- this.themeColor = color
- applyTheme()
- }
-
- private fun applyTheme() {
- val colorInt = Color.parseColor(themeColor)
- val lighterColor = adjustColorBrightness(colorInt, 0.2f)
- aiToolbar.setBackgroundColor(colorInt)
- grammarBtn.setBackgroundColor(lighterColor)
- translateBtn.setBackgroundColor(lighterColor)
- rephraseBtn.setBackgroundColor(lighterColor)
- //aiReplaceBtn.setBackgroundColor(lighterColor)
- val textColor = if (isColorDark(colorInt)) Color.WHITE else Color.BLACK
- grammarBtn.setTextColor(textColor)
- translateBtn.setTextColor(textColor)
- rephraseBtn.setTextColor(textColor)
- aiReplaceBtn.setTextColor(textColor)
- }
-
- private fun adjustColorBrightness(color: Int, factor: Float): Int {
- val red = ((Color.red(color) * (1 + factor)).coerceAtMost(255f)).toInt()
- val green = ((Color.green(color) * (1 + factor)).coerceAtMost(255f)).toInt()
- val blue = ((Color.blue(color) * (1 + factor)).coerceAtMost(255f)).toInt()
- return Color.rgb(red, green, blue)
- }
-
- private fun isColorDark(color: Int): Boolean {
- val darkness = 1 - (0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color)) / 255
- return darkness >= 0.5
- }
-
- private fun toggleAIOptions() {
- if (isOptionsExpanded) hideAIOptions() else showAIOptions()
- }
-
- private fun showAIOptions() {
- isOptionsExpanded = true
- val rotateAnimator = ObjectAnimator.ofFloat(aiOptionsIcon, "rotation", 0f, 180f)
- rotateAnimator.duration = 30
-
- aiOptionsContainer.visibility = View.VISIBLE
- val expandAnimator = ValueAnimator.ofInt(0, 60)
- expandAnimator.duration = 30
- expandAnimator.addUpdateListener { animator ->
- val value = animator.animatedValue as Int
- val layoutParams = aiOptionsContainer.layoutParams
- layoutParams.height = (value * context.resources.displayMetrics.density).toInt()
- aiOptionsContainer.layoutParams = layoutParams
- }
-
- grammarBtn.alpha = 0f
- translateBtn.alpha = 0f
- rephraseBtn.alpha = 0f
- val fadeInAnimator1 = ObjectAnimator.ofFloat(grammarBtn, "alpha", 0f, 1f)
- val fadeInAnimator2 = ObjectAnimator.ofFloat(translateBtn, "alpha", 0f, 1f)
- val fadeInAnimator3 = ObjectAnimator.ofFloat(rephraseBtn, "alpha", 0f, 1f)
- fadeInAnimator1.startDelay = 150
- fadeInAnimator2.startDelay = 200
- fadeInAnimator3.startDelay = 250
- fadeInAnimator1.duration = 200
- fadeInAnimator2.duration = 200
- fadeInAnimator3.duration = 200
-
- AnimatorSet().apply {
- playTogether(rotateAnimator, expandAnimator, fadeInAnimator1, fadeInAnimator2, fadeInAnimator3)
- start()
- }
- }
-
- private fun hideAIOptions() {
- isOptionsExpanded = false
- val rotateAnimator = ObjectAnimator.ofFloat(aiOptionsIcon, "rotation", 180f, 0f)
- rotateAnimator.duration = 30
-
- val collapseAnimator = ValueAnimator.ofInt(60, 0)
- collapseAnimator.duration = 30
- collapseAnimator.addUpdateListener { animator ->
- val value = animator.animatedValue as Int
- val layoutParams = aiOptionsContainer.layoutParams
- layoutParams.height = (value * context.resources.displayMetrics.density).toInt()
- aiOptionsContainer.layoutParams = layoutParams
- }
-
- val fadeOutAnimator1 = ObjectAnimator.ofFloat(grammarBtn, "alpha", 1f, 0f)
- val fadeOutAnimator2 = ObjectAnimator.ofFloat(translateBtn, "alpha", 1f, 0f)
- val fadeOutAnimator3 = ObjectAnimator.ofFloat(rephraseBtn, "alpha", 1f, 0f)
- fadeOutAnimator1.duration = 150
- fadeOutAnimator2.duration = 150
- fadeOutAnimator3.duration = 150
-
- AnimatorSet().apply {
- playTogether(rotateAnimator, collapseAnimator, fadeOutAnimator1, fadeOutAnimator2, fadeOutAnimator3)
- addListener(object : android.animation.AnimatorListenerAdapter() {
- override fun onAnimationEnd(animation: android.animation.Animator) {
- aiOptionsContainer.visibility = View.GONE
- }
- })
- start()
- }
- }
-
- fun showGrammarInterface(text: String) {
- Log.d("AIKeyboardView", "showGrammarInterface called with text: $text")
- currentOriginalText = text
- aiTitleText.text = "Grammar Check"
- originalTextView.text = text
- hideTranslationControls()
- showLoadingState()
-
-
- CoroutineScope(Dispatchers.Main).launch {
- try {
- val result = aiService.checkGrammar(text)
- showGrammarResult(result)
- } catch (e: Exception) {
- Log.e("AIKeyboardView", "Grammar check failed", e)
- showError("Failed to check grammar")
- }
- }
- }
-
- fun showTranslateInterface(text: String) {
- Log.d("AIKeyboardView", "showTranslateInterface called with text: $text")
- currentOriginalText = text
- aiTitleText.text = "Translate Text"
- originalTextView.text = text
- showTranslationControls()
- showLoadingState()
-
-
- CoroutineScope(Dispatchers.Main).launch {
- try {
- val result = aiService.translateText(text, currentSourceLang, currentTargetLang)
- showTranslationResult(result)
- } catch (e: Exception) {
- Log.e("AIKeyboardView", "Translation failed", e)
- showError("Failed to translate text")
- }
- }
- }
-
- fun showRephraseInterface(text: String) {
- Log.d("AIKeyboardView", "showRephraseInterface called with text: $text")
-
- currentOriginalText = text
-
- aiTitleText.text = "Rephrase Text"
-
- originalTextView.text = text
-
- hideTranslationControls()
-
- showLoadingState()
-
- CoroutineScope(Dispatchers.Main).launch {
- try {
-
- val result = aiService.rephraseText(text)
-
- showRephraseResult(result)
-
- } catch (e: Exception) {
- Log.e("AIKeyboardView", "Rephrase failed", e)
- showError("Failed to rephrase text")
- }
- }
- }
-
- private fun showTranslationControls() {
- translateSwapBtn.visibility = View.VISIBLE
- sourceLanguageText.visibility = View.VISIBLE
- targetLanguageText.visibility = View.VISIBLE
- updateLanguageLabels()
- }
-
- private fun hideTranslationControls() {
- translateSwapBtn.visibility = View.GONE
- sourceLanguageText.visibility = View.GONE
- targetLanguageText.visibility = View.GONE
- }
-
- private fun swapTranslationLanguages() {
- val tempLang = currentSourceLang
-
- currentSourceLang = currentTargetLang
-
- currentTargetLang = tempLang
-
- // Animate the swap
- val rotateAnimator = ObjectAnimator.ofFloat(translateSwapBtn, "rotation", 0f, 180f)
-
- rotateAnimator.duration = 30
-
- rotateAnimator.start()
-
- updateLanguageLabels()
-
- // Re-translate with swapped languages if we have text
- if (currentOriginalText.isNotEmpty()) {
- showLoadingState()
- CoroutineScope(Dispatchers.Main).launch {
- try {
- val result = aiService.translateText(currentOriginalText, currentSourceLang, currentTargetLang)
- showTranslationResult(result)
- } catch (e: Exception) {
- Log.e("AIKeyboardView", "Translation swap failed", e)
- showError("Failed to translate text")
- }
- }
- }
- }
-
- private fun updateLanguageLabels() {
- sourceLanguageText.text = getLanguageDisplayName(currentSourceLang)
- targetLanguageText.text = getLanguageDisplayName(currentTargetLang)
- }
-
- private fun getLanguageDisplayName(langCode: String): String {
- return when (langCode) {
- "bo" -> "བོད་སྐད།" // Tibetan
- "en" -> "English"
- else -> langCode
- }
- }
-
- private fun showLoadingState() {
- aiInrBtn.visibility = View.GONE
- loadingProgressBar.visibility = View.VISIBLE
- suggestedTextView.text = "Analyzing text..."
- aiSummaryText.text = "Please wait while we process your request."
- aiReplaceBtn.isEnabled = false
- aiReplaceBtn.alpha = 0.5f
- }
-
- private fun showGrammarResult(result: GrammarResult) {
-
- loadingProgressBar.visibility = View.GONE
- currentSuggestedText = result.correctedText
- suggestedTextView.text = result.correctedText
- aiSummaryText.text = "${result.corrections.size} corrections found: ${result.corrections.joinToString(", ")}"
- aiReplaceBtn.isEnabled = true
- aiReplaceBtn.alpha = 1f
- aiReplaceBtn.text = context.getString(R.string.apply_corrections)
- aiInrBtn.visibility = View.VISIBLE
- aiInrBtn.text = context.getString(R.string.fix);
- }
-
- private fun showTranslationResult(result: TranslationResult) {
- loadingProgressBar.visibility = View.GONE
- currentSuggestedText = result.translatedText
- suggestedTextView.text = result.translatedText
- aiSummaryText.text = "Translated from ${result.sourceLanguage} to ${result.targetLanguage}"
- aiReplaceBtn.isEnabled = true
- aiReplaceBtn.alpha = 1f
- aiReplaceBtn.text = context.getString(R.string.use_translation)
- aiInrBtn.visibility = View.VISIBLE
- aiInrBtn.text = context.getString(R.string.translate);
- if(result.error!=null){
- showError("Translation error: ${result.error}")
- }
- }
-
- private fun showRephraseResult(result: RephraseResult) {
- loadingProgressBar.visibility = View.GONE
- currentSuggestedText = result.rephrasedText
- suggestedTextView.text = result.rephrasedText
- aiSummaryText.text = "Style: ${result.style} - ${result.improvements.joinToString(", ")}"
- aiReplaceBtn.isEnabled = true
- aiReplaceBtn.alpha = 1f
- aiReplaceBtn.text = context.getString(R.string.use_rephrase)
- aiInrBtn.visibility = View.VISIBLE
- aiInrBtn.text = context.getString(R.string.rephrase);
- }
-
- private fun showError(message: String) {
- loadingProgressBar.visibility = View.GONE
- suggestedTextView.text = "Error occurred"
- aiSummaryText.text = message
- aiReplaceBtn.isEnabled = false
- aiReplaceBtn.alpha = 0.5f
- aiInrBtn.visibility = View.VISIBLE
- aiInrBtn.text = context.getString(R.string.retry);
- }
-
- private fun showAIInterface() {
- normalKeyboardContainer.visibility = View.GONE
- aiInterfaceContainer.visibility = View.VISIBLE
- aiInterfaceContainer.translationY = aiInterfaceContainer.height.toFloat()
- aiInterfaceContainer.animate().translationY(0f).setDuration(300).start()
- }
-
- fun showNormalKeyboard() {
- if (aiInterfaceContainer.visibility == View.VISIBLE) {
- aiInterfaceContainer.animate()
- .translationY(aiInterfaceContainer.height.toFloat())
- .setDuration(300)
- .withEndAction {
- aiInterfaceContainer.visibility = View.GONE
- normalKeyboardContainer.visibility = View.VISIBLE
- currentOriginalText = ""
- currentSuggestedText = ""
- if (isOptionsExpanded) hideAIOptions()
- }
- .start()
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/LoginActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/LoginActivity.kt
deleted file mode 100644
index 176c024..0000000
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/LoginActivity.kt
+++ /dev/null
@@ -1,355 +0,0 @@
-package com.kharagedition.tibetankeyboard
-
-import android.content.Intent
-import android.graphics.Color
-import android.net.Uri
-import android.os.Bundle
-import android.text.SpannableString
-import android.text.Spanned
-import android.text.TextPaint
-import android.text.method.LinkMovementMethod
-import android.text.style.ClickableSpan
-import android.view.View
-import android.widget.TextView
-import android.widget.Toast
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.content.ContextCompat
-import androidx.core.view.ViewCompat
-import androidx.core.view.WindowInsetsCompat
-import androidx.lifecycle.lifecycleScope
-import com.google.android.gms.auth.api.signin.GoogleSignIn
-import com.google.android.gms.auth.api.signin.GoogleSignInClient
-import com.google.android.gms.auth.api.signin.GoogleSignInOptions
-import com.google.android.gms.common.api.ApiException
-import com.google.android.material.button.MaterialButton
-import com.google.android.material.progressindicator.CircularProgressIndicator
-import com.google.firebase.FirebaseNetworkException
-import com.google.firebase.auth.FirebaseAuth
-import com.google.firebase.auth.FirebaseAuthUserCollisionException
-import com.google.firebase.auth.FirebaseUser
-import com.google.firebase.auth.GoogleAuthProvider
-import com.google.firebase.auth.ktx.auth
-import com.google.firebase.ktx.Firebase
-import com.kharagedition.tibetankeyboard.R
-import com.kharagedition.tibetankeyboard.UserPreferences
-import com.kharagedition.tibetankeyboard.repo.UserRepository
-import com.kharagedition.tibetankeyboard.ui.ChatActivity
-import com.kharagedition.tibetankeyboard.subscription.RevenueCatManager
-import com.kharagedition.tibetankeyboard.auth.AuthManager
-import android.util.Log
-import kotlinx.coroutines.launch
-
-class LoginActivity : AppCompatActivity() {
-
- private lateinit var auth: FirebaseAuth
- private lateinit var googleSignInClient: GoogleSignInClient
- private lateinit var buttonGoogleSignIn: MaterialButton
- private lateinit var progressIndicator: CircularProgressIndicator
- private lateinit var userPreferences: UserPreferences
- private lateinit var textViewPrivacyPolicy: TextView
- private lateinit var userRepository: UserRepository
- private lateinit var revenueCatManager: RevenueCatManager
-
- private val googleSignInLauncher = registerForActivityResult(
- ActivityResultContracts.StartActivityForResult()
- ) { result ->
- val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
- try {
- val account = task.getResult(ApiException::class.java)!!
- firebaseAuthWithGoogle(account.idToken!!)
- } catch (e: ApiException) {
- hideLoading()
- Toast.makeText(this, "Google sign in failed: ${e.message}", Toast.LENGTH_SHORT).show()
- }
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_login)
-
- // Initialize Firebase Auth
- auth = Firebase.auth
- userRepository = UserRepository()
- userPreferences = UserPreferences(this)
- revenueCatManager = RevenueCatManager.getInstance()
-
- // Check if user is already signed in
- if (auth.currentUser != null && userPreferences.isUserLoggedIn()) {
- Log.d("LoginActivity", "User already logged in, initializing RevenueCat...")
- initializeRevenueCatAndNavigate()
- return
- }
-
- //setupEdgeToEdge()
- initializeViews()
- setupPrivacyTextView()
- configureGoogleSignIn()
- setupClickListeners()
- }
-
- private fun setupPrivacyTextView() {
- val fullText = "By signing in, you agree to our Terms of Service and Privacy Policy"
- val spannableString = SpannableString(fullText)
-
- // Find the positions of the links
- val termsStart = fullText.indexOf("Terms of Service")
- val termsEnd = termsStart + "Terms of Service".length
- val privacyStart = fullText.indexOf("Privacy Policy")
- val privacyEnd = privacyStart + "Privacy Policy".length
- val termsClickableSpan = object : ClickableSpan() {
- override fun onClick(widget: View) {
- openTermsOfService()
- }
-
- override fun updateDrawState(ds: TextPaint) {
- super.updateDrawState(ds)
- ds.color = ContextCompat.getColor(this@LoginActivity, R.color.brown_700)
- ds.isUnderlineText = true
- }
- }
-
- val privacyClickableSpan = object : ClickableSpan() {
- override fun onClick(widget: View) {
- openPrivacyPolicy()
- }
-
- override fun updateDrawState(ds: TextPaint) {
- super.updateDrawState(ds)
- ds.color = ContextCompat.getColor(this@LoginActivity, R.color.brown_700)
- ds.isUnderlineText = true
- }
- }
-
- // Apply the spans
- spannableString.setSpan(termsClickableSpan, termsStart, termsEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
- spannableString.setSpan(privacyClickableSpan, privacyStart, privacyEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
-
- // Set the text and make it clickable
- textViewPrivacyPolicy.text = spannableString
- textViewPrivacyPolicy.movementMethod = LinkMovementMethod.getInstance()
-
- // Optional: Remove the default link color highlighting
- textViewPrivacyPolicy.highlightColor = Color.TRANSPARENT
-
-
- }
- private fun openTermsOfService() {
- val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://kharagedition.github.io/term-and-condition/tibetan-keyboard.html"))
- startActivity(intent)
- }
-
- private fun openPrivacyPolicy() {
- val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://kharagedition.github.io/privacy-policy/tibetan-keyboard"))
- startActivity(intent)
- }
-
- private fun setupEdgeToEdge() {
- ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main_container)) { v, insets ->
- val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
- v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
- insets
- }
- }
-
- private fun initializeViews() {
- buttonGoogleSignIn = findViewById(R.id.buttonGoogleSignIn)
- progressIndicator = findViewById(R.id.progressIndicator)
- textViewPrivacyPolicy = findViewById(R.id.textViewPrivacy)
- }
-
- private fun configureGoogleSignIn() {
- val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
- .requestIdToken(getString(R.string.default_web_client_id))
- .requestEmail()
- .requestProfile()
- .build()
-
- googleSignInClient = GoogleSignIn.getClient(this, gso)
- }
-
- private fun setupClickListeners() {
- buttonGoogleSignIn.setOnClickListener {
- signInWithGoogle()
- }
- }
-
- private fun signInWithGoogle() {
- showLoading()
- val signInIntent = googleSignInClient.signInIntent
- googleSignInLauncher.launch(signInIntent)
- }
-
- private fun firebaseAuthWithGoogle(idToken: String) {
- val credential = GoogleAuthProvider.getCredential(idToken, null)
- auth.signInWithCredential(credential)
- .addOnCompleteListener(this) { task ->
- hideLoading()
- if (task.isSuccessful) {
- val user = auth.currentUser
- user?.let { firebaseUser ->
- handleSuccessfulLogin(firebaseUser, task.result?.additionalUserInfo?.isNewUser ?: false)
- }
- } else {
- handleLoginError(task.exception)
- }
- }
- }
-
- private fun handleSuccessfulLogin(firebaseUser: FirebaseUser, isNewUser: Boolean) {
- // Save user login state locally
- userPreferences.saveUserLoginState(
- isLoggedIn = true,
- userId = firebaseUser.uid,
- userName = firebaseUser.displayName ?: "User",
- userEmail = firebaseUser.email ?: "",
- userPhotoUrl = firebaseUser.photoUrl?.toString() ?: ""
- )
-
- // Create or update user in Firestore
- lifecycleScope.launch {
- try {
- showLoading()
-
- val result = userRepository.createOrUpdateUser(
- uid = firebaseUser.uid,
- displayName = firebaseUser.displayName ?: "User",
- email = firebaseUser.email ?: "",
- photoUrl = firebaseUser.photoUrl?.toString() ?: "",
- context = this@LoginActivity,
- isNewUser = isNewUser
- )
-
- hideLoading()
-
- if (result.isSuccess) {
- val user = result.getOrNull()
- val welcomeMessage = if (isNewUser) {
- "Welcome to our app, ${firebaseUser.displayName}!"
- } else {
- "Welcome back, ${firebaseUser.displayName}!"
- }
-
- Toast.makeText(this@LoginActivity, welcomeMessage, Toast.LENGTH_SHORT).show()
-
- // Track successful login
- userRepository.trackUserEvent(
- firebaseUser.uid,
- if (isNewUser) "user_registration_completed" else "user_login_success",
- mapOf(
- "login_method" to "google",
- "user_email" to (firebaseUser.email ?: ""),
- "has_display_name" to (firebaseUser.displayName != null)
- )
- )
-
- // CRITICAL: Initialize RevenueCat immediately after successful login
- // This ensures purchases can be acknowledged with Google Play
- Log.d("LoginActivity", "Initializing RevenueCat after successful login...")
- revenueCatManager.initialize(this@LoginActivity, auth, object : RevenueCatManager.SubscriptionCallback {
- override fun onSuccess(message: String) {
- Log.d("LoginActivity", "✅ RevenueCat initialized successfully: $message")
- navigateToChatActivity()
- }
-
- override fun onError(error: String) {
- Log.e("LoginActivity", "❌ RevenueCat initialization failed: $error")
- // Still navigate even if RevenueCat fails
- navigateToChatActivity()
- }
-
- override fun onUserCancelled() {
- // Not applicable here
- }
- })
- } else {
- // Even if Firestore fails, continue with login but show warning
- Toast.makeText(
- this@LoginActivity,
- "Welcome ${firebaseUser.displayName}! (Profile sync pending)",
- Toast.LENGTH_SHORT
- ).show()
- initializeRevenueCatAndNavigate()
- }
- } catch (e: Exception) {
- hideLoading()
- // Don't block login for data collection failures
- Toast.makeText(
- this@LoginActivity,
- "Welcome ${firebaseUser.displayName}!",
- Toast.LENGTH_SHORT
- ).show()
- initializeRevenueCatAndNavigate()
- }
- }
- }
-
- /**
- * Initialize RevenueCat after login and navigate to next screen
- */
- private fun initializeRevenueCatAndNavigate() {
- Log.d("LoginActivity", "Initializing RevenueCat after login...")
- revenueCatManager.initialize(this, auth, object : RevenueCatManager.SubscriptionCallback {
- override fun onSuccess(message: String) {
- Log.d("LoginActivity", "✅ RevenueCat initialized: $message")
- navigateToChatActivity()
- }
-
- override fun onError(error: String) {
- Log.e("LoginActivity", "❌ RevenueCat init failed: $error")
- navigateToChatActivity()
- }
-
- override fun onUserCancelled() {}
- })
- }
- private fun handleLoginError(exception: Exception?) {
- val errorMessage = when (exception) {
- is FirebaseAuthUserCollisionException -> "An account already exists with this email"
- is FirebaseNetworkException -> "Network error. Please check your connection"
- else -> "Authentication failed: ${exception?.message}"
- }
-
- Toast.makeText(this, errorMessage, Toast.LENGTH_LONG).show()
-
- // Track failed login attempt
- lifecycleScope.launch {
- userRepository.trackUserEvent(
- "anonymous",
- "login_failed",
- mapOf(
- "error_message" to (exception?.message ?: "Unknown error"),
- "login_method" to "google"
- )
- )
- }
- }
-
- private fun showLoading() {
- buttonGoogleSignIn.text = ""
- buttonGoogleSignIn.isEnabled = false
- progressIndicator.visibility = android.view.View.VISIBLE
- }
-
- private fun hideLoading() {
- buttonGoogleSignIn.text = getString(R.string.sign_in_with_google)
- buttonGoogleSignIn.isEnabled = true
- progressIndicator.visibility = android.view.View.GONE
- }
-
- private fun navigateToChatActivity() {
- val intent = Intent(this, ChatActivity::class.java)
- //intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
- startActivity(intent)
- finish()
- }
-
- public override fun onStart() {
- super.onStart()
- // Check if user is signed in and update UI accordingly
- val currentUser = auth.currentUser
- if (currentUser != null && userPreferences.isUserLoggedIn()) {
- navigateToChatActivity()
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/SettingsActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/SettingsActivity.kt
deleted file mode 100644
index 56aaf57..0000000
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/SettingsActivity.kt
+++ /dev/null
@@ -1,110 +0,0 @@
-package com.kharagedition.tibetankeyboard
-
-import android.os.Bundle
-import android.view.MenuItem
-import android.view.View.GONE
-import android.view.View.VISIBLE
-import androidx.appcompat.app.AppCompatActivity
-import androidx.appcompat.app.AppCompatDelegate
-import androidx.preference.PreferenceFragmentCompat
-import com.google.android.gms.ads.*
-import com.kharagedition.tibetankeyboard.auth.AuthManager
-import com.kharagedition.tibetankeyboard.databinding.SettingsActivityBinding
-import com.kharagedition.tibetankeyboard.subscription.RevenueCatManager
-import com.kharagedition.tibetankeyboard.util.showConfirmationDialog
-import com.kharagedition.tibetankeyboard.util.showToast
-
-class SettingsActivity : AppCompatActivity() {
- lateinit var settingBinding: SettingsActivityBinding
- var isPremiumUser = false;
- private lateinit var authManager: AuthManager
-
- override fun onStart() {
- AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
- super.onStart()
- }
-
- override fun onResume() {
- super.onResume()
- setPremiumListener()
- }
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- settingBinding = SettingsActivityBinding.inflate(layoutInflater)
- setContentView(settingBinding.root)
-
- if (savedInstanceState == null) {
- supportFragmentManager
- .beginTransaction()
- .replace(R.id.settings, SettingsFragment())
- .commit()
- }
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- authManager = AuthManager(this)
- if (!authManager.isUserAuthenticated()) {
- settingBinding.logoutBtn.visibility = GONE
- }else{
- settingBinding.logoutBtn.visibility = VISIBLE
- }
- initListener()
- val adRequest = AdRequest.Builder().build()
- loadBannerAds(adRequest)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- android.R.id.home -> {
- onBackPressed()
- return true
- }
- }
- return super.onOptionsItemSelected(item)
- }
-
- private fun initListener() {
- settingBinding.settingsToolbar.setNavigationOnClickListener{
- onBackPressed()
- }
- settingBinding.logoutBtn.setOnClickListener {
- showConfirmationDialog(
- title = "Sign Out",
- message = "Are you sure you want to sign out?",
- positiveText = "Sign Out",
- onPositive = { signOut() }
- )
- }
- setPremiumListener();
- }
-
- private fun signOut() {
- authManager.signOut {
- authManager.redirectToLogin()
- showToast("Signed out successfully")
- }
- }
-
- private fun setPremiumListener() {
- RevenueCatManager.getInstance().refreshCustomerInfo()
- RevenueCatManager.getInstance().isPremiumUser.observeForever{ isPremium ->
- isPremiumUser = isPremium;
- if(authManager.isUserAuthenticated() && isPremiumUser) {
- settingBinding.premiumIcon.visibility = VISIBLE
- settingBinding.bannerAd.visibility = GONE
- }else{
- settingBinding.premiumIcon.visibility = GONE
- settingBinding.bannerAd.visibility = VISIBLE
- }
- }
- }
-
- private fun loadBannerAds(adRequest: AdRequest) {
-
- settingBinding.bannerAd.loadAd(adRequest)
- }
-
- class SettingsFragment : PreferenceFragmentCompat() {
- override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
- setPreferencesFromResource(R.xml.root_preferences, rootKey)
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/TibetanKeyboardApp.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/TibetanKeyboardApp.kt
deleted file mode 100644
index 19b45a0..0000000
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/TibetanKeyboardApp.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package com.kharagedition.tibetankeyboard
-
-import android.app.Application
-import android.content.SharedPreferences
-import android.util.Log
-import androidx.preference.PreferenceManager
-import com.google.firebase.messaging.FirebaseMessaging
-import com.kharagedition.tibetankeyboard.util.AppConstant
-import com.revenuecat.purchases.LogLevel
-import com.revenuecat.purchases.Purchases
-import com.revenuecat.purchases.PurchasesAreCompletedBy.MY_APP
-import com.revenuecat.purchases.PurchasesAreCompletedBy.REVENUECAT
-import com.revenuecat.purchases.PurchasesConfiguration
-import com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener
-
-class TibetanKeyboardApp : Application() {
- lateinit var prefs: SharedPreferences
- override fun onCreate() {
- prefs = PreferenceManager.getDefaultSharedPreferences(this)
- // setup RevenueCat
- setUpRevenueCat()
- val enableEventNotification = prefs.getBoolean("event_notification", true)
- Log.e("TAG", "onCreate: CREATED: $enableEventNotification", )
- if(enableEventNotification){
- FirebaseMessaging.getInstance().subscribeToTopic(AppConstant.TIBETAN_KEYBOARD_APP)
- }else{
- FirebaseMessaging.getInstance().unsubscribeFromTopic(AppConstant.TIBETAN_KEYBOARD_APP)
- }
-
- super.onCreate()
- }
-
- private fun setUpRevenueCat() {
- // DO NOT initialize RevenueCat here without a user ID
- // RevenueCat will be configured in RevenueCatManager when user is authenticated
- // This ensures purchases are always tied to the Firebase user ID
- Purchases.logLevel = LogLevel.DEBUG
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/TibetanKeyboardView.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/TibetanKeyboardView.kt
deleted file mode 100644
index 08bb095..0000000
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/TibetanKeyboardView.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package com.kharagedition.tibetankeyboard
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.graphics.Canvas
-import android.graphics.Paint
-import android.graphics.Typeface
-import android.inputmethodservice.KeyboardView
-import android.util.AttributeSet
-
-/**
- * Created by kharag on 07,August,2022
- */
-//added for custom font purpose
-class TibetanKeyboardView(context: Context?, attrs: AttributeSet?) : KeyboardView(context, attrs) {
- /* @SuppressLint("DrawAllocation")
- @Deprecated("Deprecated in Java")
- override fun onDraw(canvas: Canvas?) {
- super.onDraw(canvas)
- val paint = Paint()
- paint.typeface = Typeface.DEFAULT_BOLD
- paint.textSize = 40.0f
- val font = Typeface.createFromAsset(context.assets,
- "fonts/qomolangma-tsutong.ttf"); //Insert your font here.
- paint.typeface = font;
-
- val keys = keyboard.keys;
- for(key in keys) {
- if(key.label != null)
- canvas?.drawText(key.label.toString(), key.x.toFloat(), key.y.toFloat(), paint);
- }
- }*/
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/UpdateNotificationManager.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/UpdateNotificationManager.kt
index 30cc7ac..47653fb 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/UpdateNotificationManager.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/UpdateNotificationManager.kt
@@ -1,108 +1,197 @@
package com.kharagedition.tibetankeyboard
-import android.app.NotificationChannel
-import android.app.NotificationManager
-import android.app.PendingIntent
import android.content.Context
-import android.content.Intent
-import android.content.pm.PackageManager
-import android.net.Uri
-import android.os.Build
-import androidx.core.app.NotificationCompat
-
-class UpdateNotificationManager(private val context: Context) {
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.IntentSenderRequest
+import com.google.android.play.core.appupdate.AppUpdateInfo
+import com.google.android.play.core.appupdate.AppUpdateManagerFactory
+import com.google.android.play.core.appupdate.AppUpdateOptions
+import com.google.android.play.core.install.InstallStateUpdatedListener
+import com.google.android.play.core.install.model.AppUpdateType
+import com.google.android.play.core.install.model.InstallStatus
+import com.google.android.play.core.install.model.UpdateAvailability
+
+/**
+ * Wraps the Google Play In-App Updates API.
+ *
+ * Strategy:
+ * - Flexible update (background download + user-visible install banner) for routine releases.
+ * - Immediate update (full-screen, blocking) when the release is ≥30 days stale or when
+ * a push notification forces the issue.
+ *
+ * Debug builds: skips the Play API entirely and simulates a downloaded flexible update
+ * after 3 s so the install banner UI can be verified without a Play-signed APK.
+ */
+class InAppUpdateManager(context: Context) {
+
+ private val appUpdateManager = AppUpdateManagerFactory.create(context)
+ private var installStateListener: InstallStateUpdatedListener? = null
+
+ /**
+ * Queries the Play Store for an available update and starts the appropriate flow.
+ * Safe to call from [HomeActivity.onCreate] and whenever [forceImmediate] is true
+ * (e.g. from a "force_update" FCM payload).
+ *
+ * In DEBUG builds the Play API is bypassed and [onFlexibleDownloadComplete] is invoked
+ * after 3 s so the install banner can be tested locally.
+ */
+ fun checkForUpdate(
+ launcher: ActivityResultLauncher,
+ forceImmediate: Boolean = false,
+ onFlexibleDownloadComplete: () -> Unit = {},
+ ) {
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "checkForUpdate: DEBUG build — Play API skipped (debug.keystore ≠ release key). " +
+ "Simulating flexible-update downloaded in ${DEBUG_DELAY_MS} ms.")
+ Handler(Looper.getMainLooper()).postDelayed({
+ Log.d(TAG, "checkForUpdate: DEBUG simulate → invoking onFlexibleDownloadComplete")
+ onFlexibleDownloadComplete()
+ }, DEBUG_DELAY_MS)
+ return
+ }
- companion object {
- private const val PREFS_NAME = "app_update_prefs"
- private const val LAST_UPDATE_CHECK = "last_update_check"
- private const val CURRENT_VERSION = "current_version"
+ Log.d(TAG, "checkForUpdate: querying Play API (forceImmediate=$forceImmediate)")
+ appUpdateManager.appUpdateInfo
+ .addOnSuccessListener { info ->
+ val availability = info.updateAvailability()
+ val installStatus = info.installStatus()
+ val stalenessDays = info.clientVersionStalenessDays()
+ val canFlexible = info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)
+ val canImmediate = info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)
+ Log.d(TAG, "checkForUpdate: updateAvailability=$availability " +
+ "installStatus=$installStatus stalenessDays=$stalenessDays " +
+ "canFlexible=$canFlexible canImmediate=$canImmediate")
+
+ when (availability) {
+ UpdateAvailability.UPDATE_AVAILABLE -> {
+ Log.d(TAG, "checkForUpdate: UPDATE_AVAILABLE — starting update flow")
+ startUpdate(info, forceImmediate, launcher, onFlexibleDownloadComplete)
+ }
+ UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS -> {
+ Log.d(TAG, "checkForUpdate: update already in progress — resuming immediate flow")
+ if (canImmediate) startImmediateUpdate(info, launcher)
+ }
+ else -> {
+ Log.d(TAG, "checkForUpdate: no update available (availability=$availability)")
+ }
+ }
+
+ // A flexible update finished downloading while the app was not in the foreground.
+ if (installStatus == InstallStatus.DOWNLOADED) {
+ Log.d(TAG, "checkForUpdate: flexible update already DOWNLOADED — notifying")
+ onFlexibleDownloadComplete()
+ }
+ }
+ .addOnFailureListener { e ->
+ Log.w(TAG, "checkForUpdate: Play API call failed — $e")
+ }
}
- private val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
-
- fun checkForUpdates() {
- val currentVersionCode = getCurrentVersionCode()
- val lastKnownVersion = sharedPrefs.getInt(CURRENT_VERSION, 0)
- val lastCheckTime = sharedPrefs.getLong(LAST_UPDATE_CHECK, 0)
- val currentTime = System.currentTimeMillis()
-
- // Check once per day
- if (currentTime - lastCheckTime > 24 * 60 * 60 * 1000) {
- // Here you could make an API call to check for updates
- // For demo purposes, we'll simulate an update available
- simulateUpdateCheck(currentVersionCode, lastKnownVersion)
-
- sharedPrefs.edit()
- .putLong(LAST_UPDATE_CHECK, currentTime)
- .putInt(CURRENT_VERSION, currentVersionCode)
- .apply()
- }
+ /**
+ * Lightweight resume check for [HomeActivity.onResume]: only asks whether a previously
+ * started flexible download has completed, without re-launching the consent dialog.
+ */
+ fun checkIfUpdateDownloaded(onDownloaded: () -> Unit) {
+ if (BuildConfig.DEBUG) return // debug simulation already fires from checkForUpdate
+
+ Log.d(TAG, "checkIfUpdateDownloaded: querying Play API")
+ appUpdateManager.appUpdateInfo
+ .addOnSuccessListener { info ->
+ val status = info.installStatus()
+ Log.d(TAG, "checkIfUpdateDownloaded: installStatus=$status")
+ if (status == InstallStatus.DOWNLOADED) {
+ Log.d(TAG, "checkIfUpdateDownloaded: DOWNLOADED — notifying")
+ onDownloaded()
+ }
+ }
+ .addOnFailureListener { e ->
+ Log.w(TAG, "checkIfUpdateDownloaded: Play API call failed — $e")
+ }
}
- private fun simulateUpdateCheck(currentVersion: Int, lastKnownVersion: Int) {
- // Simulate server response indicating update available
- // In real implementation, you'd call your server API here
-
- // Example: Show notification if we detect user might have an older version
- if (shouldShowUpdateNotification()) {
- showLocalUpdateNotification()
- }
+ /** Installs a downloaded flexible update (triggers app restart). */
+ fun completeFlexibleUpdate() {
+ Log.d(TAG, "completeFlexibleUpdate: calling appUpdateManager.completeUpdate()")
+ appUpdateManager.completeUpdate()
}
- private fun shouldShowUpdateNotification(): Boolean {
- // Add your logic here to determine when to show update notification
- // This could be based on server response, version comparison, etc.
- return true // For demo purposes
+ /** Must be called from [HomeActivity.onDestroy] to prevent listener leaks. */
+ fun unregisterListener() {
+ if (installStateListener != null) {
+ Log.d(TAG, "unregisterListener: removing InstallStateUpdatedListener")
+ appUpdateManager.unregisterListener(installStateListener!!)
+ installStateListener = null
+ }
}
- private fun showLocalUpdateNotification() {
- val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
- val channelId = "local_update_channel"
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- val channel = NotificationChannel(
- channelId,
- "Local Update Check",
- NotificationManager.IMPORTANCE_DEFAULT
- )
- notificationManager.createNotificationChannel(channel)
+ // ── internals ────────────────────────────────────────────────────────────
+
+ private fun startUpdate(
+ info: AppUpdateInfo,
+ forceImmediate: Boolean,
+ launcher: ActivityResultLauncher,
+ onFlexibleDownloadComplete: () -> Unit,
+ ) {
+ val stalenessDays = info.clientVersionStalenessDays() ?: 0
+ val canImmediate = info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)
+ val canFlexible = info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)
+
+ val chosenType = when {
+ (forceImmediate || stalenessDays >= 30) && canImmediate -> AppUpdateType.IMMEDIATE
+ canFlexible -> AppUpdateType.FLEXIBLE
+ canImmediate -> AppUpdateType.IMMEDIATE
+ else -> {
+ Log.w(TAG, "startUpdate: neither FLEXIBLE nor IMMEDIATE allowed — skipping")
+ return
+ }
}
- val playStoreIntent = Intent(Intent.ACTION_VIEW).apply {
- data = Uri.parse("market://details?id=${context.packageName}")
+ Log.d(TAG, "startUpdate: launching ${if (chosenType == AppUpdateType.IMMEDIATE) "IMMEDIATE" else "FLEXIBLE"} " +
+ "update flow (forceImmediate=$forceImmediate stalenessDays=$stalenessDays)")
+
+ if (chosenType == AppUpdateType.IMMEDIATE) {
+ startImmediateUpdate(info, launcher)
+ } else {
+ startFlexibleUpdate(info, launcher, onFlexibleDownloadComplete)
}
+ }
- val pendingIntent = PendingIntent.getActivity(
- context,
- 0,
- playStoreIntent,
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ private fun startImmediateUpdate(
+ info: AppUpdateInfo,
+ launcher: ActivityResultLauncher,
+ ) {
+ Log.d(TAG, "startImmediateUpdate: starting full-screen update UI")
+ appUpdateManager.startUpdateFlowForResult(
+ info, launcher,
+ AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build(),
)
+ }
- val notification = NotificationCompat.Builder(context, channelId)
- .setSmallIcon(R.drawable.ic_update)
- .setContentTitle("Update Available")
- .setContentText("Tap to update your keyboard app")
- .setAutoCancel(true)
- .setContentIntent(pendingIntent)
- .build()
+ private fun startFlexibleUpdate(
+ info: AppUpdateInfo,
+ launcher: ActivityResultLauncher,
+ onDownloadComplete: () -> Unit,
+ ) {
+ Log.d(TAG, "startFlexibleUpdate: registering InstallStateUpdatedListener + showing consent")
+ installStateListener = InstallStateUpdatedListener { state ->
+ Log.d(TAG, "InstallStateUpdatedListener: installStatus=${state.installStatus()}")
+ if (state.installStatus() == InstallStatus.DOWNLOADED) {
+ Log.d(TAG, "startFlexibleUpdate: download COMPLETE — notifying caller")
+ onDownloadComplete()
+ }
+ }.also { appUpdateManager.registerListener(it) }
- notificationManager.notify(1002, notification)
+ appUpdateManager.startUpdateFlowForResult(
+ info, launcher,
+ AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build(),
+ )
}
- private fun getCurrentVersionCode(): Int {
- return try {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- context.packageManager.getPackageInfo(
- context.packageName,
- PackageManager.PackageInfoFlags.of(0)
- ).longVersionCode.toInt()
- } else {
- @Suppress("DEPRECATION")
- context.packageManager.getPackageInfo(context.packageName, 0).versionCode
- }
- } catch (e: PackageManager.NameNotFoundException) {
- 0
- }
+ companion object {
+ private const val TAG = "InAppUpdate"
+ private const val DEBUG_DELAY_MS = 3_000L
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ads/TemplateView.java b/app/src/main/java/com/kharagedition/tibetankeyboard/ads/TemplateView.java
index 8526467..a1714b2 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/ads/TemplateView.java
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ads/TemplateView.java
@@ -208,6 +208,7 @@ public void setNativeAd(NativeAd nativeAd) {
nativeAdView.setCallToActionView(callToActionView);
nativeAdView.setHeadlineView(primaryView);
nativeAdView.setMediaView(mediaView);
+ nativeAdView.setIconView(iconView);
secondaryView.setVisibility(VISIBLE);
if (adHasOnlyStore(nativeAd)) {
nativeAdView.setStoreView(secondaryView);
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ai/AIGrammarViewModel.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ai/AIGrammarViewModel.kt
deleted file mode 100644
index 0a53575..0000000
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/ai/AIGrammarViewModel.kt
+++ /dev/null
@@ -1,84 +0,0 @@
-package com.kharagedition.tibetankeyboard.ai
-
-import android.app.Application
-import androidx.lifecycle.AndroidViewModel
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.MutableLiveData
-import androidx.lifecycle.viewModelScope
-import kotlinx.coroutines.launch
-import java.io.IOException
-
-data class GrammarCorrection(
- val id: String,
- val originalText: String,
- val correctedText: String,
- val reason: String,
- val confidence: Double,
- val alternatives: List,
- val explanation: String
-)
-
-data class GrammarAnalysisResult(
- val corrections: List,
- val overallScore: Double,
- val estimatedReadingLevel: String,
- val tone: String
-)
-
-/**
- * ViewModel for Grammar Analysis
- */
-class AIGrammarViewModel(app: Application) : AndroidViewModel(app) {
-
- private val repository = AIRepository(app)
-
- private val _isLoading = MutableLiveData()
- val isLoading: LiveData = _isLoading
-
- private val _grammarResult = MutableLiveData()
- val grammarResult: LiveData = _grammarResult
-
- private val _error = MutableLiveData()
- val error: LiveData = _error
-
- fun analyzeGrammar(text: String, userId: String) {
- viewModelScope.launch {
- try {
- _isLoading.value = true
- _error.value = null
-
- val result = repository.analyzeGrammar(text, userId)
- _grammarResult.value = result
-
- } catch (e: IOException) {
- _error.value = "Network error. Please check your connection."
- } catch (e: Exception) {
- _error.value = "Error analyzing grammar: ${e.message}"
- } finally {
- _isLoading.value = false
- }
- }
- }
-
- fun getToneSuggestions(text: String, userId: String) {
- viewModelScope.launch {
- try {
- _isLoading.value = true
- _error.value = null
-
- val result = repository.getToneSuggestions(text, userId)
- // Handle tone suggestions
-
- } catch (e: Exception) {
- _error.value = "Error getting suggestions: ${e.message}"
- } finally {
- _isLoading.value = false
- }
- }
- }
-
- fun clearResults() {
- _grammarResult.value = null
- _error.value = null
- }
-}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ai/AIRepository.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ai/AIRepository.kt
deleted file mode 100644
index 9379c39..0000000
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/ai/AIRepository.kt
+++ /dev/null
@@ -1,200 +0,0 @@
-package com.kharagedition.tibetankeyboard.ai
-
-import android.app.Application
-import android.content.Context
-import android.os.Build
-import com.google.firebase.auth.FirebaseAuth
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import java.net.HttpURLConnection
-import java.net.URL
-import java.util.UUID
-import org.json.JSONObject
-
-/**
- * Repository for AI feature API calls
- */
-class AIRepository(private val app: Application) {
-
- private val context: Context = app.applicationContext
- private val firebaseAuth: FirebaseAuth = FirebaseAuth.getInstance()
-
- // Backend API URL (update with your Firebase function URL)
- private val baseUrl = "https://asia-south1-tibetan-keyboard-123456.cloudfunctions.net"
-
- /**
- * Analyze Tibetan grammar using AI
- */
- suspend fun analyzeGrammar(text: String, userId: String): GrammarAnalysisResult {
- return withContext(Dispatchers.IO) {
- val endpoint = "$baseUrl/api/grammar/analyze"
-
- val requestBody = JSONObject().apply {
- put("text", text)
- put("mode", "detailed")
- put("style", "formal")
- put("contextualInfo", JSONObject().apply {
- put("userLevel", "intermediate")
- put("documentType", "casual")
- })
- }
-
- val response = makePostRequest(endpoint, requestBody.toString(), userId)
- parseGrammarResponse(response)
- }
- }
-
- /**
- * Get tone suggestions for text
- */
- suspend fun getToneSuggestions(text: String, userId: String): List {
- return withContext(Dispatchers.IO) {
- val endpoint = "$baseUrl/api/grammar/suggestions"
-
- val requestBody = JSONObject().apply {
- put("text", text)
- put("type", "alternatives")
- }
-
- makePostRequest(endpoint, requestBody.toString(), userId)
- listOf("Formal", "Casual", "Poetic")
- }
- }
-
- /**
- * Convert text using transliteration
- */
- suspend fun transliterate(
- text: String,
- sourceSystem: String,
- targetSystem: String,
- userId: String
- ): TransliterationResult {
- return withContext(Dispatchers.IO) {
- val endpoint = "$baseUrl/api/transliterate/convert"
-
- val requestBody = JSONObject().apply {
- put("text", text)
- put("sourceSystem", sourceSystem)
- put("targetSystem", targetSystem)
- }
-
- val response = makePostRequest(endpoint, requestBody.toString(), userId)
- parseTransliterationResponse(response)
- }
- }
-
- /**
- * Make POST request to API
- */
- private fun makePostRequest(endpoint: String, body: String, userId: String): String {
- val url = URL(endpoint)
- val connection = url.openConnection() as HttpURLConnection
-
- try {
- connection.requestMethod = "POST"
- connection.setRequestProperty("Content-Type", "application/json")
- connection.setRequestProperty("userid", userId)
- connection.setRequestProperty("User-Agent", "TibetanKeyboard/2.1.12")
- connection.doOutput = true
- connection.doInput = true
- connection.connectTimeout = 10000
- connection.readTimeout = 10000
-
- // Send request body
- connection.outputStream.use { output ->
- output.write(body.toByteArray())
- }
-
- // Read response
- return if (connection.responseCode == HttpURLConnection.HTTP_OK) {
- connection.inputStream.bufferedReader().use { it.readText() }
- } else {
- connection.errorStream?.bufferedReader()?.use { it.readText() } ?: "{\"error\":\"Unknown error\"}"
- }
-
- } finally {
- connection.disconnect()
- }
- }
-
- /**
- * Parse grammar analysis response
- */
- private fun parseGrammarResponse(jsonResponse: String): GrammarAnalysisResult {
- return try {
- val json = JSONObject(jsonResponse)
- val data = json.optJSONObject("data") ?: JSONObject()
-
- val corrections = mutableListOf()
- val correctionArray = data.optJSONArray("corrections") ?: return GrammarAnalysisResult(
- corrections = emptyList(),
- overallScore = 0.0,
- estimatedReadingLevel = "unknown",
- tone = "neutral"
- )
-
- for (i in 0 until correctionArray.length()) {
- val corrObj = correctionArray.getJSONObject(i)
- corrections.add(
- GrammarCorrection(
- id = corrObj.optString("id", ""),
- originalText = corrObj.optString("originalText", ""),
- correctedText = corrObj.optString("correctedText", ""),
- reason = corrObj.optString("reason", ""),
- confidence = corrObj.optDouble("confidence", 0.0),
- alternatives = mutableListOf().apply {
- val altArray = corrObj.optJSONArray("alternatives")
- for (j in 0 until (altArray?.length() ?: 0)) {
- add(altArray?.getString(j) ?: "")
- }
- },
- explanation = corrObj.optString("explanation", "")
- )
- )
- }
-
- GrammarAnalysisResult(
- corrections = corrections,
- overallScore = data.optDouble("overallScore", 0.0),
- estimatedReadingLevel = data.optString("estimatedReadingLevel", "intermediate"),
- tone = data.optJSONObject("toneAnalysis")?.optString("detectedTone", "neutral") ?: "neutral"
- )
-
- } catch (e: Exception) {
- GrammarAnalysisResult(
- corrections = emptyList(),
- overallScore = 0.0,
- estimatedReadingLevel = "unknown",
- tone = "neutral"
- )
- }
- }
-
- /**
- * Parse transliteration response
- */
- private fun parseTransliterationResponse(jsonResponse: String): TransliterationResult {
- return try {
- val json = JSONObject(jsonResponse)
- val data = json.optJSONObject("data") ?: JSONObject()
-
- TransliterationResult(
- result = data.optString("result", ""),
- alternatives = emptyList(),
- confidence = data.optDouble("confidence", 0.0),
- pronunciation = data.optString("pronunciation", "")
- )
-
- } catch (e: Exception) {
- TransliterationResult("", emptyList(), 0.0, "")
- }
- }
-}
-
-data class TransliterationResult(
- val result: String,
- val alternatives: List,
- val confidence: Double,
- val pronunciation: String = ""
-)
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ai/GrammarCorrectionAdapter.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ai/GrammarCorrectionAdapter.kt
deleted file mode 100644
index abae144..0000000
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/ai/GrammarCorrectionAdapter.kt
+++ /dev/null
@@ -1,84 +0,0 @@
-package com.kharagedition.tibetankeyboard.ai
-
-import android.view.LayoutInflater
-import android.view.ViewGroup
-import android.widget.TextView
-import androidx.core.content.ContextCompat
-import androidx.recyclerview.widget.DiffUtil
-import androidx.recyclerview.widget.ListAdapter
-import androidx.recyclerview.widget.RecyclerView
-import com.google.android.material.card.MaterialCardView
-import com.kharagedition.tibetankeyboard.R
-
-/**
- * Adapter for displaying grammar corrections
- */
-class GrammarCorrectionAdapter(
- private val onCorrectionClick: (GrammarCorrection) -> Unit
-) : ListAdapter(DiffCallback()) {
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
- val view = LayoutInflater.from(parent.context)
- .inflate(R.layout.item_grammar_correction, parent, false)
- return ViewHolder(view as MaterialCardView, onCorrectionClick)
- }
-
- override fun onBindViewHolder(holder: ViewHolder, position: Int) {
- holder.bind(getItem(position))
- }
-
- class ViewHolder(
- private val cardView: MaterialCardView,
- private val onCorrectionClick: (GrammarCorrection) -> Unit
- ) : RecyclerView.ViewHolder(cardView) {
-
- private val originalText: TextView = cardView.findViewById(R.id.original_text)
- private val correctedText: TextView = cardView.findViewById(R.id.corrected_text)
- private val reason: TextView = cardView.findViewById(R.id.correction_reason)
- private val confidence: TextView = cardView.findViewById(R.id.confidence_score)
-
- fun bind(correction: GrammarCorrection) {
- originalText.text = correction.originalText
- correctedText.text = correction.correctedText
- reason.text = correction.reason
-
- // Set confidence color based on score
- val confidencePercent = (correction.confidence * 100).toInt()
- confidence.text = "$confidencePercent% confident"
-
- val confidenceColor = when {
- correction.confidence >= 0.9 -> ContextCompat.getColor(
- cardView.context,
- R.color.success_green
- )
- correction.confidence >= 0.7 -> ContextCompat.getColor(
- cardView.context,
- R.color.warning_orange
- )
- else -> ContextCompat.getColor(cardView.context, R.color.error_red)
- }
- confidence.setTextColor(confidenceColor)
-
- // Add smooth entry animation
- cardView.alpha = 0f
- cardView.translationY = 50f
- cardView.animate()
- .alpha(1f)
- .translationY(0f)
- .setDuration(300)
- .start()
-
- cardView.setOnClickListener {
- onCorrectionClick(correction)
- }
- }
- }
-
- class DiffCallback : DiffUtil.ItemCallback() {
- override fun areItemsTheSame(oldItem: GrammarCorrection, newItem: GrammarCorrection): Boolean =
- oldItem.id == newItem.id
-
- override fun areContentsTheSame(oldItem: GrammarCorrection, newItem: GrammarCorrection): Boolean =
- oldItem == newItem
- }
-}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ai/YigChikAIAPI.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ai/YigChikAIAPI.kt
deleted file mode 100644
index 8506613..0000000
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/ai/YigChikAIAPI.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-package com.kharagedition.tibetankeyboard.ai
-
-import com.kharagedition.tibetankeyboard.model.GrammarCheckRequest
-import com.kharagedition.tibetankeyboard.model.GrammarCheckResponse
-import com.kharagedition.tibetankeyboard.model.TranslationRequest
-import com.kharagedition.tibetankeyboard.model.TranslationResponse
-import retrofit2.http.Body
-import retrofit2.http.Headers
-import retrofit2.http.POST
-
-interface YigChikAIAPI {
- @Headers("Content-Type: application/json", "Accept: application/json")
- @POST("grammar-check")
- suspend fun checkGrammar(@Body request: GrammarCheckRequest): GrammarCheckResponse
-}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ai/YigChikTranslateAPI.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ai/YigChikTranslateAPI.kt
deleted file mode 100644
index bdbfbe5..0000000
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/ai/YigChikTranslateAPI.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.kharagedition.tibetankeyboard.ai
-
-import com.kharagedition.tibetankeyboard.model.TranslationRequest
-import com.kharagedition.tibetankeyboard.model.TranslationResponse
-import retrofit2.http.Body
-import retrofit2.http.Headers
-import retrofit2.http.POST
-
-interface YigChikTranslateAPI {
- @Headers("Content-Type: application/json", "Accept: application/json")
- @POST("translate")
- suspend fun translateText(@Body request: TranslationRequest): TranslationResponse
-}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/analytics/AppAnalytics.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/analytics/AppAnalytics.kt
new file mode 100644
index 0000000..3fe455d
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/analytics/AppAnalytics.kt
@@ -0,0 +1,299 @@
+package com.kharagedition.tibetankeyboard.analytics
+
+import android.content.Context
+import android.os.Bundle
+import com.google.firebase.analytics.FirebaseAnalytics
+import com.kharagedition.tibetankeyboard.BuildConfig
+
+/**
+ * Single source of truth for product analytics (Firebase Analytics / GA4).
+ *
+ * Every custom event the app reports lives here as a typed method, so call sites stay clean
+ * (`AppAnalytics.logChatMessageSent(model)`) and the full event taxonomy is visible in one file.
+ *
+ * **Release-only:** [init] only wires a real [FirebaseAnalytics] instance for release builds. In
+ * debug builds the backing instance stays null, so every `log*` call is a no-op and no event is
+ * ever sent. This is the single gate — there is no per-call-site `if (BuildConfig.DEBUG)`.
+ *
+ * Not to be confused with [com.kharagedition.tibetankeyboard.data.repository.UserRepository.trackUserEvent],
+ * which writes per-user usage docs to Firestore for backend limits. This object feeds the
+ * Firebase console dashboards ("which feature is most used").
+ */
+object AppAnalytics {
+
+ /** Null in debug builds → all logging is a no-op. Set once in [init] for release builds. */
+ private var analytics: FirebaseAnalytics? = null
+
+ /** Wire up Firebase Analytics. Call once from Application.onCreate. No-op in debug builds. */
+ fun init(context: Context) {
+ if (BuildConfig.DEBUG) return
+ analytics = FirebaseAnalytics.getInstance(context.applicationContext)
+ }
+
+ /** Associate following events with the signed-in user (GA4 user id). Cleared on logout. */
+ fun setUser(userId: String?) {
+ analytics?.setUserId(userId)
+ }
+
+ /**
+ * Record keyboard-setup completion as user properties so users can be segmented by whether
+ * they ever enabled the IME / made it their default. Idempotent — only changes are reported.
+ */
+ fun setKeyboardSetup(enabled: Boolean, isDefault: Boolean) {
+ analytics?.setUserProperty(USER_PROP_KB_ENABLED, enabled.toString())
+ analytics?.setUserProperty(USER_PROP_KB_DEFAULT, isDefault.toString())
+ }
+
+ // ----------------------------------------------------------------------------------------
+ // Navigation / Home hub
+ // ----------------------------------------------------------------------------------------
+
+ /** A tap on a Home-screen tile. [action] is one of [HomeAction]. */
+ fun logHomeAction(action: String) = log(EVENT_HOME_ACTION, PARAM_ACTION to action)
+
+ // ----------------------------------------------------------------------------------------
+ // Auth / Login
+ // ----------------------------------------------------------------------------------------
+
+ /** A successful sign-in. New users are reported as `sign_up`, returning users as `login`. */
+ fun logLogin(isNewUser: Boolean, method: String = METHOD_GOOGLE) {
+ val event = if (isNewUser) FirebaseAnalytics.Event.SIGN_UP else FirebaseAnalytics.Event.LOGIN
+ log(event, FirebaseAnalytics.Param.METHOD to method)
+ }
+
+ fun logLoginFailed(reason: String, method: String = METHOD_GOOGLE) {
+ log(EVENT_LOGIN_FAILED, FirebaseAnalytics.Param.METHOD to method, PARAM_REASON to reason)
+ }
+
+ fun logLogout() = log(EVENT_LOGOUT)
+
+ // ----------------------------------------------------------------------------------------
+ // Chat
+ // ----------------------------------------------------------------------------------------
+
+ fun logChatOpened() = log(EVENT_CHAT_OPENED)
+
+ fun logChatMessageSent(model: String) = log(EVENT_CHAT_MESSAGE_SENT, PARAM_MODEL to model)
+
+ fun logChatResponseReceived(model: String, success: Boolean) =
+ log(EVENT_CHAT_RESPONSE_RECEIVED, PARAM_MODEL to model, PARAM_SUCCESS to success)
+
+ fun logChatCleared() = log(EVENT_CHAT_CLEARED)
+
+ fun logChatModelChanged(model: String) = log(EVENT_CHAT_MODEL_CHANGED, PARAM_MODEL to model)
+
+ // ----------------------------------------------------------------------------------------
+ // Translate
+ // ----------------------------------------------------------------------------------------
+
+ fun logTranslateOpened() = log(EVENT_TRANSLATE_OPENED)
+
+ fun logTranslatePerformed(sourceLang: String, targetLang: String, engine: String, success: Boolean) =
+ log(
+ EVENT_TRANSLATE_PERFORMED,
+ PARAM_SOURCE_LANG to sourceLang,
+ PARAM_TARGET_LANG to targetLang,
+ PARAM_ENGINE to engine,
+ PARAM_SUCCESS to success,
+ )
+
+ fun logTranslateLanguagesSwapped() = log(EVENT_TRANSLATE_SWAPPED)
+
+ fun logTranslateEngineChanged(engine: String) = log(EVENT_TRANSLATE_ENGINE_CHANGED, PARAM_ENGINE to engine)
+
+ // ----------------------------------------------------------------------------------------
+ // Subscription / Paywall
+ // ----------------------------------------------------------------------------------------
+
+ /** An "unlock PRO" intent, before any login/paywall is shown. [source] is one of [UpgradeSource]. */
+ fun logUpgradeClicked(source: String) = log(EVENT_UPGRADE_CLICKED, PARAM_SOURCE to source)
+
+ fun logPaywallViewed() = log(EVENT_PAYWALL_VIEWED)
+
+ fun logPurchaseStarted() = log(EVENT_PURCHASE_STARTED)
+
+ fun logPurchaseCompleted() = log(EVENT_PURCHASE_COMPLETED)
+
+ fun logPurchaseFailed(reason: String) = log(EVENT_PURCHASE_FAILED, PARAM_REASON to reason)
+
+ fun logPurchaseRestored() = log(EVENT_PURCHASE_RESTORED)
+
+ // ----------------------------------------------------------------------------------------
+ // Keyboard setup funnel (does the user ever enable / default the IME?)
+ // ----------------------------------------------------------------------------------------
+
+ fun logKeyboardEnableClicked() = log(EVENT_KEYBOARD_ENABLE_CLICKED)
+
+ fun logKeyboardPickerOpened() = log(EVENT_KEYBOARD_PICKER_OPENED)
+
+ // ----------------------------------------------------------------------------------------
+ // Keyboard usage (the IME itself)
+ // ----------------------------------------------------------------------------------------
+
+ /** The keyboard surfaced in some app. The core "is the keyboard being used" signal. */
+ fun logKeyboardShown() = log(EVENT_KEYBOARD_SHOWN)
+
+ /** User toggled the typing language. [language] is one of [KeyboardLanguage]. */
+ fun logKeyboardLanguageSwitched(language: String) =
+ log(EVENT_KEYBOARD_LANGUAGE_SWITCHED, PARAM_LANGUAGE to language)
+
+ fun logKeyboardEmojiOpened() = log(EVENT_KEYBOARD_EMOJI_OPENED)
+
+ /** User accepted a Botok autocomplete suggestion. */
+ fun logKeyboardSuggestionSelected() = log(EVENT_KEYBOARD_SUGGESTION_SELECTED)
+
+ // ----------------------------------------------------------------------------------------
+ // Keyboard AI (the IME — grammar / translate / rephrase from the keyboard toolbar)
+ // ----------------------------------------------------------------------------------------
+
+ /** An AI feature was invoked from the keyboard. [feature] is one of [KeyboardFeature]. */
+ fun logKeyboardAiUsed(feature: String) = log(EVENT_KEYBOARD_AI_USED, PARAM_FEATURE to feature)
+
+ /** The user accepted/applied the AI result back into the text field. [feature] is one of [KeyboardFeature]. */
+ fun logKeyboardAiApplied(feature: String) = log(EVENT_KEYBOARD_AI_APPLIED, PARAM_FEATURE to feature)
+
+ // ----------------------------------------------------------------------------------------
+ // Settings
+ // ----------------------------------------------------------------------------------------
+
+ /** A settings value changed. [setting] is one of [Setting]; [value] is the new value. */
+ fun logSettingChanged(setting: String, value: String) =
+ log(EVENT_SETTING_CHANGED, PARAM_SETTING to setting, PARAM_VALUE to value)
+
+ // ----------------------------------------------------------------------------------------
+ // Push notifications (FCM)
+ // ----------------------------------------------------------------------------------------
+
+ fun logNotificationReceived(type: String) = log(EVENT_NOTIFICATION_RECEIVED, PARAM_TYPE to type)
+
+ fun logNotificationOpened() = log(EVENT_NOTIFICATION_OPENED)
+
+ // ----------------------------------------------------------------------------------------
+ // Internals
+ // ----------------------------------------------------------------------------------------
+
+ /** Build a [Bundle] from the given params and forward to Firebase. No-op when [analytics] is null. */
+ private fun log(event: String, vararg params: Pair) {
+ val fa = analytics ?: return
+ val bundle = Bundle()
+ for ((key, value) in params) {
+ when (value) {
+ null -> {}
+ is String -> bundle.putString(key, value)
+ is Int -> bundle.putLong(key, value.toLong())
+ is Long -> bundle.putLong(key, value)
+ is Float -> bundle.putDouble(key, value.toDouble())
+ is Double -> bundle.putDouble(key, value)
+ is Boolean -> bundle.putString(key, value.toString())
+ else -> bundle.putString(key, value.toString())
+ }
+ }
+ fa.logEvent(event, bundle)
+ }
+
+ // Event names (snake_case, GA4 convention). Standard events reuse FirebaseAnalytics.Event.*
+ private const val EVENT_HOME_ACTION = "home_action"
+
+ private const val EVENT_LOGIN_FAILED = "login_failed"
+ private const val EVENT_LOGOUT = "logout"
+
+ private const val EVENT_CHAT_OPENED = "chat_opened"
+ private const val EVENT_CHAT_MESSAGE_SENT = "chat_message_sent"
+ private const val EVENT_CHAT_RESPONSE_RECEIVED = "chat_response_received"
+ private const val EVENT_CHAT_CLEARED = "chat_cleared"
+ private const val EVENT_CHAT_MODEL_CHANGED = "chat_model_changed"
+
+ private const val EVENT_TRANSLATE_OPENED = "translate_opened"
+ private const val EVENT_TRANSLATE_PERFORMED = "translate_performed"
+ private const val EVENT_TRANSLATE_SWAPPED = "translate_languages_swapped"
+ private const val EVENT_TRANSLATE_ENGINE_CHANGED = "translate_engine_changed"
+
+ private const val EVENT_UPGRADE_CLICKED = "upgrade_clicked"
+ private const val EVENT_PAYWALL_VIEWED = "paywall_viewed"
+ private const val EVENT_PURCHASE_STARTED = "purchase_started"
+ private const val EVENT_PURCHASE_COMPLETED = "purchase_completed"
+ private const val EVENT_PURCHASE_FAILED = "purchase_failed"
+ private const val EVENT_PURCHASE_RESTORED = "purchase_restored"
+
+ private const val EVENT_KEYBOARD_ENABLE_CLICKED = "keyboard_enable_clicked"
+ private const val EVENT_KEYBOARD_PICKER_OPENED = "keyboard_picker_opened"
+ private const val EVENT_KEYBOARD_SHOWN = "keyboard_shown"
+ private const val EVENT_KEYBOARD_LANGUAGE_SWITCHED = "keyboard_language_switched"
+ private const val EVENT_KEYBOARD_EMOJI_OPENED = "keyboard_emoji_opened"
+ private const val EVENT_KEYBOARD_SUGGESTION_SELECTED = "keyboard_suggestion_selected"
+ private const val EVENT_KEYBOARD_AI_USED = "keyboard_ai_used"
+ private const val EVENT_KEYBOARD_AI_APPLIED = "keyboard_ai_applied"
+
+ private const val EVENT_SETTING_CHANGED = "setting_changed"
+
+ private const val EVENT_NOTIFICATION_RECEIVED = "notification_received"
+ private const val EVENT_NOTIFICATION_OPENED = "notification_opened"
+
+ // Param keys
+ private const val PARAM_ACTION = "action"
+ private const val PARAM_REASON = "reason"
+ private const val PARAM_MODEL = "model"
+ private const val PARAM_SUCCESS = "success"
+ private const val PARAM_SOURCE_LANG = "source_lang"
+ private const val PARAM_TARGET_LANG = "target_lang"
+ private const val PARAM_ENGINE = "engine"
+ private const val PARAM_FEATURE = "feature"
+ private const val PARAM_LANGUAGE = "language"
+ private const val PARAM_SETTING = "setting"
+ private const val PARAM_VALUE = "value"
+ private const val PARAM_SOURCE = "source"
+ private const val PARAM_TYPE = "type"
+
+ // User properties
+ private const val USER_PROP_KB_ENABLED = "kb_enabled"
+ private const val USER_PROP_KB_DEFAULT = "kb_default"
+
+ // Param values
+ private const val METHOD_GOOGLE = "google"
+
+ /** Stable feature labels for [logKeyboardAiUsed] / [logKeyboardAiApplied]. */
+ object KeyboardFeature {
+ const val GRAMMAR = "grammar"
+ const val TRANSLATE = "translate"
+ const val REPHRASE = "rephrase"
+ }
+
+ /** Stable language labels for [logKeyboardLanguageSwitched]. */
+ object KeyboardLanguage {
+ const val TIBETAN = "tibetan"
+ const val ENGLISH = "english"
+ }
+
+ /** Stable action labels for [logHomeAction]. */
+ object HomeAction {
+ const val ENABLE_KEYBOARD = "enable_keyboard"
+ const val PICK_INPUT_METHOD = "pick_input_method"
+ const val CHAT = "chat"
+ const val TRANSLATE = "translate"
+ const val SETTINGS = "settings"
+ const val SHARE = "share"
+ const val RATE = "rate"
+ const val ABOUT = "about"
+ const val UPGRADE = "upgrade"
+ }
+
+ /** Stable setting labels for [logSettingChanged]. */
+ object Setting {
+ const val COLOR = "color"
+ const val STYLE = "style"
+ const val VIBRATE = "vibrate"
+ const val SOUND = "sound"
+ const val NOTIFICATION = "notification"
+ }
+
+ /** Stable source labels for [logUpgradeClicked] (where the upgrade intent originated). */
+ object UpgradeSource {
+ const val HOME = "home"
+ const val HOME_FEATURE_GATE = "home_feature_gate"
+ const val CHAT = "chat"
+ const val TRANSLATE = "translate"
+ const val SETTINGS = "settings"
+ const val KEYBOARD = "keyboard"
+ const val UNKNOWN = "unknown"
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/analytics/UserActivityTracker.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/analytics/UserActivityTracker.kt
new file mode 100644
index 0000000..0810370
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/analytics/UserActivityTracker.kt
@@ -0,0 +1,145 @@
+package com.kharagedition.tibetankeyboard.analytics
+
+import android.content.Context
+import android.util.Log
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.firestore.FieldValue
+import com.google.firebase.firestore.FirebaseFirestore
+import com.google.firebase.firestore.SetOptions
+import com.kharagedition.tibetankeyboard.data.local.UserPreferences
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+/**
+ * Maintains **queryable per-user activity aggregates** on `users/{uid}.activity` so the most active
+ * users can be pulled with a single Firestore query — something neither Firebase Analytics (GA4, which
+ * only feeds aggregate dashboards, see [AppAnalytics]) nor the raw per-event `user_analytics` log
+ * (which would have to be aggregated client-side) can answer cheaply.
+ *
+ * ### What lands on `users/{uid}.activity`
+ * - `lastActiveAt` — epoch millis of the user's most recent active day (recency filter/sort).
+ * - `lastActiveDay` — `yyyy-MM-dd` (UTC) of that day, human-readable.
+ * - `activeDaysCount` — distinct days the user was ever active (monotonic). The canonical
+ * "most active / most engaged" metric.
+ * - `lastActiveSource` — `app` or `keyboard`, the surface that recorded the latest day.
+ *
+ * ### Cost
+ * At most **one Firestore write per user per UTC day**. An in-memory fast-path skips work entirely
+ * once the day's ping is done, and a durable [UserPreferences]-style guard (its own prefs file)
+ * survives process restarts so a cold start mid-day doesn't double-write.
+ *
+ * ### How to read it back (admin/console/BigQuery — not wired in-app)
+ * ```
+ * // Most engaged users (power users):
+ * db.collection("users")
+ * .orderBy("activity.activeDaysCount", Query.Direction.DESCENDING)
+ * .limit(50)
+ *
+ * // Recently active users (last 7 days):
+ * val cutoff = System.currentTimeMillis() - 7L * 24 * 60 * 60 * 1000
+ * db.collection("users")
+ * .whereGreaterThan("activity.lastActiveAt", cutoff)
+ * .orderBy("activity.lastActiveAt", Query.Direction.DESCENDING)
+ * ```
+ *
+ * The `activity` map is intentionally a **separate top-level field** from the `User.analytics`
+ * object: `UserRepository.createOrUpdateUser` rewrites `analytics` wholesale on every login, which
+ * would clobber an incrementing counter living inside it. We only ever touch `activity` via merged
+ * dotted-path writes here.
+ */
+object UserActivityTracker {
+
+ /** Where the activity ping originated, persisted as `activity.lastActiveSource`. */
+ enum class Source(val tag: String) {
+ APP("app"),
+ KEYBOARD("keyboard"),
+ }
+
+ private const val TAG = "UserActivityTracker"
+ private const val PREFS = "user_activity"
+ private const val KEY_LAST_PING_EPOCH_DAY = "last_ping_epoch_day"
+ private const val KEY_LAST_PING_UID = "last_ping_uid"
+
+ private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
+
+ private val db get() = FirebaseFirestore.getInstance()
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ /** In-memory fast-path: once today's ping is written we skip even launching a coroutine. */
+ @Volatile private var memoEpochDay: Long = -1
+ @Volatile private var memoUid: String? = null
+
+ /**
+ * Record that the signed-in user is active right now. Fire-and-forget and idempotent within a
+ * UTC day — safe to call on every Activity resume / keyboard show. No-op when no user is signed
+ * in (anonymous activity can't be attributed to a user).
+ */
+ fun recordActive(context: Context, source: Source) {
+ val uid = resolveUid(context)
+ if (uid.isNullOrBlank()) return
+
+ val epochDay = System.currentTimeMillis() / MILLIS_PER_DAY
+ // Already pinged for this user today — nothing to do (no coroutine, no disk, no network).
+ if (epochDay == memoEpochDay && uid == memoUid) return
+
+ val appContext = context.applicationContext
+ scope.launch { maybeWrite(appContext, uid, epochDay, source) }
+ }
+
+ /** Durable throttle + the actual Firestore write. Runs off the main thread. */
+ private fun maybeWrite(context: Context, uid: String, epochDay: Long, source: Source) {
+ val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ val lastDay = prefs.getLong(KEY_LAST_PING_EPOCH_DAY, -1)
+ val lastUid = prefs.getString(KEY_LAST_PING_UID, null)
+
+ // Already recorded for this user on this UTC day (survives process restarts).
+ if (epochDay == lastDay && uid == lastUid) {
+ memoEpochDay = epochDay
+ memoUid = uid
+ return
+ }
+
+ val now = System.currentTimeMillis()
+ val activity = mapOf(
+ "lastActiveAt" to now,
+ "lastActiveDay" to utcDay(now),
+ "lastActiveSource" to source.tag,
+ // Count a distinct active day. Increment (not a plain write) is atomic and never races.
+ "activeDaysCount" to FieldValue.increment(1),
+ )
+
+ // merge() so this both creates the field if absent and never disturbs other user fields.
+ db.collection("users").document(uid)
+ .set(mapOf("activity" to activity), SetOptions.merge())
+ .addOnSuccessListener {
+ memoEpochDay = epochDay
+ memoUid = uid
+ prefs.edit()
+ .putLong(KEY_LAST_PING_EPOCH_DAY, epochDay)
+ .putString(KEY_LAST_PING_UID, uid)
+ .apply()
+ }
+ .addOnFailureListener { e ->
+ // Best-effort: a failed ping just means we retry on the next activity. Don't persist
+ // the throttle so the next attempt is allowed to write.
+ Log.w(TAG, "activity ping failed for uid=$uid (will retry next activity)", e)
+ }
+ }
+
+ /** Prefer the live Firebase session; fall back to the persisted uid (e.g. inside the IME). */
+ private fun resolveUid(context: Context): String? {
+ FirebaseAuth.getInstance().currentUser?.uid?.let { if (it.isNotBlank()) return it }
+ return UserPreferences(context).getUserId().takeIf { it.isNotBlank() }
+ }
+
+ private fun utcDay(millis: Long): String =
+ SimpleDateFormat("yyyy-MM-dd", Locale.US)
+ .apply { timeZone = TimeZone.getTimeZone("UTC") }
+ .format(Date(millis))
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/application/InputMethodActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/app/InputMethodActivity.kt
similarity index 87%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/application/InputMethodActivity.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/app/InputMethodActivity.kt
index 90f6e2d..7089cab 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/application/InputMethodActivity.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/app/InputMethodActivity.kt
@@ -1,8 +1,9 @@
-package com.kharagedition.tibetankeyboard.application
+package com.kharagedition.tibetankeyboard.app
import android.os.Bundle
import android.view.inputmethod.InputMethodManager
import androidx.appcompat.app.AppCompatActivity
+import com.kharagedition.tibetankeyboard.analytics.AppAnalytics
/**
@@ -26,6 +27,7 @@ abstract class InputMethodActivity : AppCompatActivity() {
private var mState = 0
protected fun pickInput() {
+ AppAnalytics.logKeyboardPickerOpened()
val imm: InputMethodManager =
applicationContext.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
imm.showInputMethodPicker()
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/app/TibetanKeyboardApp.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/app/TibetanKeyboardApp.kt
new file mode 100644
index 0000000..3a7134f
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/app/TibetanKeyboardApp.kt
@@ -0,0 +1,79 @@
+package com.kharagedition.tibetankeyboard.app
+
+import android.app.Activity
+import android.app.Application
+import android.content.SharedPreferences
+import android.os.Bundle
+import android.util.Log
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.preference.PreferenceManager
+import com.google.firebase.messaging.FirebaseMessaging
+import com.kharagedition.tibetankeyboard.analytics.AppAnalytics
+import com.kharagedition.tibetankeyboard.analytics.UserActivityTracker
+import com.kharagedition.tibetankeyboard.util.AppConstant
+import com.revenuecat.purchases.LogLevel
+import com.revenuecat.purchases.Purchases
+import com.revenuecat.purchases.PurchasesAreCompletedBy.MY_APP
+import com.revenuecat.purchases.PurchasesAreCompletedBy.REVENUECAT
+import com.revenuecat.purchases.PurchasesConfiguration
+import com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+
+class TibetanKeyboardApp : Application() {
+ lateinit var prefs: SharedPreferences
+
+ /**
+ * App-lived coroutine scope for fire-and-forget work that must outlive a single screen
+ * (e.g. post-login profile sync / analytics that should not be cancelled when LoginActivity
+ * finishes). SupervisorJob so one failed child doesn't cancel its siblings.
+ */
+ val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ override fun onCreate() {
+ // The app is a single premium dark-warm design; force light mode globally so no
+ // activity needs to set it individually.
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
+ prefs = PreferenceManager.getDefaultSharedPreferences(this)
+ // Product analytics — release-only (no-op in debug builds, see AppAnalytics).
+ AppAnalytics.init(this)
+ // Per-user activity aggregates (users/{uid}.activity) — drives the "most active users"
+ // Firestore query. Throttled to one write/user/day, so resuming any screen is cheap.
+ registerActivityLifecycleCallbacks(activityTracker)
+ // setup RevenueCat
+ setUpRevenueCat()
+ val enableEventNotification = prefs.getBoolean("event_notification", true)
+ Log.e("TAG", "onCreate: CREATED: $enableEventNotification", )
+ if(enableEventNotification){
+ FirebaseMessaging.getInstance().subscribeToTopic(AppConstant.TIBETAN_KEYBOARD_APP)
+ }else{
+ FirebaseMessaging.getInstance().unsubscribeFromTopic(AppConstant.TIBETAN_KEYBOARD_APP)
+ }
+
+ super.onCreate()
+ }
+
+ private fun setUpRevenueCat() {
+ // DO NOT initialize RevenueCat here without a user ID
+ // RevenueCat will be configured in RevenueCatManager when user is authenticated
+ // This ensures purchases are always tied to the Firebase user ID
+ Purchases.logLevel = LogLevel.DEBUG
+ }
+
+ /**
+ * Records a daily activity ping whenever any app screen comes to the foreground. The tracker
+ * itself is throttled (one write/user/day), so a ping on every resume is intentional and cheap.
+ */
+ private val activityTracker = object : ActivityLifecycleCallbacks {
+ override fun onActivityResumed(activity: Activity) {
+ UserActivityTracker.recordActive(activity, UserActivityTracker.Source.APP)
+ }
+
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
+ override fun onActivityStarted(activity: Activity) {}
+ override fun onActivityPaused(activity: Activity) {}
+ override fun onActivityStopped(activity: Activity) {}
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
+ override fun onActivityDestroyed(activity: Activity) {}
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/auth/AuthManager.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/auth/AuthManager.kt
index 7344b6c..2e3ff36 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/auth/AuthManager.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/auth/AuthManager.kt
@@ -4,13 +4,18 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
+import com.google.android.gms.auth.api.signin.GoogleSignIn
+import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.ktx.auth
import com.google.firebase.ktx.Firebase
-import com.kharagedition.tibetankeyboard.LoginActivity
-import com.kharagedition.tibetankeyboard.UserPreferences
-import com.kharagedition.tibetankeyboard.subscription.RevenueCatManager
+import com.kharagedition.tibetankeyboard.R
+import com.kharagedition.tibetankeyboard.analytics.AppAnalytics
+import com.kharagedition.tibetankeyboard.ui.login.LoginActivity
+import com.kharagedition.tibetankeyboard.ui.subscription.PremiumActivity
+import com.kharagedition.tibetankeyboard.data.local.UserPreferences
+import com.kharagedition.tibetankeyboard.data.repository.RevenueCatManager
/**
* Manages authentication state and user session
@@ -49,14 +54,12 @@ class AuthManager(private val context: Context) {
revenueCatManager.logout(object : RevenueCatManager.SubscriptionCallback {
override fun onSuccess(message: String) {
- auth.signOut()
- userPreferences.clearUserData()
+ finishSignOut()
onComplete()
}
override fun onError(error: String) {
- auth.signOut()
- userPreferences.clearUserData()
+ finishSignOut()
onComplete()
}
@@ -67,20 +70,62 @@ class AuthManager(private val context: Context) {
}
/**
- * Redirect to login activity
+ * Tear down every cached session. Firebase's signOut alone leaves Google's last-used account
+ * cached on the device, so the next sign-in silently re-picks it; signing out of the
+ * GoogleSignInClient too forces the account chooser to reappear on the next login.
*/
- fun redirectToLogin() {
-
+ private fun finishSignOut() {
+ auth.signOut()
+ val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
+ .requestIdToken(context.getString(R.string.default_web_client_id))
+ .requestEmail()
+ .build()
+ GoogleSignIn.getClient(context, gso).signOut()
+ userPreferences.clearUserData()
+ AppAnalytics.logLogout()
+ AppAnalytics.setUser(null)
+ }
+ /**
+ * Redirect to login activity
+ */
+ fun redirectToLogin(
+ openPremiumAfter: Boolean = false,
+ finishCaller: Boolean = true,
+ target: Class<*>? = null,
+ ) {
val intent = Intent(context, LoginActivity::class.java)
+ if (openPremiumAfter) {
+ intent.putExtra(LoginActivity.EXTRA_OPEN_PREMIUM_AFTER_LOGIN, true)
+ }
+ if (target != null) {
+ intent.putExtra(LoginActivity.EXTRA_POST_LOGIN_TARGET, target.name)
+ }
+
if (context !is Activity) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
- (context as? Activity)?.finish()
+ // Keep the caller alive when asked (e.g. Home stays beneath the paywall so
+ // closing the paywall never leaves an empty back stack).
+ if (finishCaller) (context as? Activity)?.finish()
+ }
+
+ /**
+ * Open the premium paywall directly (used for already-signed-in free users tapping a
+ * PRO feature). Safe to call from a non-Activity context such as the IME service.
+ */
+ fun openPremium() {
+ val intent = Intent(context, PremiumActivity::class.java)
+
+ if (context !is Activity) {
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+
+ context.startActivity(intent)
}
/**
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/auth/UnlockRouter.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/auth/UnlockRouter.kt
new file mode 100644
index 0000000..9fd1f00
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/auth/UnlockRouter.kt
@@ -0,0 +1,19 @@
+package com.kharagedition.tibetankeyboard.auth
+
+/** Where a free user tapping a PRO control should be sent. */
+enum class UnlockDestination {
+ /** Not signed in: go to login first (login then forwards to the paywall). */
+ LOGIN_THEN_PREMIUM,
+
+ /** Signed in but not subscribed: go straight to the premium paywall. */
+ PREMIUM,
+}
+
+/**
+ * Pure routing decision for the "unlock PRO" flow, separated from Activity/IME plumbing so the
+ * conversion-critical branch is unit-testable.
+ */
+object UnlockRouter {
+ fun destinationFor(isAuthenticated: Boolean): UnlockDestination =
+ if (isAuthenticated) UnlockDestination.PREMIUM else UnlockDestination.LOGIN_THEN_PREMIUM
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/chat/ChatAdapter.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/chat/ChatAdapter.kt
deleted file mode 100644
index 8883e27..0000000
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/chat/ChatAdapter.kt
+++ /dev/null
@@ -1,105 +0,0 @@
-package com.kharagedition.tibetankeyboard.chat
-import android.content.ClipData
-import android.content.Context
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.ImageView
-import android.widget.TextView
-import androidx.recyclerview.widget.DiffUtil
-import androidx.recyclerview.widget.ListAdapter
-import androidx.recyclerview.widget.RecyclerView
-import com.kharagedition.tibetankeyboard.R
-import java.text.SimpleDateFormat
-import java.util.Locale
-import android.content.ClipboardManager
-class ChatAdapter(isPremium: Boolean) : ListAdapter(ChatDiffCallback()) {
-
- companion object {
- private const val VIEW_TYPE_USER = 1
- private const val VIEW_TYPE_ASSISTANT = 2
- }
- private var isPremiumUser: Boolean = isPremium;
- init {
- this.isPremiumUser = isPremium
- }
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
- return when (viewType) {
- VIEW_TYPE_USER -> {
- val view = LayoutInflater.from(parent.context)
- .inflate(R.layout.item_chat_user, parent, false)
- UserMessageViewHolder(view)
- }
- VIEW_TYPE_ASSISTANT -> {
- val view = LayoutInflater.from(parent.context)
- .inflate(R.layout.item_chat_assistant, parent, false)
- //change view imageViewAssistant color if isPremiumUser
- val assistantImageView = view.findViewById(R.id.imageViewAssistant)
-
- if(isPremiumUser){
- assistantImageView.setColorFilter(view.context.getColor(R.color.premium_yellow))
- }else{
- // remove and make it default
- assistantImageView.colorFilter = null
- }
- AssistantMessageViewHolder(view)
- }
- else -> throw IllegalArgumentException("Invalid view type")
- }
- }
-
- override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
- val message = getItem(position)
-
- when (holder) {
- is UserMessageViewHolder -> holder.bind(message)
- is AssistantMessageViewHolder -> holder.bind(message)
- }
- }
-
- override fun getItemViewType(position: Int): Int {
- return if (getItem(position).isFromUser) VIEW_TYPE_USER else VIEW_TYPE_ASSISTANT
- }
-
- class UserMessageViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
- private val textViewMessage: TextView = itemView.findViewById(R.id.textViewUserMessage)
- private val textViewTimestamp: TextView = itemView.findViewById(R.id.textViewTimestamp)
- private val timeFormat = SimpleDateFormat("h:mm a", Locale.getDefault())
-
- fun bind(message: ChatMessage) {
- textViewMessage.setTextIsSelectable(true)
- textViewMessage.text = message.message
- textViewTimestamp.text = timeFormat.format(message.timestamp)
- }
- }
-
- class AssistantMessageViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
- private val textViewMessage: TextView = itemView.findViewById(R.id.textViewAssistantMessage)
- private val textViewTimestamp: TextView = itemView.findViewById(R.id.textViewTimestamp)
- private val timeFormat = SimpleDateFormat("h:mm a", Locale.getDefault())
- private val clipboardImg : ImageView = itemView.findViewById(R.id.clipboardIcon)
- fun bind(message: ChatMessage) {
- textViewMessage.setTextIsSelectable(true)
- textViewMessage.text = message.message
- textViewTimestamp.text = timeFormat.format(message.timestamp)
- clipboardImg.setOnClickListener {
- val clipboard = itemView.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
- val clip = ClipData.newPlainText("message", message.message)
- clipboard.setPrimaryClip(clip)
-
-
- }
- }
- }
-
- class ChatDiffCallback : DiffUtil.ItemCallback() {
- override fun areItemsTheSame(oldItem: ChatMessage, newItem: ChatMessage): Boolean {
- return oldItem.id == newItem.id
- }
-
- override fun areContentsTheSame(oldItem: ChatMessage, newItem: ChatMessage): Boolean {
- return oldItem == newItem
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/chat/ChatRepository.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/chat/ChatRepository.kt
deleted file mode 100644
index b097b9e..0000000
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/chat/ChatRepository.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-package com.kharagedition.tibetankeyboard.chat
-
-import com.kharagedition.tibetankeyboard.ai.RetrofitClient
-import com.kharagedition.tibetankeyboard.model.GeminiChatRequest
-
-class ChatRepository() {
- private var currentSessionId: String? = null
-
- suspend fun sendMessage(message: String, userId: String): String {
- return try {
- val request = GeminiChatRequest(
- message = message,
- sessionId = currentSessionId,
- resetChat = false
- )
-
- val response = RetrofitClient.geminiAPI.chatWithGemini(request,userId)
-
- if (response.success && response.data != null) {
- // Update session ID for conversation continuity
- currentSessionId = response.data.sessionId
- response.data.response
- } else {
- response.error ?: "དགོངས་དག། ལན་འདེབས་དཀའ་ངལ་འཕྲད་སོང་།"
- }
- } catch (e: Exception) {
- e.printStackTrace()
- "དགོངས་དག། ཕྱི་ཕྱོགས་དང་འབྲེལ་བའི་དཀའ་ངལ་ཞིག་འཕྲད་སོང་།"
- }
- }
-
- suspend fun resetChat(): Boolean {
- return try {
- currentSessionId?.let { sessionId ->
- val request = mapOf("sessionId" to sessionId)
- val response = RetrofitClient.geminiAPI.resetChatSession(request)
- if (response.success) {
- currentSessionId = null
- true
- } else {
- false
- }
- } ?: true // If no session, consider it reset
- } catch (e: Exception) {
- e.printStackTrace()
- false
- }
- }
-
- fun clearSession() {
- currentSessionId = null
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/UserPreferences.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/data/local/UserPreferences.kt
similarity index 98%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/UserPreferences.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/data/local/UserPreferences.kt
index 46a5400..1cf1a95 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/UserPreferences.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/data/local/UserPreferences.kt
@@ -1,4 +1,4 @@
-package com.kharagedition.tibetankeyboard
+package com.kharagedition.tibetankeyboard.data.local
import android.content.Context
import android.content.SharedPreferences
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/chat/ChatMessage.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/data/model/ChatMessage.kt
similarity index 78%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/chat/ChatMessage.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/data/model/ChatMessage.kt
index 16ad41e..4d057ef 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/chat/ChatMessage.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/data/model/ChatMessage.kt
@@ -1,4 +1,4 @@
-package com.kharagedition.tibetankeyboard.chat
+package com.kharagedition.tibetankeyboard.data.model
import java.util.Date
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/model/DataClass.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/data/model/DataClass.kt
similarity index 93%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/model/DataClass.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/data/model/DataClass.kt
index a53ea54..69bda3e 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/model/DataClass.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/data/model/DataClass.kt
@@ -1,4 +1,4 @@
-package com.kharagedition.tibetankeyboard.model
+package com.kharagedition.tibetankeyboard.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -27,7 +27,8 @@ data class TranslationResult(
data class TranslationRequest(
val text: String,
val sourceLang: String,
- val targetLang: String
+ val targetLang: String,
+ val model: String? = null
)
@Serializable
@@ -102,7 +103,8 @@ data class GrammarCheckResponse(
data class GeminiChatRequest(
val message: String,
val sessionId: String? = null,
- val resetChat: Boolean? = false
+ val resetChat: Boolean? = false,
+ val model: String? = null
)
@Serializable
data class GeminiChatResponse(
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/util/User.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/data/model/User.kt
similarity index 96%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/util/User.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/data/model/User.kt
index 284cb5f..4a97c4a 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/util/User.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/data/model/User.kt
@@ -1,4 +1,4 @@
-package com.kharagedition.tibetankeyboard.util
+package com.kharagedition.tibetankeyboard.data.model
data class User(
val uid: String = "",
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ai/RetrofitClient.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/data/remote/RetrofitClient.kt
similarity index 97%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/ai/RetrofitClient.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/data/remote/RetrofitClient.kt
index 2e80194..ea7f29d 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/ai/RetrofitClient.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/data/remote/RetrofitClient.kt
@@ -1,4 +1,4 @@
-package com.kharagedition.tibetankeyboard.ai
+package com.kharagedition.tibetankeyboard.data.remote
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/data/remote/YigChikAIAPI.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/data/remote/YigChikAIAPI.kt
new file mode 100644
index 0000000..313bca9
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/data/remote/YigChikAIAPI.kt
@@ -0,0 +1,15 @@
+package com.kharagedition.tibetankeyboard.data.remote
+
+import com.kharagedition.tibetankeyboard.data.model.GrammarCheckRequest
+import com.kharagedition.tibetankeyboard.data.model.GrammarCheckResponse
+import com.kharagedition.tibetankeyboard.data.model.TranslationRequest
+import com.kharagedition.tibetankeyboard.data.model.TranslationResponse
+import retrofit2.http.Body
+import retrofit2.http.Headers
+import retrofit2.http.POST
+
+interface YigChikAIAPI {
+ @Headers("Content-Type: application/json", "Accept: application/json")
+ @POST("grammar-check")
+ suspend fun checkGrammar(@Body request: GrammarCheckRequest): GrammarCheckResponse
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ai/YigChikGeminiAPI.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/data/remote/YigChikGeminiAPI.kt
similarity index 68%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/ai/YigChikGeminiAPI.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/data/remote/YigChikGeminiAPI.kt
index 3557f3d..d544cb2 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/ai/YigChikGeminiAPI.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/data/remote/YigChikGeminiAPI.kt
@@ -1,9 +1,9 @@
-package com.kharagedition.tibetankeyboard.ai
+package com.kharagedition.tibetankeyboard.data.remote
-import com.kharagedition.tibetankeyboard.model.GeminiChatRequest
-import com.kharagedition.tibetankeyboard.model.GeminiChatResponse
-import com.kharagedition.tibetankeyboard.model.GrammarCheckRequest
-import com.kharagedition.tibetankeyboard.model.GrammarCheckResponse
+import com.kharagedition.tibetankeyboard.data.model.GeminiChatRequest
+import com.kharagedition.tibetankeyboard.data.model.GeminiChatResponse
+import com.kharagedition.tibetankeyboard.data.model.GrammarCheckRequest
+import com.kharagedition.tibetankeyboard.data.model.GrammarCheckResponse
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.Headers
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/data/remote/YigChikTranslateAPI.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/data/remote/YigChikTranslateAPI.kt
new file mode 100644
index 0000000..4615969
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/data/remote/YigChikTranslateAPI.kt
@@ -0,0 +1,17 @@
+package com.kharagedition.tibetankeyboard.data.remote
+
+import com.kharagedition.tibetankeyboard.data.model.TranslationRequest
+import com.kharagedition.tibetankeyboard.data.model.TranslationResponse
+import retrofit2.http.Body
+import retrofit2.http.Header
+import retrofit2.http.Headers
+import retrofit2.http.POST
+
+interface YigChikTranslateAPI {
+ @Headers("Content-Type: application/json", "Accept: application/json")
+ @POST("translate")
+ suspend fun translateText(
+ @Body request: TranslationRequest,
+ @Header("userid") userId: String,
+ ): TranslationResponse
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ai/AIService.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/AIService.kt
similarity index 82%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/ai/AIService.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/AIService.kt
index e5e630a..0102732 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/ai/AIService.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/AIService.kt
@@ -1,10 +1,13 @@
-package com.kharagedition.tibetankeyboard.ai
-
-import com.kharagedition.tibetankeyboard.model.GrammarCheckRequest
-import com.kharagedition.tibetankeyboard.model.GrammarResult
-import com.kharagedition.tibetankeyboard.model.RephraseResult
-import com.kharagedition.tibetankeyboard.model.TranslationRequest
-import com.kharagedition.tibetankeyboard.model.TranslationResult
+package com.kharagedition.tibetankeyboard.data.repository
+
+import com.kharagedition.tibetankeyboard.data.model.GrammarCheckRequest
+import com.kharagedition.tibetankeyboard.data.model.GrammarResult
+import com.kharagedition.tibetankeyboard.data.model.RephraseResult
+import com.kharagedition.tibetankeyboard.data.model.TranslationRequest
+import com.kharagedition.tibetankeyboard.data.model.TranslationResult
+import com.kharagedition.tibetankeyboard.data.remote.RetrofitClient
+import com.kharagedition.tibetankeyboard.util.ApiErrors
+import com.google.firebase.auth.FirebaseAuth
import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -45,19 +48,31 @@ class AIService {
}
}
- suspend fun translateText(text: String, sourceLang: String, targetLang: String): TranslationResult {
+ suspend fun translateText(
+ text: String,
+ sourceLang: String,
+ targetLang: String,
+ model: String? = null
+ ): TranslationResult {
return withContext(Dispatchers.IO) {
try {
if (text.isEmpty()) {
return@withContext TranslationResult("", sourceLang, targetLang,)
}
+ // Backend identifies the user (for pro check + free-tier limits)
+ // from this header. Must be the Firebase UID — the same id used
+ // as the RevenueCat appUserID.
+ val userId = FirebaseAuth.getInstance().currentUser?.uid ?: "anonymous"
+
val response = RetrofitClient.translateAPI.translateText(
TranslationRequest(
text = text,
sourceLang = sourceLang,
- targetLang = targetLang
- )
+ targetLang = targetLang,
+ model = model
+ ),
+ userId = userId,
)
TranslationResult(
@@ -67,10 +82,12 @@ class AIService {
)
} catch (e: Exception) {
- println("Translation error: ${e.message}")
- e.printStackTrace()
+ // Map to a clean, user-facing message (no raw "HTTP 429" / host
+ // errors). On a 429 this surfaces the backend's daily-limit copy.
+ val apiError = ApiErrors.from(e)
+ println("Translation error: ${apiError.type} - ${e.message}")
TranslationResult(
- error = e.message,
+ error = apiError.message,
translatedText = text,
sourceLanguage = getLanguageName(sourceLang),
targetLanguage = getLanguageName(targetLang),
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/ChatRepository.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/ChatRepository.kt
new file mode 100644
index 0000000..ec88612
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/ChatRepository.kt
@@ -0,0 +1,76 @@
+package com.kharagedition.tibetankeyboard.data.repository
+
+import com.kharagedition.tibetankeyboard.data.remote.RetrofitClient
+import com.kharagedition.tibetankeyboard.data.model.GeminiChatRequest
+import com.kharagedition.tibetankeyboard.util.ApiErrorType
+import com.kharagedition.tibetankeyboard.util.ApiErrors
+
+class ChatRepository() {
+ private var currentSessionId: String? = null
+
+ suspend fun sendMessage(message: String, userId: String, model: String? = null): String {
+ return try {
+ val request = GeminiChatRequest(
+ message = message,
+ sessionId = currentSessionId,
+ resetChat = false,
+ model = model
+ )
+
+ val response = RetrofitClient.geminiAPI.chatWithGemini(request,userId)
+
+ if (response.success && response.data != null) {
+ // Update session ID for conversation continuity
+ currentSessionId = response.data.sessionId
+ response.data.response
+ } else {
+ // 200 with success=false (rare) — prefer the backend's own copy.
+ response.message ?: response.error ?: tibetanError(ApiErrorType.UNKNOWN)
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ // Distinguish no-internet / timeout / daily-limit / server errors so
+ // the user sees a meaningful Tibetan message, not one generic line.
+ tibetanError(ApiErrors.from(e).type)
+ }
+ }
+
+ /**
+ * User-facing Tibetan copy per failure category. Edit freely to refine the
+ * wording — these are the messages shown in the chat bubble on a failure.
+ */
+ private fun tibetanError(type: ApiErrorType): String = when (type) {
+ ApiErrorType.NO_INTERNET ->
+ "དགོངས་དག། དྲ་རྒྱ་དང་འབྲེལ་མ་ཐུབ། ཁྱེད་ཀྱི་དྲ་ཐག་ལ་བལྟ་ཞིབ་བྱས་ནས་ཡང་བསྐྱར་འབད་བརྩོན་གནང་རོགས།"
+ ApiErrorType.TIMEOUT ->
+ "དགོངས་དག། ལན་འདེབས་ལ་དུས་ཡུན་འགོར་དྲགས་སོང་། ཡང་བསྐྱར་འབད་བརྩོན་གནང་རོགས།"
+ ApiErrorType.RATE_LIMITED ->
+ "དེ་རིང་གི་རིན་མེད་སྤྱོད་ཚད་ཟིན་སོང་། ཚད་མེད་སྤྱོད་པར་ Pro ལ་གཞི་སྤོ་གནང་རོགས།"
+ ApiErrorType.SERVER ->
+ "དགོངས་དག། ཞབས་ཞུ་ལ་གནད་དོན་འཕྲད་སོང་། སྐར་མ་འགའ་རྗེས་ཡང་བསྐྱར་འབད་བརྩོན་གནང་རོགས།"
+ ApiErrorType.BAD_REQUEST, ApiErrorType.UNKNOWN ->
+ "དགོངས་དག། དཀའ་ངལ་ཞིག་འཕྲད་སོང་། ཡང་བསྐྱར་འབད་བརྩོན་གནང་རོགས།"
+ }
+
+ suspend fun resetChat(): Boolean {
+ return try {
+ currentSessionId?.let { sessionId ->
+ val request = mapOf("sessionId" to sessionId)
+ val response = RetrofitClient.geminiAPI.resetChatSession(request)
+ if (response.success) {
+ currentSessionId = null
+ true
+ } else {
+ false
+ }
+ } ?: true // If no session, consider it reset
+ } catch (e: Exception) {
+ e.printStackTrace()
+ false
+ }
+ }
+
+ fun clearSession() {
+ currentSessionId = null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ai/PremiumFeatureManager.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/PremiumFeatureManager.kt
similarity index 95%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/ai/PremiumFeatureManager.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/PremiumFeatureManager.kt
index 500e5d2..97c59d5 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/ai/PremiumFeatureManager.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/PremiumFeatureManager.kt
@@ -1,8 +1,8 @@
-package com.kharagedition.tibetankeyboard.ai
+package com.kharagedition.tibetankeyboard.data.repository
import android.app.Activity
import android.content.Context
-import com.kharagedition.tibetankeyboard.subscription.RevenueCatManager
+import com.kharagedition.tibetankeyboard.data.repository.RevenueCatManager
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -103,7 +103,7 @@ class PremiumFeatureManager(private val context: Context) {
.setMessage(message)
.setIcon(android.R.drawable.ic_dialog_info)
.setPositiveButton("Upgrade") { _, _ ->
- revenueCatManager.purchasePremium(activity, object : com.kharagedition.tibetankeyboard.subscription.RevenueCatManager.SubscriptionCallback {
+ revenueCatManager.purchasePremium(activity, object : com.kharagedition.tibetankeyboard.data.repository.RevenueCatManager.SubscriptionCallback {
override fun onSuccess(message: String) {
onPurchase?.invoke()
}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/RevenueCatCallbacks.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/RevenueCatCallbacks.kt
new file mode 100644
index 0000000..03a8555
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/RevenueCatCallbacks.kt
@@ -0,0 +1,17 @@
+package com.kharagedition.tibetankeyboard.data.repository
+
+/**
+ * Builds a [RevenueCatManager.SubscriptionCallback] from optional lambdas, so call sites
+ * don't repeat the three-method anonymous object every time.
+ *
+ * Usage: `RevenueCatManager.getInstance().syncPurchases(subscriptionCallback(onError = { ... }))`
+ */
+inline fun subscriptionCallback(
+ crossinline onSuccess: (String) -> Unit = {},
+ crossinline onError: (String) -> Unit = {},
+ crossinline onCancelled: () -> Unit = {},
+): RevenueCatManager.SubscriptionCallback = object : RevenueCatManager.SubscriptionCallback {
+ override fun onSuccess(message: String) = onSuccess(message)
+ override fun onError(error: String) = onError(error)
+ override fun onUserCancelled() = onCancelled()
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/subscription/RevenueCatManager.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/RevenueCatManager.kt
similarity index 79%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/subscription/RevenueCatManager.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/RevenueCatManager.kt
index 4d6de5c..96b2709 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/subscription/RevenueCatManager.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/RevenueCatManager.kt
@@ -1,4 +1,4 @@
-package com.kharagedition.tibetankeyboard.subscription
+package com.kharagedition.tibetankeyboard.data.repository
import android.app.Activity
import android.content.Context
@@ -6,7 +6,6 @@ import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.firebase.auth.FirebaseAuth
-import com.google.firebase.firestore.FirebaseFirestore
import com.kharagedition.tibetankeyboard.BuildConfig
import com.revenuecat.purchases.*
import com.revenuecat.purchases.interfaces.*
@@ -109,13 +108,29 @@ class RevenueCatManager private constructor() {
callback?.onError(errorMsg)
}
} else {
- // Already configured, just refresh customer info
- Log.d(TAG, "RevenueCat already configured, refreshing customer info")
-
- // CRITICAL: Sync purchases to ensure Google Play acknowledgment
- syncPurchasesWithGooglePlay()
+ // Already configured this process. After a logout we call Purchases.logOut(), which
+ // switches RevenueCat to a fresh ANONYMOUS user — so on re-login we must explicitly
+ // re-identify the Firebase user with logIn() BEFORE reading entitlements. Without this,
+ // a logout→login in the same session reads the anonymous user's (empty) entitlements and
+ // a paying user is wrongly shown as not subscribed. logIn() also handles account
+ // switching (user A → user B) and is a cheap no-op when the user is already current.
+ Log.d(TAG, "RevenueCat already configured; identifying user before refresh: $userId")
+ Purchases.sharedInstance.logIn(userId, object : LogInCallback {
+ override fun onReceived(customerInfo: CustomerInfo, created: Boolean) {
+ Log.d(TAG, "RevenueCat logIn success (created=$created) for $userId")
+ // Sync purchases to ensure Google Play acknowledgment, then refresh entitlements.
+ syncPurchasesWithGooglePlay()
+ fetchCustomerInfoAndOfferings(callback)
+ }
- fetchCustomerInfoAndOfferings(callback)
+ override fun onError(error: PurchasesError) {
+ _isLoading.value = false
+ val errorMsg = "RevenueCat logIn failed: ${error.message}"
+ Log.e(TAG, errorMsg)
+ _error.value = errorMsg
+ callback?.onError(errorMsg)
+ }
+ })
}
}
@@ -299,6 +314,11 @@ class RevenueCatManager private constructor() {
object : PurchaseCallback {
override fun onCompleted(storeTransaction: StoreTransaction, customerInfo: CustomerInfo) {
_isLoading.value = false
+ Log.i(
+ TAG,
+ "subs-flow: purchase completed — appUserId=${currentAppUserId(customerInfo)} " +
+ "order=${storeTransaction.orderId} products=${storeTransaction.productIds.joinToString()}"
+ )
Log.d(TAG, "==== Purchase Successful ====")
Log.d(TAG, "Transaction ID: ${storeTransaction.orderId}")
Log.d(TAG, "Product IDs: ${storeTransaction.productIds.joinToString()}")
@@ -360,83 +380,53 @@ class RevenueCatManager private constructor() {
}
/**
- * Update premium status based on customer info
+ * Update premium status based on customer info.
+ *
+ * This ONLY updates the in-app LiveData that drives the UI (locks/PRO tags),
+ * which reflects instantly on purchase for good UX. It does NOT write to
+ * Firestore: the backend is the single, authoritative writer of server-side
+ * pro state (`users/{uid}.isPro`), kept in sync by the RevenueCat webhook +
+ * a live REST fallback. Keeping the client out of Firestore avoids competing
+ * writers / field sprawl and means pro gating can't be spoofed from the app.
*/
private fun updatePremiumStatus(customerInfo: CustomerInfo) {
val proEntitlement = customerInfo.entitlements[PREMIUM_ENTITLEMENT_ID]
val isPremium = proEntitlement?.isActive == true
- // CRITICAL FIX: Do not use debug mode bypass in production
_isPremiumUser.value = isPremium
- // Get subscription expiry date from entitlement
- val expiryDate = proEntitlement?.expirationDate
- val willRenew = proEntitlement?.willRenew ?: false
- val periodType = proEntitlement?.periodType?.toString() ?: "unknown"
+ // Single greppable confirmation of the client's entitlement decision — mirrors the
+ // backend's `pro-status:` lines. Filter with: adb logcat | grep subs-flow
+ // IMPORTANT: log the CURRENT app user id (== Firebase UID == the backend's
+ // app_user_id), NOT customerInfo.originalAppUserId — the latter returns the
+ // historical/original alias (often "$RCAnonymousID:...") and is misleading when
+ // checking that client/server identities line up.
+ Log.i(
+ TAG,
+ "subs-flow: entitlement resolved — appUserId=${currentAppUserId(customerInfo)} " +
+ "premium=$isPremium expires=${proEntitlement?.expirationDate} " +
+ "willRenew=${proEntitlement?.willRenew} " +
+ "activeSubs=${customerInfo.activeSubscriptions.joinToString()}"
+ )
- // Enhanced logging for debugging
Log.d(TAG, "==== Premium Status Update ====")
Log.d(TAG, "App User ID: ${customerInfo.originalAppUserId}")
Log.d(TAG, "Premium Status: $isPremium")
- Log.d(TAG, "Expiry Date: $expiryDate")
- Log.d(TAG, "Will Renew: $willRenew")
- Log.d(TAG, "Period Type: $periodType")
+ Log.d(TAG, "Expiry Date: ${proEntitlement?.expirationDate}")
+ Log.d(TAG, "Will Renew: ${proEntitlement?.willRenew}")
Log.d(TAG, "Active Subscriptions: ${customerInfo.activeSubscriptions.joinToString()}")
- Log.d(TAG, "All Entitlements: ${customerInfo.entitlements.all.keys.joinToString()}")
- Log.d(TAG, "Pro Entitlement Active: ${proEntitlement?.isActive}")
- Log.d(TAG, "Request Date: ${customerInfo.requestDate}")
Log.d(TAG, "============================")
-
- val userId = FirebaseAuth.getInstance().currentUser?.uid
- val db = FirebaseFirestore.getInstance()
- if(userId==null) {
- Log.w(TAG, "Cannot update Firestore: User ID is null")
- return
- }
-
- if (isPremium) {
- // CRITICAL FIX: Use actual expiration date from entitlement, not request date
- val premiumDetails = hashMapOf(
- "isPremium" to true,
- "subscribed" to true,
- "isSubscribed" to true,
- "subscriptionType" to "premium",
- "activeSubscriptions" to customerInfo.activeSubscriptions.toList(),
- "premiumExpiryDate" to (expiryDate ?: customerInfo.requestDate), // Use actual expiry or fallback to request date
- "willRenew" to willRenew,
- "periodType" to periodType,
- "revenueCatUserId" to customerInfo.originalAppUserId,
- "lastUpdated" to customerInfo.requestDate,
- "originalPurchaseDate" to proEntitlement.originalPurchaseDate
- )
- val userRef = db.collection("users").document(userId)
-
- userRef.update(premiumDetails)
- .addOnSuccessListener {
- Log.d(TAG, "Firestore: User premium details updated successfully for user: $userId")
- Log.d(TAG, "Firestore: Subscription expires at: $expiryDate")
- }
- .addOnFailureListener { e ->
- Log.e(TAG, "Firestore: Failed to update premium details - ${e.message}", e)
- }
- } else {
- val userRef = db.collection("users").document(userId)
-
- userRef.update(mapOf(
- "isPremium" to false,
- "subscribed" to false,
- "isSubscribed" to false,
- "lastUpdated" to customerInfo.requestDate
- ))
- .addOnSuccessListener {
- Log.d(TAG, "Firestore: User premium status set to false for user: $userId")
- }
- .addOnFailureListener { e ->
- Log.e(TAG, "Firestore: Failed to set premium status - ${e.message}", e)
- }
- }
}
+ /**
+ * The CURRENT identified app user id (Firebase UID after logIn/configure) — this is what
+ * RevenueCat sends as `app_user_id` to the webhook and what the backend keys pro state on.
+ * Falls back to the customer's original id only if the SDK isn't configured yet. Use this
+ * for identity logging, never [CustomerInfo.originalAppUserId] (the historical alias).
+ */
+ private fun currentAppUserId(customerInfo: CustomerInfo): String =
+ runCatching { Purchases.sharedInstance.appUserID }.getOrDefault(customerInfo.originalAppUserId)
+
/**
* Get premium package details for UI display
*/
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/repo/UserRepository.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/UserRepository.kt
similarity index 96%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/repo/UserRepository.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/UserRepository.kt
index 33ae8ca..7f137d6 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/repo/UserRepository.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/UserRepository.kt
@@ -1,4 +1,4 @@
-package com.kharagedition.tibetankeyboard.repo
+package com.kharagedition.tibetankeyboard.data.repository
// UserRepository.kt - Repository for user data operations
import com.google.firebase.firestore.FirebaseFirestore
@@ -8,9 +8,9 @@ import android.content.Context
import android.os.Build
import android.provider.Settings
import com.google.firebase.messaging.FirebaseMessaging
-import com.kharagedition.tibetankeyboard.util.DeviceInfo
-import com.kharagedition.tibetankeyboard.util.SubscriptionType
-import com.kharagedition.tibetankeyboard.util.User
+import com.kharagedition.tibetankeyboard.data.model.DeviceInfo
+import com.kharagedition.tibetankeyboard.data.model.SubscriptionType
+import com.kharagedition.tibetankeyboard.data.model.User
class UserRepository {
private val db = FirebaseFirestore.getInstance()
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/MyFirebaseMessagingService.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/service/MyFirebaseMessagingService.kt
similarity index 82%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/MyFirebaseMessagingService.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/service/MyFirebaseMessagingService.kt
index e8abc26..9d28caa 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/MyFirebaseMessagingService.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/service/MyFirebaseMessagingService.kt
@@ -1,18 +1,19 @@
-package com.kharagedition.tibetankeyboard
+package com.kharagedition.tibetankeyboard.service
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
-import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.graphics.Color
-import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
+import com.kharagedition.tibetankeyboard.R
+import com.kharagedition.tibetankeyboard.analytics.AppAnalytics
+import com.kharagedition.tibetankeyboard.ui.home.HomeActivity
class MyFirebaseMessagingService : FirebaseMessagingService() {
@@ -20,6 +21,12 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
private const val TAG = "FCMService"
private const val CHANNEL_ID = "app_update_channel"
private const val NOTIFICATION_ID = 1001
+
+ /** Set on the tap intent so the launched screen can report a [AppAnalytics.logNotificationOpened]. */
+ const val EXTRA_FROM_NOTIFICATION = "from_notification"
+
+ /** Set on the tap intent so [HomeActivity] immediately triggers an in-app update check. */
+ const val EXTRA_FORCE_UPDATE = "force_update"
}
override fun onMessageReceived(remoteMessage: RemoteMessage) {
@@ -27,6 +34,11 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
Log.d(TAG, "Message received from: ${remoteMessage.from}")
+ // Report delivery once per message (regardless of fore/background handling below).
+ val type = remoteMessage.data["type"]
+ ?: if (remoteMessage.notification != null) "system" else "unknown"
+ AppAnalytics.logNotificationReceived(type)
+
// Handle both notification and data payloads
// This ensures the notification works in all app states
@@ -87,47 +99,40 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
title: String,
message: String,
version: String,
- customUrl: String? = null
+ customUrl: String? = null,
) {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
-
createNotificationChannel(notificationManager)
- // Create intent to open Play Store or custom URL
+ // Tap opens HomeActivity → in-app update dialog triggers immediately.
+ // customUrl is kept for override cases (e.g. beta / side-load links).
val intent = if (customUrl != null) {
createCustomUrlIntent(customUrl)
} else {
- createPlayStoreIntent()
+ Intent(this, HomeActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ putExtra(EXTRA_FROM_NOTIFICATION, true)
+ putExtra(EXTRA_FORCE_UPDATE, true)
+ }
}
val pendingIntent = PendingIntent.getActivity(
- this,
- 0,
- intent,
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ this, 0, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
- // Build notification with full details
- val fullMessage = if (version.isNotEmpty()) {
- "$message\nVersion: $version"
- } else {
- message
- }
+ val body = if (version.isNotEmpty()) "$message (v$version)" else message
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_update)
.setContentTitle(title)
- .setContentText(message)
- .setStyle(NotificationCompat.BigTextStyle().bigText(fullMessage))
+ .setContentText(body)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.setContentIntent(pendingIntent)
- .addAction(
- R.drawable.ic_download,
- "Update Now",
- pendingIntent
- )
+ .addAction(R.drawable.ic_download, "Update Now", pendingIntent)
.build()
notificationManager.notify(NOTIFICATION_ID, notification)
@@ -141,6 +146,7 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
// Create intent to open the app
val intent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ putExtra(EXTRA_FROM_NOTIFICATION, true)
}
val pendingIntent = PendingIntent.getActivity(
@@ -181,22 +187,8 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
}
}
- private fun createPlayStoreIntent(): Intent {
- val packageName = packageName
-
- return try {
- Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName")).apply {
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- }
- } catch (e: ActivityNotFoundException) {
- Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$packageName")).apply {
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- }
- }
- }
-
private fun createCustomUrlIntent(url: String): Intent {
- return Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
+ return Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/TibetanKeyboard.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/service/TibetanKeyboard.kt
similarity index 63%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/TibetanKeyboard.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/service/TibetanKeyboard.kt
index 0412d6e..d13a0bc 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/TibetanKeyboard.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/service/TibetanKeyboard.kt
@@ -1,7 +1,7 @@
-package com.kharagedition.tibetankeyboard
+package com.kharagedition.tibetankeyboard.service
-import android.content.ComponentName
import android.content.Context
+import android.content.Intent
import android.content.SharedPreferences
import android.graphics.Color
import android.graphics.drawable.Drawable
@@ -22,11 +22,28 @@ import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.ExtractedText
import android.view.inputmethod.ExtractedTextRequest
+import android.view.inputmethod.InputConnection
import android.widget.FrameLayout
import androidx.preference.PreferenceManager
-import com.kharagedition.tibetankeyboard.ui.KeyboardType
+import com.google.firebase.auth.FirebaseAuth
+import com.kharagedition.tibetankeyboard.analytics.AppAnalytics
+import com.kharagedition.tibetankeyboard.analytics.UserActivityTracker
+import com.kharagedition.tibetankeyboard.data.repository.RevenueCatManager
+import com.kharagedition.tibetankeyboard.data.repository.subscriptionCallback
+import com.kharagedition.tibetankeyboard.ui.keyboard.KeyboardType
import com.kharagedition.tibetankeyboard.util.AppConstant
-import com.kharagedition.tibetankeyboard.ai.AIKeyboardInterface
+import com.kharagedition.tibetankeyboard.util.openPremiumUpgrade
+import com.kharagedition.tibetankeyboard.ui.chat.ChatActivity
+import com.kharagedition.tibetankeyboard.ui.keyboard.AIKeyboardInterface
+import com.kharagedition.tibetankeyboard.ui.keyboard.KeyboardLayoutHint
+import com.kharagedition.tibetankeyboard.ui.keyboard.TibetanKeyboardView
+import com.kharagedition.tibetankeyboard.ui.keyboard.AIKeyboardView
+import com.kharagedition.tibetankeyboard.ui.keyboard.EmojiKeyboardView
+import com.kharagedition.tibetankeyboard.ui.keyboard.AIKeyboardCodes
+import com.kharagedition.tibetankeyboard.ui.keyboard.EmojiItemList
+import com.kharagedition.tibetankeyboard.ui.keyboard.StickerItem
+import com.kharagedition.tibetankeyboard.R
+import com.kharagedition.tibetankeyboard.FontsOverride
class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboardInterface {
@@ -40,13 +57,76 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
private var emojiKeyboardView: EmojiKeyboardView? = null
private var isEmojiMode = false
+ // Per-field layout override requested by the focused field via
+ // EditorInfo.privateImeOptions (e.g. the AI Translate source field forces
+ // QWERTY for an English source). true → Tibetan, false → QWERTY, null →
+ // honour the user's own saved language choice. Set on every onStartInputView.
+ private var forcedTibetan: Boolean? = null
+
+ // Tracks how many Unicode code points the user has typed since the last word boundary.
+ // Used to know exactly what to delete when a suggestion is selected.
+ // Resets on: shad (།), space, newline, or suggestion commit.
+ // Tshek (་) is NOT a boundary — it is part of the Tibetan word.
+ private var currentWordLength = 0
+
enum class KeyboardMode {
NORMAL,
AI_GRAMMAR,
AI_REPHRASE
}
+ companion object {
+ /** Shared logcat tag for the client subscription flow (`adb logcat -s SubsFlow:*`). */
+ private const val SUBS_TAG = "SubsFlow"
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ // The IME can start in a FRESH process with no app Activity having run first
+ // (the user just types in another app), and Application.onCreate does NOT
+ // configure RevenueCat. Without this, RevenueCatManager.isPremiumUser stays
+ // false inside the keyboard, so a PAYING user sees Chat/Translate/Autocomplete
+ // locked and gets routed to the paywall. Firebase Auth restores currentUser
+ // synchronously in any process, and initialize() is idempotent (a cheap logIn
+ // when already configured), so this is safe on every IME create.
+ initializeRevenueCatForKeyboard()
+ }
+
+ /**
+ * Configure RevenueCat for this process so the keyboard knows the user's PRO status.
+ * No-ops for signed-out users (the keyboard simply runs free-tier). All lines are
+ * prefixed `subs-flow:` so the whole subscription path is greppable in logcat:
+ * adb logcat | grep subs-flow
+ */
+ private fun initializeRevenueCatForKeyboard() {
+ val auth = FirebaseAuth.getInstance()
+ val user = auth.currentUser
+ if (user == null) {
+ Log.d(SUBS_TAG, "subs-flow: IME onCreate — no Firebase user; keyboard runs free-tier")
+ return
+ }
+ Log.i(SUBS_TAG, "subs-flow: IME onCreate — initializing RevenueCat for uid=${user.uid}")
+ RevenueCatManager.getInstance().initialize(applicationContext, auth, subscriptionCallback(
+ onSuccess = {
+ Log.i(
+ SUBS_TAG,
+ "subs-flow: IME RevenueCat ready — premium=${RevenueCatManager.getInstance().isPremiumUserCached()}"
+ )
+ },
+ onError = { Log.w(SUBS_TAG, "subs-flow: IME RevenueCat init failed: $it") },
+ ))
+ }
+
override fun onStartInputView(info: EditorInfo?, restarting: Boolean) {
+ currentWordLength = 0
+ // Read the focused field's requested layout before (re)building the view,
+ // so onCreateInputView can open on the matching language.
+ forcedTibetan = KeyboardLayoutHint.forcedTibetan(info?.privateImeOptions)
+ if (!restarting) {
+ AppAnalytics.logKeyboardShown()
+ // Count keyboard usage toward the user's daily activity (throttled to one write/day).
+ UserActivityTracker.recordActive(this, UserActivityTracker.Source.KEYBOARD)
+ }
setInputView(onCreateInputView())
super.onStartInputView(info, restarting)
}
@@ -68,6 +148,10 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
isLanguageTibetan = getSharedPreferences("com.kharagedition.tibetankeyboard", MODE_PRIVATE).getBoolean(
AppConstant.IS_TIB,true)
+ // A focused field can override the saved choice for itself only (e.g. the
+ // Translate source field). Doesn't touch the persisted preference, so the
+ // user's language in every other app is unaffected.
+ forcedTibetan?.let { isLanguageTibetan = it }
keyboard = if(isLanguageTibetan){
Keyboard(this, R.xml.tibetan_uchen_alphabet_1)
}else{
@@ -91,17 +175,22 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
}
private fun setKeyBoardLanguage() {
- val isUme = prefs.getBoolean("isUme", false)
- if(isUme){
- FontsOverride.setDefaultFont(this, "DEFAULT", "fonts/qomolangma-tsutong.ttf");
- }else{
- FontsOverride.setDefaultFont(this, "DEFAULT", null);
- }
+ // Uchen only — never the cursive Ume/Tsutong face. Use the system Tibetan
+ // font (renders Uchen) so Latin UI text stays clean too.
+ FontsOverride.setDefaultFont(this, "DEFAULT", null)
}
private fun setKeyBoardView() {
val color = prefs.getString("colors", "#FF704C04")
- val keyboardStyle = prefs.getString("keyboard_style", "classic")
+ val keyboardStyle = prefs.getString("keyboard_style", "borderless")
+
+ if (keyboardStyle == "borderless") {
+ // Borderless is colour-agnostic: one transparent-key layout, surface tint
+ // applied below. Glyphs only, no key boxes.
+ keyboardView = layoutInflater.inflate(R.layout.keyboard_borderless, null) as TibetanKeyboardView
+ keyboardView?.setBackgroundColor(darkenColor(Color.parseColor(color ?: "#FF704C04"), 0.42f))
+ return
+ }
keyboardView = when (color) {
"#FF704C04" -> {
@@ -126,7 +215,15 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
}
}
}
- keyboardView?.setBackgroundColor(Color.parseColor(color))
+ // Match the dark espresso surface used by the AI toolbar (see AIKeyboardView.applyTheme).
+ keyboardView?.setBackgroundColor(darkenColor(Color.parseColor(color), 0.42f))
+ }
+
+ private fun darkenColor(color: Int, factor: Float): Int {
+ val r = (Color.red(color) * factor).toInt().coerceIn(0, 255)
+ val g = (Color.green(color) * factor).toInt().coerceIn(0, 255)
+ val b = (Color.blue(color) * factor).toInt().coerceIn(0, 255)
+ return Color.rgb(r, g, b)
}
override fun onPress(i: Int) {}
@@ -145,7 +242,11 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
playClick(i)
when (i) {
- Keyboard.KEYCODE_DELETE -> inputConnection.deleteSurroundingText(1, 0)
+ Keyboard.KEYCODE_DELETE -> {
+ inputConnection.deleteSurroundingText(1, 0)
+ if (currentWordLength > 0) currentWordLength--
+ aiKeyboardView?.updateSuggestions(currentPrefix(inputConnection))
+ }
Keyboard.KEYCODE_SHIFT -> {
isCaps = !isCaps
keyboard!!.isShifted = isCaps
@@ -173,6 +274,7 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
"com.kharagedition.dictionary", Context.MODE_PRIVATE).edit()
prefs.putBoolean(AppConstant.IS_TIB,true)
prefs.apply()
+ AppAnalytics.logKeyboardLanguageSwitched(AppAnalytics.KeyboardLanguage.TIBETAN)
keyboardView?.keyboard = Keyboard(this, R.xml.tibetan_uchen_alphabet_1)
}
@@ -187,6 +289,7 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
"com.kharagedition.dictionary", Context.MODE_PRIVATE).edit()
prefs.putBoolean(AppConstant.IS_TIB,false)
prefs.apply()
+ AppAnalytics.logKeyboardLanguageSwitched(AppAnalytics.KeyboardLanguage.ENGLISH)
keyboardView?.keyboard = Keyboard(this, R.xml.qwerty)
}
KeyboardType.TIBETAN -> {
@@ -198,14 +301,6 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
KeyboardType.SYMBOL_EN -> {
keyboardView?.keyboard = Keyboard(this, R.xml.symbol_en)
}
- KeyboardType.GEMINI -> {
- var chatactivity = "com.kharagedition.tibetankeyboard.ui.ChatActivity"
- var intent = packageManager.getLaunchIntentForPackage("com.kharagedition.tibetankeyboard")
- if (intent != null) {
- intent.component = ComponentName("com.kharagedition.tibetankeyboard", chatactivity)
- startActivity(intent)
- }
- }
KeyboardType.EMOJI_KEYBOARD -> {
toggleEmojiKeyboard()
}
@@ -213,7 +308,15 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
else -> {
var code = i.toChar()
if (Character.isLetter(code) && isCaps) code = Character.toUpperCase(code)
+ // Shad (།) and space are sentence/word boundaries — reset the word tracker.
+ // Tshek (་) is a syllable separator WITHIN a word, so it increments the counter.
+ if (code == '།' || code == '༎' || code == ' ' || code == '\n') {
+ currentWordLength = 0
+ } else {
+ currentWordLength++
+ }
inputConnection.commitText(code.toString(), 1)
+ aiKeyboardView?.updateSuggestions(currentPrefix(inputConnection))
}
}
}
@@ -227,6 +330,7 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
}
private fun showEmojiKeyboard() {
+ AppAnalytics.logKeyboardEmojiOpened()
if (emojiKeyboardView == null) {
emojiKeyboardView = EmojiKeyboardView(this)
val themeColor = prefs.getString("colors", "#FF704C04") ?: "#FF704C04"
@@ -349,6 +453,7 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
}
override fun onGrammarReplace(originalText: String, correctedText: String) {
+ AppAnalytics.logKeyboardAiApplied(AppAnalytics.KeyboardFeature.GRAMMAR)
val inputConnection = currentInputConnection
if (inputConnection != null) {
// Clear current text and insert corrected text
@@ -361,6 +466,7 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
}
override fun onTranslateReplace(originalText: String, translatedText: String) {
+ AppAnalytics.logKeyboardAiApplied(AppAnalytics.KeyboardFeature.TRANSLATE)
val inputConnection = currentInputConnection
if (inputConnection != null) {
// Clear current text and insert rephrased text
@@ -373,6 +479,7 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
}
override fun onRephraseReplace(originalText: String, rephrasedText: String) {
+ AppAnalytics.logKeyboardAiApplied(AppAnalytics.KeyboardFeature.REPHRASE)
val inputConnection = currentInputConnection
if (inputConnection != null) {
// Clear current text and insert rephrased text
@@ -389,6 +496,37 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
aiKeyboardView?.showNormalKeyboard()
}
+ override fun onSuggestionSelected(word: String) {
+ val ic = currentInputConnection ?: return
+ AppAnalytics.logKeyboardSuggestionSelected()
+ Log.d("TibetanKeyboard", "onSuggestionSelected: '$word', deleting $currentWordLength chars")
+ if (currentWordLength > 0) ic.deleteSurroundingText(currentWordLength, 0)
+ ic.commitText(word, 1)
+ currentWordLength = 0
+ aiKeyboardView?.updateSuggestions("")
+ }
+
+ override fun onOpenChat() {
+ startActivity(
+ Intent(this, ChatActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ )
+ }
+
+ override fun onUnlockPro() {
+ // Single source of truth for the upgrade flow (login-then-paywall if needed).
+ openPremiumUpgrade(AppAnalytics.UpgradeSource.KEYBOARD)
+ }
+
+ // Returns the Unicode code points the user has typed since the last word boundary,
+ // verified against actual text before the cursor.
+ private fun currentPrefix(ic: InputConnection): String {
+ if (currentWordLength == 0) return ""
+ val textBefore = ic.getTextBeforeCursor(currentWordLength + 5, 0)?.toString() ?: ""
+ val prefix = textBefore.takeLast(currentWordLength)
+ Log.d("TibetanKeyboard", "prefix='$prefix' (wordLen=$currentWordLength)")
+ return prefix
+ }
+
private fun vibratePhone() {
val v = getSystemService(VIBRATOR_SERVICE) as Vibrator
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/subscription/SubscriptionUIComponent.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/subscription/SubscriptionUIComponent.kt
deleted file mode 100644
index 08c17ee..0000000
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/subscription/SubscriptionUIComponent.kt
+++ /dev/null
@@ -1,97 +0,0 @@
-package com.kharagedition.tibetankeyboard.subscription
-
-import android.view.View
-import androidx.appcompat.app.AppCompatActivity
-import androidx.lifecycle.LifecycleOwner
-import com.google.android.material.imageview.ShapeableImageView
-import com.kharagedition.tibetankeyboard.util.showLongToast
-import com.kharagedition.tibetankeyboard.util.showToast
-
-/**
- * UI component to handle subscription-related UI operations
- * This can be used in any activity/fragment that needs subscription functionality
- */
-interface IsPremiumListener {
- fun onPremiumStatusChanged(isPremium: Boolean)
-}
-class SubscriptionUIComponent(
- private val activity: AppCompatActivity,
- private val buyPremiumButton: ShapeableImageView,
- private val isPremiumListener: IsPremiumListener
-) : RevenueCatManager.SubscriptionCallback {
-
- private val revenueCatManager = RevenueCatManager.getInstance()
-
- init {
- setupUI()
- observeSubscriptionStatus()
- }
-
- private fun setupUI() {
- buyPremiumButton.setOnClickListener {
- purchasePremium()
- }
- }
-
- private fun observeSubscriptionStatus() {
- // Observe premium status changes
- revenueCatManager.isPremiumUser.observe(activity as LifecycleOwner) { isPremium ->
- isPremiumListener.onPremiumStatusChanged(isPremium)
- }
-
- // Observe loading state
- revenueCatManager.isLoading.observe(activity as LifecycleOwner) { isLoading ->
- buyPremiumButton.isEnabled = !isLoading
- }
-
- // Observe errors
- revenueCatManager.error.observe(activity as LifecycleOwner) { error ->
- error?.let {
- activity.showToast(it)
- revenueCatManager.clearError()
- }
- }
- }
-
-
- /**
- * Initiate premium purchase
- */
- fun purchasePremium() {
- revenueCatManager.purchasePremium(activity, this)
- }
-
- /**
- * Refresh subscription status
- */
- fun refreshSubscriptionStatus() {
- revenueCatManager.refreshCustomerInfo(this)
- }
-
- /**
- * Get premium package info for display
- */
- fun getPremiumPackageInfo(): Pair {
- return revenueCatManager.getPremiumPackageInfo()
- }
-
- /**
- * Check if user is premium (cached)
- */
- fun isPremiumUser(): Boolean {
- return revenueCatManager.isPremiumUserCached()
- }
-
- // RevenueCatManager.SubscriptionCallback implementations
- override fun onSuccess(message: String) {
- activity.showLongToast(message)
- }
-
- override fun onError(error: String) {
- activity.showToast(error)
- }
-
- override fun onUserCancelled() {
- activity.showToast("Purchase cancelled")
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/ChatActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/ChatActivity.kt
deleted file mode 100644
index 8a6b697..0000000
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/ChatActivity.kt
+++ /dev/null
@@ -1,317 +0,0 @@
-package com.kharagedition.tibetankeyboard.ui
-
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import android.view.inputmethod.EditorInfo
-import android.widget.ImageButton
-import android.widget.LinearLayout
-import android.widget.TextView
-import androidx.activity.enableEdgeToEdge
-import androidx.activity.viewModels
-import androidx.appcompat.app.AlertDialog
-import androidx.appcompat.app.AppCompatActivity
-import androidx.constraintlayout.widget.ConstraintLayout
-import androidx.core.view.ViewCompat
-import androidx.core.widget.addTextChangedListener
-import androidx.recyclerview.widget.LinearLayoutManager
-import androidx.recyclerview.widget.RecyclerView
-import com.airbnb.lottie.LottieAnimationView
-import com.google.android.material.appbar.MaterialToolbar
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.google.android.material.floatingactionbutton.FloatingActionButton
-import com.google.android.material.imageview.ShapeableImageView
-import com.google.android.material.textfield.TextInputEditText
-import com.kharagedition.tibetankeyboard.R
-import com.kharagedition.tibetankeyboard.auth.AuthManager
-import com.kharagedition.tibetankeyboard.chat.ChatAdapter
-import com.kharagedition.tibetankeyboard.chat.ChatViewModel
-import com.kharagedition.tibetankeyboard.subscription.IsPremiumListener
-import com.kharagedition.tibetankeyboard.subscription.RevenueCatManager
-import com.kharagedition.tibetankeyboard.subscription.SubscriptionUIComponent
-import com.kharagedition.tibetankeyboard.util.*
-
-class ChatActivity : AppCompatActivity(), RevenueCatManager.SubscriptionCallback,
- IsPremiumListener {
-
- // ViewModels and Managers
- private val viewModel: ChatViewModel by viewModels()
- private lateinit var authManager: AuthManager
- private lateinit var subscriptionUIComponent: SubscriptionUIComponent
-
- // UI Components
- private lateinit var chatAdapter: ChatAdapter
- private lateinit var recyclerView: RecyclerView
- private lateinit var layoutEmptyChat: LinearLayout
- private lateinit var editTextMessage: TextInputEditText
- private lateinit var buttonSend: FloatingActionButton
- private lateinit var animationViewLoading: LottieAnimationView
- private lateinit var materialToolbar: MaterialToolbar
- private lateinit var userProfileImage: ShapeableImageView
- private lateinit var userNameText: TextView
- private lateinit var mainLayout: ConstraintLayout
- private lateinit var buttonBack: ImageButton
- private lateinit var buyPremium: ShapeableImageView
- private var isPremium: Boolean = false
- private var premiumAlertDialog: AlertDialog? = null
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- // Initialize managers
- authManager = AuthManager(this)
-
- // Check authentication first
- if (!authManager.isUserAuthenticated()) {
- authManager.redirectToLogin()
- return
- }
-
- enableEdgeToEdge()
- setContentView(R.layout.activity_chat)
-
- initializeViews()
- setupUI()
- setupObservers()
- initializeServices()
-
- // Show welcome message for first-time users
- if (authManager.isFirstTimeUser()) {
- showWelcomeMessage()
- }
-
- // Add welcome message to chat
- viewModel.addWelcomeMessage()
- }
-
- private fun initializeViews() {
- recyclerView = findViewById(R.id.recyclerViewChat)
- editTextMessage = findViewById(R.id.editTextMessage)
- buttonSend = findViewById(R.id.buttonSend)
- animationViewLoading = findViewById(R.id.animationViewLoading)
- materialToolbar = findViewById(R.id.chat_toolbar)
- userProfileImage = findViewById(R.id.userProfileImage)
- userNameText = findViewById(R.id.userNameText)
- mainLayout = findViewById(R.id.main_container)
- layoutEmptyChat = findViewById(R.id.layoutEmptyChat)
- buttonBack = findViewById(R.id.buttonBack)
- buyPremium = findViewById(R.id.buyPremium)
- }
-
- private fun setupUI() {
- // Setup edge-to-edge with keyboard handling
- setupEdgeToEdgeWithKeyboard(mainLayout)
-
- // Setup toolbar
- setupToolbar()
-
- // Setup RecyclerView
- setupRecyclerView()
-
- // Setup listeners
- setupListeners()
-
- // Load user profile
- loadUserProfile()
-
- // Initialize subscription UI component
- subscriptionUIComponent = SubscriptionUIComponent(this, buyPremium,this)
- }
-
- private fun setupToolbar() {
- setSupportActionBar(materialToolbar)
- supportActionBar?.setDisplayShowTitleEnabled(false)
- materialToolbar.setNavigationOnClickListener { onBackPressed() }
- }
-
- private fun loadUserProfile() {
- val userName = authManager.getCurrentUserName()
- val userPhotoUrl = authManager.getCurrentUserPhotoUrl()
-
- userNameText.text = userName
- userProfileImage.loadProfileImage(userPhotoUrl)
- }
-
- private fun setupRecyclerView() {
- chatAdapter = ChatAdapter(isPremium)
- recyclerView.apply {
- layoutManager = LinearLayoutManager(this@ChatActivity).apply {
- stackFromEnd = true
- }
- adapter = chatAdapter
- setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom + 16)
- clipToPadding = false
- }
- }
-
- private fun setupListeners() {
- buttonSend.setOnClickListener {
- if(!isPremium){
- subscriptionUIComponent.purchasePremium()
- return@setOnClickListener
- }
- sendMessage()
- }
- buttonBack.setOnClickListener { onBackPressed() }
-
- editTextMessage.setOnEditorActionListener { _, actionId, _ ->
- if (actionId == EditorInfo.IME_ACTION_SEND) {
- sendMessage()
- return@setOnEditorActionListener true
- }
- false
- }
-
- editTextMessage.addTextChangedListener {
- //buttonSend.isEnabled = !it.isNullOrBlank()
- }
-
- ViewCompat.setOnApplyWindowInsetsListener(recyclerView) { _, insets ->
- if (chatAdapter.itemCount > 0) {
- recyclerView.scrollToPosition(chatAdapter.itemCount - 1)
- }
- insets
- }
- }
-
- private fun setupObservers() {
- // Observe chat messages
- viewModel.messages.observe(this) { messages ->
- chatAdapter.submitList(messages) {
- if (messages.isNotEmpty()) {
- recyclerView.smoothScrollToPosition(messages.size - 1)
- }
- layoutEmptyChat.visibility = if (messages.isEmpty()) View.VISIBLE else View.GONE
- }
- }
-
- // Observe loading state
- viewModel.isLoading.observe(this) { isLoading ->
- animationViewLoading.visibility = if (isLoading) View.VISIBLE else View.GONE
- editTextMessage.isEnabled = !isLoading
- buttonSend.isEnabled = !isLoading //&& !editTextMessage.text.isNullOrBlank()
- }
- }
-
- private fun initializeServices() {
- // Initialize user session and RevenueCat
- authManager.initializeUserSession(this)
- }
-
- private fun sendMessage() {
- val messageText = editTextMessage.text.toString().trim()
- if (messageText.isValidMessage()) {
- viewModel.sendMessage(messageText,authManager.getUser()?.uid ?: "")
- editTextMessage.setText("")
- } else {
- showToast("Message too long or short")
- }
- }
-
- private fun showWelcomeMessage() {
- val userName = authManager.getCurrentUserName()
- mainLayout.showWelcomeSnackbar(userName)
- }
-
- override fun onCreateOptionsMenu(menu: Menu?): Boolean {
- menuInflater.inflate(R.menu.chat_menu, menu)
- return true
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- R.id.action_logout -> {
- showLogoutDialog()
- true
- }
- R.id.action_clear_chat -> {
- showClearChatDialog()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
- private fun showLogoutDialog() {
- showConfirmationDialog(
- title = "Sign Out",
- message = "Are you sure you want to sign out?",
- positiveText = "Sign Out",
- onPositive = { signOut() }
- )
- }
-
- private fun showClearChatDialog() {
- showConfirmationDialog(
- title = "Clear Chat",
- message = "Are you sure you want to clear all messages?",
- positiveText = "Clear",
- onPositive = {
- viewModel.clearMessages()
- showToast("Chat cleared")
- }
- )
- }
-
- private fun signOut() {
- authManager.signOut {
- authManager.redirectToLogin()
- showToast("Signed out successfully")
- }
- }
-
- override fun onResume() {
- super.onResume()
- if (!authManager.isUserAuthenticated()) {
- authManager.redirectToLogin()
- } else {
- // Refresh subscription status when resuming
- subscriptionUIComponent.refreshSubscriptionStatus()
- }
- }
-
- // RevenueCatManager.SubscriptionCallback implementations
- override fun onSuccess(message: String) {
- // Handle successful initialization
- println("RevenueCat initialized: $message")
- }
-
- override fun onError(error: String) {
- println("RevenueCat error: $error")
- showToast("Premium services temporarily unavailable")
- }
-
- override fun onUserCancelled() {
- // Not applicable for initialization
- }
-
- override fun onPremiumStatusChanged(isPremium: Boolean) {
- buyPremium.visibility = if (isPremium) View.GONE else View.VISIBLE
- this.isPremium = isPremium
- if(isPremium && premiumAlertDialog?.isShowing==true){
- premiumAlertDialog?.dismiss()
- }
- if(!isPremium){
- editTextMessage.setText("")
- editTextMessage.isEnabled = false
- if(premiumAlertDialog?.isShowing==true)
- return
- premiumAlertDialog = MaterialAlertDialogBuilder(this)
- .setTitle("Premium Required")
- .setMessage("This feature is available only for Premium users.\n\nUpgrade now to unlock all features!\n1.AI Chat\n2.Google Translate\n3.Ad-free Experience\n4.Priority Support")
- .setIcon(R.drawable.baseline_generating_tokens_24)
- .setPositiveButton("Upgrade") { dialog, _ ->
-
- subscriptionUIComponent.purchasePremium()
- dialog.dismiss()
- }
- .setNegativeButton("Maybe Later") { dialog, _ ->
- dialog.dismiss()
- }.create()
- premiumAlertDialog?.show()
- }else{
- editTextMessage.isEnabled = true
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/GrammarActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/GrammarActivity.kt
deleted file mode 100644
index bf740ba..0000000
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/GrammarActivity.kt
+++ /dev/null
@@ -1,194 +0,0 @@
-package com.kharagedition.tibetankeyboard.ui
-
-import android.os.Bundle
-import android.view.View
-import android.widget.Toast
-import androidx.activity.enableEdgeToEdge
-import androidx.activity.viewModels
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.view.ViewCompat
-import androidx.core.view.WindowInsetsCompat
-import androidx.recyclerview.widget.LinearLayoutManager
-import com.airbnb.lottie.LottieAnimationView
-import com.google.android.material.appbar.MaterialToolbar
-import com.google.android.material.button.MaterialButton
-import com.google.android.material.card.MaterialCardView
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.google.android.material.textfield.TextInputEditText
-import com.google.android.material.textfield.TextInputLayout
-import com.kharagedition.tibetankeyboard.R
-import com.kharagedition.tibetankeyboard.ai.AIGrammarViewModel
-import com.kharagedition.tibetankeyboard.ai.GrammarCorrectionAdapter
-import com.kharagedition.tibetankeyboard.ai.PremiumFeatureManager
-import com.kharagedition.tibetankeyboard.auth.AuthManager
-import com.kharagedition.tibetankeyboard.util.showToast
-import androidx.recyclerview.widget.RecyclerView
-
-/**
- * Activity for Advanced Tibetan Grammar Analysis
- */
-class GrammarActivity : AppCompatActivity() {
-
- private val viewModel: AIGrammarViewModel by viewModels()
- private lateinit var authManager: AuthManager
- private lateinit var premiumFeatureManager: PremiumFeatureManager
-
- // UI Components
- private lateinit var toolbar: MaterialToolbar
- private lateinit var inputText: TextInputEditText
- private lateinit var inputLayout: TextInputLayout
- private lateinit var btnAnalyze: MaterialButton
- private lateinit var recyclerView: RecyclerView
- private lateinit var loadingAnimation: LottieAnimationView
- private lateinit var emptyStateView: MaterialCardView
- private lateinit var grammarAdapter: GrammarCorrectionAdapter
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- enableEdgeToEdge()
- setContentView(R.layout.activity_grammar)
-
- authManager = AuthManager(this)
- premiumFeatureManager = PremiumFeatureManager(this)
-
- // Check authentication
- if (!authManager.isUserAuthenticated()) {
- authManager.redirectToLogin()
- return
- }
-
- initializeViews()
- setupUI()
- setupObservers()
- }
-
- private fun initializeViews() {
- toolbar = findViewById(R.id.grammar_toolbar)
- inputText = findViewById(R.id.grammar_input_text)
- inputLayout = findViewById(R.id.grammar_input_layout)
- btnAnalyze = findViewById(R.id.btn_analyze_grammar)
- recyclerView = findViewById(R.id.grammar_corrections_list)
- loadingAnimation = findViewById(R.id.grammar_loading)
- emptyStateView = findViewById(R.id.grammar_empty_state)
- }
-
- private fun setupUI() {
- // Setup toolbar
- setSupportActionBar(toolbar)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- supportActionBar?.title = "Grammar Analyzer"
-
- toolbar.setNavigationOnClickListener { onBackPressed() }
-
- // Setup RecyclerView
- grammarAdapter = GrammarCorrectionAdapter { correction ->
- // Handle correction click
- showCorrectionDetails(correction)
- }
- recyclerView.apply {
- layoutManager = LinearLayoutManager(this@GrammarActivity)
- adapter = grammarAdapter
- visibility = View.GONE
- }
-
- // Setup button click
- btnAnalyze.setOnClickListener {
- analyzeGrammar()
- }
-
- // Setup window insets
- ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.grammar_main)) { v, insets ->
- val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
- v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
- insets
- }
- }
-
- private fun setupObservers() {
- viewModel.isLoading.observe(this) { isLoading ->
- loadingAnimation.visibility = if (isLoading) View.VISIBLE else View.GONE
- btnAnalyze.isEnabled = !isLoading
- inputText.isEnabled = !isLoading
- }
-
- viewModel.grammarResult.observe(this) { result ->
- if (result != null) {
- displayResults(result)
- }
- }
-
- viewModel.error.observe(this) { error ->
- if (error != null) {
- showToast(error)
- }
- }
- }
-
- private fun analyzeGrammar() {
- val text = inputText.text.toString().trim()
-
- if (text.isEmpty()) {
- inputLayout.error = "Please enter text to analyze"
- return
- }
-
- // Check premium feature limit
- if (premiumFeatureManager.hasExceededLimit(
- PremiumFeatureManager.PremiumFeature.GRAMMAR_CHECK
- )
- ) {
- premiumFeatureManager.showPremiumDialog(
- this,
- PremiumFeatureManager.PremiumFeature.GRAMMAR_CHECK
- )
- return
- }
-
- inputLayout.error = null
- premiumFeatureManager.incrementFeatureUsage(
- PremiumFeatureManager.PremiumFeature.GRAMMAR_CHECK
- )
-
- viewModel.analyzeGrammar(
- text,
- authManager.getUser()?.uid ?: ""
- )
- }
-
- private fun displayResults(result: Any) {
- // Parse result and display corrections
- emptyStateView.visibility = View.GONE
- recyclerView.visibility = View.VISIBLE
-
- // Animate RecyclerView entry
- recyclerView.alpha = 0f
- recyclerView.animate()
- .alpha(1f)
- .setDuration(300)
- .start()
-
- // Update adapter with corrections
- // This would require proper data mapping
- showToast("Analysis complete")
- }
-
- private fun showCorrectionDetails(correction: Any) {
- MaterialAlertDialogBuilder(this)
- .setTitle("Correction Details")
- .setMessage("Grammar correction explanation")
- .setPositiveButton("Accept") { dialog, _ ->
- dialog.dismiss()
- }
- .setNegativeButton("Dismiss") { dialog, _ ->
- dialog.dismiss()
- }
- .show()
- }
-
- override fun onResume() {
- super.onResume()
- if (!authManager.isUserAuthenticated()) {
- authManager.redirectToLogin()
- }
- }
-}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/HomeActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/HomeActivity.kt
deleted file mode 100644
index 0e9ab4f..0000000
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/HomeActivity.kt
+++ /dev/null
@@ -1,363 +0,0 @@
-package com.kharagedition.tibetankeyboard.ui
-
-import android.Manifest
-import android.annotation.SuppressLint
-import android.content.Intent
-import android.content.pm.PackageManager
-import android.net.Uri
-import android.os.Build
-import android.os.Bundle
-import android.provider.Settings
-import android.util.Log
-import android.view.View.GONE
-import android.view.View.VISIBLE
-import android.view.inputmethod.InputMethodManager
-import androidx.appcompat.app.AppCompatDelegate
-import androidx.core.app.ActivityCompat
-import androidx.core.content.ContextCompat
-import com.bumptech.glide.Glide
-import com.google.android.gms.ads.*
-import com.google.android.gms.ads.nativead.NativeAd
-import com.google.android.gms.ads.nativead.NativeAdOptions
-import com.google.firebase.messaging.FirebaseMessaging
-import com.kharagedition.tibetankeyboard.application.InputMethodActivity
-import com.kharagedition.tibetankeyboard.R
-import com.kharagedition.tibetankeyboard.SettingsActivity
-import com.kharagedition.tibetankeyboard.ads.NativeTemplateStyle
-import com.kharagedition.tibetankeyboard.databinding.ActivityHomeBinding
-import com.kharagedition.tibetankeyboard.util.AppConstant
-import com.kharagedition.tibetankeyboard.util.BottomSheetDialog
-import com.kharagedition.tibetankeyboard.util.CommonUtils
-import com.kharagedition.tibetankeyboard.BuildConfig
-import com.kharagedition.tibetankeyboard.UpdateNotificationManager
-import com.kharagedition.tibetankeyboard.auth.AuthManager
-import com.kharagedition.tibetankeyboard.subscription.RevenueCatManager
-
-
-class HomeActivity : InputMethodActivity() {
- private lateinit var homeBinding: ActivityHomeBinding
- private var isPremiumUser:Boolean = false;
- private lateinit var authManager: AuthManager
- private lateinit var updateManager: UpdateNotificationManager
-
- override fun onResume() {
- checkKeyboardIsEnabledOrNot()
-
- // CRITICAL: Sync purchases when app resumes to acknowledge any pending subscriptions
- // This prevents Google Play from auto-cancelling subscriptions after 3 days
- if (authManager.isUserAuthenticated()) {
- Log.d("HomeActivity", "onResume: Syncing purchases with Google Play...")
- RevenueCatManager.getInstance().syncPurchases(object : RevenueCatManager.SubscriptionCallback {
- override fun onSuccess(message: String) {
- Log.d("HomeActivity", "✅ Purchases synced in onResume")
- }
-
- override fun onError(error: String) {
- Log.w("HomeActivity", "⚠️ Sync failed in onResume: $error (This is OK if RevenueCat is still initializing)")
- }
-
- override fun onUserCancelled() {}
- })
- } else {
- Log.w("HomeActivity", "onResume: User not authenticated, skipping purchase sync")
- }
-
- premiumListener()
- super.onResume()
- }
-
- override fun onInputMethodPicked() {
- checkInputMethodEnableOrNot()
- }
- override fun onStart() {
- AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
- super.onStart()
- }
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- homeBinding = ActivityHomeBinding.inflate(layoutInflater)
- setContentView(homeBinding.root)
- authManager = AuthManager(this)
-
- // CRITICAL: Initialize user session and RevenueCat
- // This is essential for purchase acknowledgment
- if (authManager.isUserAuthenticated()) {
- Log.d("HomeActivity", "User authenticated, initializing RevenueCat...")
- authManager.initializeUserSession(object : RevenueCatManager.SubscriptionCallback {
- override fun onSuccess(message: String) {
- Log.d("HomeActivity", "✅ RevenueCat initialized in HomeActivity: $message")
- }
-
- override fun onError(error: String) {
- Log.e("HomeActivity", "❌ RevenueCat initialization failed: $error")
- }
-
- override fun onUserCancelled() {}
- })
- } else {
- Log.w("HomeActivity", "User NOT authenticated - RevenueCat will not initialize")
- // Optionally redirect to login if required
- // authManager.redirectToLogin()
- }
-
- checkKeyboardIsEnabledOrNot()
- initClickListener()
- updateManager = UpdateNotificationManager(this)
- requestNotificationPermission()
-
- // subscribe to all-users topic
- subscribeToAllUsersTopic()
- // Initialize Firebase and get FCM token
- initializeFirebase()
-
-
- updateManager.checkForUpdates()
- }
-
- private fun subscribeToAllUsersTopic() {
- FirebaseMessaging.getInstance().subscribeToTopic("test-users")
- .addOnCompleteListener { task ->
- var msg = "Subscribed to all-users topic"
- if (!task.isSuccessful) {
- msg = "Failed to subscribe to all-users topic"
- }
- Log.d("FCM", msg)
- }
- }
-
- private fun initializeFirebase() {
- FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
- if (!task.isSuccessful) {
- Log.w("FCM", "Fetching FCM registration token failed", task.exception)
- return@addOnCompleteListener
- }
-
- val token = task.result
- Log.d("FCM", "FCM Registration Token: $token")
-
- // Send token to your server
- }
- }
- private fun requestNotificationPermission() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- if (ContextCompat.checkSelfPermission(
- this,
- Manifest.permission.POST_NOTIFICATIONS
- ) != PackageManager.PERMISSION_GRANTED
- ) {
- ActivityCompat.requestPermissions(
- this,
- arrayOf(Manifest.permission.POST_NOTIFICATIONS),
- 101
- )
- }
- }
- }
-
- @SuppressLint("NewApi")
- private fun initNativeAds() {
- val adUnitId = if (BuildConfig.DEBUG) {
- AppConstant.TEST_APP_ID
- } else {
- AppConstant.PRODUCTION_ADS_NATIVE
- }
- val adLoader = AdLoader.Builder(this, adUnitId)
- .forNativeAd { ad: NativeAd ->
- if (isDestroyed) {
- ad.destroy()
- return@forNativeAd
- }
- val styles =
- NativeTemplateStyle.Builder().build()
-
- homeBinding.template.setStyles(styles)
- homeBinding.template.setNativeAd(ad)
- homeBinding.nativeAdsLayout.visibility = VISIBLE
- }
- .withAdListener(object : AdListener() {
- override fun onAdFailedToLoad(adError: LoadAdError) {
- Log.e("TAG", "onAdFailedToLoad: " + adError.message)
- }
- })
- .withNativeAdOptions(
- NativeAdOptions.Builder()
- // Methods in the NativeAdOptions.Builder class can be
- // used here to specify individual options settings.
- .build()
- )
- .build()
- adLoader.loadAd(AdRequest.Builder().build())
- }
-
-/*
- private fun initNativeAds() {
- //---> initializing Google Ad SDK
- MobileAds.initialize(this) {
- val adLoader: AdLoader =
- AdLoader.Builder(this, "ca-app-pub-3940256099942544/2247696110")
- .forNativeAd(NativeAd.OnNativeAdLoadedListener { nativeAd ->
- Log.d("TAG", "Native Ad Loaded")
- if (isDestroyed) {
- nativeAd.destroy()
- Log.d("TAG", "Native Ad Destroyed")
- return@OnNativeAdLoadedListener
- }
- val styles = NativeTemplateStyle.Builder().build()
- homeBinding.templete.setStyles(styles)
- homeBinding.templete.visibility = VISIBLE
- homeBinding.templete.setNativeAd(nativeAd)
- })
- .withAdListener(object : AdListener() {
- override fun onAdFailedToLoad(adError: LoadAdError?) {
- // Handle the failure by logging, altering the UI, and so on.
- Log.d("TAG", "Native Ad Failed To Load" + adError?.message)
- homeBinding.templete.visibility = GONE
- }
- })
- .withNativeAdOptions(
- NativeAdOptions.Builder()
- .build()
- )
- .build()
-
- adLoader.loadAd(AdRequest.Builder().build())
- }
-
- }
-*/
-
- private fun initClickListener() {
- homeBinding.enableKeyboardBtn.setOnClickListener {
- val enableIntent = Intent(Settings.ACTION_INPUT_METHOD_SETTINGS)
- enableIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
- this.startActivity(enableIntent)
- }
- homeBinding.inputMethodBtn.setOnClickListener {
- pickInput()
- }
- homeBinding.chatCard.setOnClickListener {
- startActivity(Intent(this, ChatActivity::class.java))
- }
- homeBinding.manageSubscriptionCard.setOnClickListener() {
- openView(CommonUtils.PLAY_STORE_SUBSCRIPTION_URL)
- }
- homeBinding.sharedCard.setOnClickListener {
- try {
- val shareIntent = Intent(Intent.ACTION_SEND)
- shareIntent.type = "text/plain"
- shareIntent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.app_name))
- var shareMessage = "\nCheck out this Keyboard Application.\n\n"
- shareMessage =
- """
- $shareMessage ${CommonUtils.PLAY_STORE_URL}
- """.trimIndent()
- shareIntent.putExtra(Intent.EXTRA_TEXT, shareMessage)
- startActivity(Intent.createChooser(shareIntent, "choose one"))
- } catch (e: Exception) {
- //e.toString()
- }
- }
- homeBinding.rateCard.setOnClickListener {
- openView(CommonUtils.PLAY_STORE_URL)
-
- }
- homeBinding.moreCard.setOnClickListener {
- val sheet = BottomSheetDialog(
- showAd = !isPremiumUser
- )
- sheet.show(this.supportFragmentManager, "ModalBottomSheet")
- }
- homeBinding.settingCard.setOnClickListener{
- startActivity(Intent(this,SettingsActivity::class.java))
- }
- homeBinding.exitCard.setOnClickListener {
- finish()
- }
- homeBinding.settingCard.setOnClickListener{
- startActivity(Intent(this, SettingsActivity::class.java))
- }
-
- premiumListener()
- }
-
- private fun premiumListener() {
- RevenueCatManager.getInstance().refreshCustomerInfo()
- RevenueCatManager.getInstance().isPremiumUser.observe(this) { isPremium ->
- isPremiumUser = isPremium;
- if (authManager.isUserAuthenticated() && isPremium) {
- homeBinding.nativeAdsLayout.visibility = GONE
- homeBinding.bottomBtnLayout.visibility = GONE // For now
- /* homeBinding.chatIcon.setColorFilter(getColor(R.color.premium_yellow))
- homeBinding.shareIcon.setColorFilter(getColor(R.color.premium_yellow))
- homeBinding.rateIcon.setColorFilter(getColor(R.color.premium_yellow))
- homeBinding.moreIcon.setColorFilter(getColor(R.color.premium_yellow))
- homeBinding.settingIcon.setColorFilter(getColor(R.color.premium_yellow))
- homeBinding.exitIcon.setColorFilter(getColor(R.color.premium_yellow))
- homeBinding.bagIcon.setColorFilter(getColor(R.color.premium_yellow))
- */
- } else {
- homeBinding.bottomBtnLayout.visibility = GONE
- homeBinding.nativeAdsLayout.visibility = VISIBLE
- /* homeBinding.chatIcon.setColorFilter(getColor(R.color.white))
- homeBinding.shareIcon.setColorFilter(getColor(R.color.white))
- homeBinding.rateIcon.setColorFilter(getColor(R.color.white))
- homeBinding.moreIcon.setColorFilter(getColor(R.color.white))
- homeBinding.settingIcon.setColorFilter(getColor(R.color.white))
- homeBinding.exitIcon.setColorFilter(getColor(R.color.white))
- homeBinding.bagIcon.setColorFilter(getColor(R.color.white))*/
- initNativeAds()
- }
- }
- }
-
- private fun openView(playStoreUrl: String) {
- startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(playStoreUrl)))
- }
- private fun checkKeyboardIsEnabledOrNot() {
- val im = applicationContext.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
- val list = im.enabledInputMethodList.toString()
- if(list.contains("com.kharagedition.tibetankeyboard")){
- homeBinding.messgaeLbl.text = getString(R.string.one_step_left)
- homeBinding.enableKeyboardBtn.isEnabled = false
- homeBinding.inputMethodBtn.isEnabled = true
- //check input method
- checkInputMethodEnableOrNot()
-
- }else{
- homeBinding.messgaeLbl.text = getString(R.string.two_step_left)
- homeBinding.enableKeyboardBtn.isEnabled = true
- homeBinding.inputMethodBtn.isEnabled = false
- homeBinding.gifCard.visibility = VISIBLE
- Glide.with(this)
- .load(R.drawable.keyboard)
- .into(homeBinding.gitImage)
- }
- //homeBinding.enableKeyboardBtn.isEnabled = !list.contains("com.kharagedition.tibetankeyboard")
- }
-
- private fun checkInputMethodEnableOrNot() {
- val string = Settings.Secure.getString(
- contentResolver,
- Settings.Secure.DEFAULT_INPUT_METHOD
- )
- if(string.contains("com.kharagedition.tibetankeyboard")){
- homeBinding.messgaeLbl.text = getString(R.string.setup_done)
- homeBinding.testField.visibility = VISIBLE
- //homeBinding.inputMethodBtn.isEnabled = false;
- homeBinding.gifCard.visibility = GONE
-
- }else{
- homeBinding.testField.visibility = GONE
- homeBinding.gifCard.visibility = VISIBLE
- Glide.with(this)
- .load(R.drawable.input)
- .into(homeBinding.gitImage)
- homeBinding.messgaeLbl.text = getString(R.string.one_step_left)
- homeBinding.inputMethodBtn.isEnabled = true
-
- }
- //homeBinding.inputMethodBtn.isEnabled = !string.contains("com.kharagedition.tibetankeyboard")
- }
-
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/SplashScreenActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/SplashScreenActivity.kt
deleted file mode 100644
index 099a7f4..0000000
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/SplashScreenActivity.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-package com.kharagedition.tibetankeyboard.ui
-
-import android.content.Intent
-import android.content.pm.PackageManager
-import androidx.appcompat.app.AppCompatActivity
-import android.os.Bundle
-import android.os.Handler
-import android.os.Looper
-import android.view.animation.Animation
-import android.view.animation.AnimationUtils
-import androidx.appcompat.app.AppCompatDelegate
-import com.google.android.gms.ads.MobileAds
-import com.kharagedition.tibetankeyboard.R
-import com.kharagedition.tibetankeyboard.databinding.ActivitySplashScreenBinding
-
-class SplashScreenActivity : AppCompatActivity() {
- lateinit var activitySplashScreenBinding : ActivitySplashScreenBinding
- lateinit var topAnimation : Animation
- lateinit var bottomAnimation: Animation
- companion object {
- private var SECOND : Long = 1100
- }
-
- override fun onStart() {
- AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
- super.onStart()
- }
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- activitySplashScreenBinding = ActivitySplashScreenBinding.inflate(layoutInflater)
- setContentView(activitySplashScreenBinding.root)
- MobileAds.initialize(this) {}
-
- topAnimation = AnimationUtils.loadAnimation(this,R.anim.top_animation)
- bottomAnimation = AnimationUtils.loadAnimation(this,R.anim.bottom_animation)
-
- activitySplashScreenBinding.splashImage.animation =topAnimation
- activitySplashScreenBinding.splashText.animation = bottomAnimation
- activitySplashScreenBinding.authorText.animation = bottomAnimation
- setVersion()
- Handler(Looper.getMainLooper()).postDelayed({
- startActivity(Intent(this, HomeActivity::class.java))
- finish()
- },SECOND)
- }
-
- private fun setVersion() {
- try {
- val versionName: String? = this.packageManager
- .getPackageInfo(this.packageName, 0).versionName
- activitySplashScreenBinding.version.text = "Version: $versionName"
- } catch (e: PackageManager.NameNotFoundException) {
- e.printStackTrace()
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/TransliterationActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/TransliterationActivity.kt
deleted file mode 100644
index 2c3cc8e..0000000
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/TransliterationActivity.kt
+++ /dev/null
@@ -1,192 +0,0 @@
-package com.kharagedition.tibetankeyboard.ui
-
-import android.os.Bundle
-import android.view.View
-import android.widget.ArrayAdapter
-import android.widget.Spinner
-import android.widget.TextView
-import androidx.activity.enableEdgeToEdge
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.view.ViewCompat
-import androidx.core.view.WindowInsetsCompat
-import androidx.core.widget.addTextChangedListener
-import com.airbnb.lottie.LottieAnimationView
-import com.google.android.material.appbar.MaterialToolbar
-import com.google.android.material.button.MaterialButton
-import com.google.android.material.textfield.TextInputEditText
-import com.google.android.material.textfield.TextInputLayout
-import com.kharagedition.tibetankeyboard.R
-import com.kharagedition.tibetankeyboard.ai.AIRepository
-import com.kharagedition.tibetankeyboard.ai.PremiumFeatureManager
-import com.kharagedition.tibetankeyboard.auth.AuthManager
-import com.kharagedition.tibetankeyboard.util.showToast
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.launch
-
-/**
- * Activity for Tibetan-English Phonetic Transliteration
- */
-class TransliterationActivity : AppCompatActivity() {
-
- private val repository = AIRepository(application)
- private lateinit var authManager: AuthManager
- private lateinit var premiumFeatureManager: PremiumFeatureManager
-
- // UI Components
- private lateinit var toolbar: MaterialToolbar
- private lateinit var sourceInput: TextInputEditText
- private lateinit var targetOutput: TextInputEditText
- private lateinit var sourceSystemSpinner: Spinner
- private lateinit var targetSystemSpinner: Spinner
- private lateinit var btnConvert: MaterialButton
- private lateinit var loadingAnimation: LottieAnimationView
- private lateinit var resultStatus: TextView
-
- private val transliterationScope = CoroutineScope(Dispatchers.Main)
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- enableEdgeToEdge()
- setContentView(R.layout.activity_transliteration)
-
- authManager = AuthManager(this)
- premiumFeatureManager = PremiumFeatureManager(this)
-
- if (!authManager.isUserAuthenticated()) {
- authManager.redirectToLogin()
- return
- }
-
- initializeViews()
- setupUI()
- }
-
- private fun initializeViews() {
- toolbar = findViewById(R.id.transliteration_toolbar)
- sourceInput = findViewById(R.id.source_input)
- targetOutput = findViewById(R.id.target_output)
- sourceSystemSpinner = findViewById(R.id.source_system_spinner)
- targetSystemSpinner = findViewById(R.id.target_system_spinner)
- btnConvert = findViewById(R.id.btn_convert)
- loadingAnimation = findViewById(R.id.transliteration_loading)
- resultStatus = findViewById(R.id.result_status)
- }
-
- private fun setupUI() {
- setSupportActionBar(toolbar)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- supportActionBar?.title = "Transliteration"
-
- toolbar.setNavigationOnClickListener { onBackPressed() }
-
- // Setup spinners
- val systems = arrayOf("Wylie", "Tibetan", "Phonetic", "DTS")
- val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, systems)
- adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
-
- sourceSystemSpinner.adapter = adapter
- targetSystemSpinner.adapter = adapter
-
- // Set defaults
- sourceSystemSpinner.setSelection(0) // Wylie
- targetSystemSpinner.setSelection(1) // Tibetan
-
- // Real-time conversion
- sourceInput.addTextChangedListener { text ->
- if (text?.isNotBlank() == true) {
- performTransliteration()
- }
- }
-
- // Convert button
- btnConvert.setOnClickListener {
- if (premiumFeatureManager.hasExceededLimit(
- PremiumFeatureManager.PremiumFeature.TRANSLITERATION
- )
- ) {
- premiumFeatureManager.showPremiumDialog(
- this,
- PremiumFeatureManager.PremiumFeature.TRANSLITERATION
- )
- return@setOnClickListener
- }
- performTransliteration()
- premiumFeatureManager.incrementFeatureUsage(
- PremiumFeatureManager.PremiumFeature.TRANSLITERATION
- )
- }
-
- ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.transliteration_main)) { v, insets ->
- val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
- v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
- insets
- }
- }
-
- private fun performTransliteration() {
- val text = sourceInput.text.toString().trim()
- if (text.isEmpty()) return
-
- loadingAnimation.visibility = View.VISIBLE
- btnConvert.isEnabled = false
-
- transliterationScope.launch {
- try {
- val result = repository.transliterate(
- text,
- getSourceSystem(),
- getTargetSystem(),
- authManager.getUser()?.uid ?: ""
- )
-
- targetOutput.setText(result.result)
- resultStatus.text = "Confidence: ${(result.confidence * 100).toInt()}%"
- resultStatus.visibility = View.VISIBLE
-
- // Animate entry
- targetOutput.alpha = 0f
- targetOutput.animate().alpha(1f).setDuration(300).start()
-
- } catch (e: Exception) {
- showToast("Transliteration failed: ${e.message}")
- } finally {
- loadingAnimation.visibility = View.GONE
- btnConvert.isEnabled = true
- }
- }
- }
-
- private fun getSourceSystem(): String {
- return when (sourceSystemSpinner.selectedItemPosition) {
- 0 -> "wylie"
- 1 -> "tibetan"
- 2 -> "phonetic"
- 3 -> "dts"
- else -> "wylie"
- }
- }
-
- private fun getTargetSystem(): String {
- return when (targetSystemSpinner.selectedItemPosition) {
- 0 -> "wylie"
- 1 -> "tibetan"
- 2 -> "phonetic"
- 3 -> "dts"
- else -> "tibetan"
- }
- }
-
- override fun onResume() {
- super.onResume()
- if (!authManager.isUserAuthenticated()) {
- authManager.redirectToLogin()
- }
- }
-
- override fun onDestroy() {
- super.onDestroy()
- transliterationScope.cancel()
- }
-}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/about/AboutActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/about/AboutActivity.kt
new file mode 100644
index 0000000..cb86a1a
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/about/AboutActivity.kt
@@ -0,0 +1,62 @@
+package com.kharagedition.tibetankeyboard.ui.about
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.appcompat.app.AppCompatActivity
+import com.kharagedition.tibetankeyboard.R
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanKeyboardTheme
+import com.kharagedition.tibetankeyboard.util.CommonUtils
+
+/** Compose About screen — replaces the old BottomSheetDialog. Same links/intents. */
+class AboutActivity : AppCompatActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ TibetanKeyboardTheme {
+ AboutScreen(
+ AboutActions(
+ onBack = { finish() },
+ onShare = { shareApp() },
+ onRate = { openUrl(CommonUtils.PLAY_STORE_URL) },
+ onDictionary = { openUrl(CommonUtils.PLAY_STORE_DICTIONARY_URL) },
+ onCalendar = { openUrl(CommonUtils.PLAY_STORE_CALENDAR_URL) },
+ onGithub = { openUrl(CommonUtils.GITHUB_URL) },
+ onGmail = { sendEmail() },
+ )
+ )
+ }
+ }
+ }
+
+ private fun openUrl(url: String) {
+ startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
+ }
+
+ private fun shareApp() {
+ try {
+ val shareIntent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_SUBJECT, getString(R.string.app_name))
+ putExtra(
+ Intent.EXTRA_TEXT,
+ "\nCheck out this Keyboard Application.\n\n${CommonUtils.PLAY_STORE_URL}"
+ )
+ }
+ startActivity(Intent.createChooser(shareIntent, "choose one"))
+ } catch (_: Exception) {
+ }
+ }
+
+ private fun sendEmail() {
+ val intent = Intent(Intent.ACTION_SENDTO).apply {
+ data = Uri.parse("mailto:developer.kharag@gmail.com")
+ }
+ try {
+ startActivity(intent)
+ } catch (_: Exception) {
+ }
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/about/AboutScreen.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/about/AboutScreen.kt
new file mode 100644
index 0000000..79bb4bc
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/about/AboutScreen.kt
@@ -0,0 +1,202 @@
+package com.kharagedition.tibetankeyboard.ui.about
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Code
+import androidx.compose.material.icons.rounded.Email
+import androidx.compose.material.icons.rounded.Favorite
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.kharagedition.tibetankeyboard.R
+import com.kharagedition.tibetankeyboard.ui.compose.components.AppIcons
+import com.kharagedition.tibetankeyboard.ui.compose.components.BackHeader
+import com.kharagedition.tibetankeyboard.ui.compose.components.BoText
+import com.kharagedition.tibetankeyboard.ui.compose.components.ScreenScaffold
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanColors
+import androidx.compose.foundation.Image
+import androidx.compose.ui.res.painterResource
+
+class AboutActions(
+ val onBack: () -> Unit,
+ val onShare: () -> Unit,
+ val onRate: () -> Unit,
+ val onDictionary: () -> Unit,
+ val onCalendar: () -> Unit,
+ val onGithub: () -> Unit,
+ val onGmail: () -> Unit,
+)
+
+@Composable
+fun AboutScreen(actions: AboutActions) {
+ ScreenScaffold {
+ BackHeader(stringResource(R.string.action_about), onBack = actions.onBack)
+
+ Column(Modifier.padding(horizontal = 18.dp)) {
+ // hero cover
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(150.dp)
+ .clip(RoundedCornerShape(20.dp))
+ .background(
+ Brush.linearGradient(
+ listOf(TibetanColors.Brown400, TibetanColors.Brown600, TibetanColors.Bg800)
+ )
+ )
+ .border(1.dp, TibetanColors.Line, RoundedCornerShape(20.dp)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Image(
+ painter = painterResource(R.mipmap.ic_launcher),
+ contentDescription = null,
+ modifier = Modifier.size(72.dp,).clip(RoundedCornerShape(10.dp))
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(stringResource(R.string.app_name), color = TibetanColors.Cream, fontSize = 14.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+
+ // about card
+ Spacer(Modifier.height(16.dp))
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(18.dp))
+ .background(TibetanColors.Brown700)
+ .border(1.dp, TibetanColors.Line, RoundedCornerShape(18.dp))
+ .padding(18.dp)
+ ) {
+ Text(stringResource(R.string.about_this_app), color = TibetanColors.Cream, fontSize = 16.sp, fontWeight = FontWeight.ExtraBold)
+ Spacer(Modifier.height(12.dp))
+ listOf(
+ "A simple, friendly way to type in Tibetan.",
+ "Built and maintained as open source.",
+ "Your feedback and rating mean a lot to us.",
+ ).forEach { line ->
+ Row(modifier = Modifier.padding(bottom = 11.dp), horizontalArrangement = Arrangement.spacedBy(11.dp)) {
+ Box(
+ modifier = Modifier.padding(top = 7.dp).size(6.dp).clip(RoundedCornerShape(99.dp)).background(TibetanColors.Gold400)
+ )
+ Text(line, color = TibetanColors.Cream2, fontSize = 14.sp, lineHeight = 21.sp)
+ }
+ }
+ Spacer(Modifier.height(3.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ PillAction("Share", AppIcons.Share, Modifier.weight(1f), actions.onShare)
+ PillAction("Rate", AppIcons.Star, Modifier.weight(1f), actions.onRate)
+ }
+ }
+
+ // more from KharagEdition
+ Spacer(Modifier.height(22.dp))
+ Text(
+ stringResource(R.string.more_from_kharag),
+ color = TibetanColors.CreamDim, fontSize = 12.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.6.sp,
+ modifier = Modifier.padding(start = 2.dp, bottom = 12.dp),
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ SisterApp("Tb–En Dictionary", "ཚིག་མཛོད", R.drawable.dictionary,Modifier.weight(1f), actions.onDictionary)
+ SisterApp("Tibetan Calendar", "ལོ་ཐོ", R.drawable.calendar,Modifier.weight(1f), actions.onCalendar)
+ }
+
+ // support
+ Spacer(Modifier.height(22.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
+ PillAction("GitHub", Icons.Rounded.Code, Modifier.weight(1f), actions.onGithub)
+ PillAction("Contact", Icons.Rounded.Email, Modifier.weight(1f), actions.onGmail)
+ }
+
+ Spacer(Modifier.height(20.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text("Made with ", color = TibetanColors.CreamFaint, fontSize = 12.sp)
+ Icon(Icons.Rounded.Favorite, null, tint = TibetanColors.MaroonSoft, modifier = Modifier.size(13.dp))
+ Text(" by KharagEdition", color = TibetanColors.CreamFaint, fontSize = 12.sp)
+ }
+ }
+ }
+}
+
+@Composable
+private fun PillAction(label: String, icon: ImageVector, modifier: Modifier, onClick: () -> Unit) {
+ Row(
+ modifier = modifier
+ .clip(RoundedCornerShape(12.dp))
+ .background(TibetanColors.Brown600)
+ .clickable(onClick = onClick)
+ .padding(vertical = 11.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(icon, null, tint = TibetanColors.Gold300, modifier = Modifier.size(17.dp))
+ Spacer(Modifier.size(8.dp))
+ Text(label, color = TibetanColors.Cream, fontSize = 13.5.sp, fontWeight = FontWeight.Bold)
+ }
+}
+
+@Composable
+private fun SisterApp(title: String, bo: String,drawableId:Int, modifier: Modifier, onClick: () -> Unit) {
+ Column(
+ modifier = modifier
+ .clip(RoundedCornerShape(16.dp))
+ .background(TibetanColors.Brown700)
+ .border(1.dp, TibetanColors.Line, RoundedCornerShape(16.dp))
+ .clickable(onClick = onClick)
+ .padding(horizontal = 12.dp, vertical = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .size(52.dp)
+ .clip(RoundedCornerShape(14.dp))
+ .background(
+ Brush.linearGradient(
+ listOf(
+ TibetanColors.Brown500,
+ TibetanColors.Brown600
+ )
+ )
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ painter = painterResource(drawableId),
+ contentDescription = null,
+ modifier = Modifier.size(42.dp,).clip(RoundedCornerShape(10.dp))
+ )
+ }
+ Text(title, color = TibetanColors.Cream, fontSize = 13.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center)
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ Icon(AppIcons.Download, null, tint = TibetanColors.Gold300, modifier = Modifier.size(15.dp))
+ Text("Get", color = TibetanColors.Gold300, fontSize = 12.5.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/ChatActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/ChatActivity.kt
new file mode 100644
index 0000000..a29f9b3
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/ChatActivity.kt
@@ -0,0 +1,113 @@
+package com.kharagedition.tibetankeyboard.ui.chat
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import com.kharagedition.tibetankeyboard.R
+import com.kharagedition.tibetankeyboard.analytics.AppAnalytics
+import com.kharagedition.tibetankeyboard.auth.AuthManager
+import com.kharagedition.tibetankeyboard.data.repository.RevenueCatManager
+import com.kharagedition.tibetankeyboard.data.repository.subscriptionCallback
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanKeyboardTheme
+import com.kharagedition.tibetankeyboard.util.isValidMessage
+import com.kharagedition.tibetankeyboard.util.openPremiumUpgrade
+import com.kharagedition.tibetankeyboard.util.showConfirmationDialog
+import com.kharagedition.tibetankeyboard.util.showToast
+
+class ChatActivity : AppCompatActivity() {
+
+ private val viewModel: ChatViewModel by viewModels()
+ private lateinit var authManager: AuthManager
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ authManager = AuthManager(this)
+
+ if (!authManager.isUserAuthenticated()) {
+ authManager.redirectToLogin()
+ return
+ }
+
+ // Initialize user session and RevenueCat. Init errors are logged, not shown —
+ // they fire transiently while RevenueCat warms up and shouldn't interrupt the user.
+ authManager.initializeUserSession(subscriptionCallback(
+ onError = { android.util.Log.w("ChatActivity", "RevenueCat init: $it") },
+ ))
+
+ viewModel.addWelcomeMessage()
+ AppAnalytics.logChatOpened()
+
+ setContent {
+ TibetanKeyboardTheme {
+ val messages by viewModel.messages.observeAsState(emptyList())
+ val isLoading by viewModel.isLoading.observeAsState(false)
+ val isPremium by RevenueCatManager.getInstance().isPremiumUser.observeAsState(false)
+ val model by viewModel.model.observeAsState(com.kharagedition.tibetankeyboard.ui.settings.SettingsPrefs.DEFAULT_MODEL)
+
+ ChatScreen(
+ messages = messages,
+ isLoading = isLoading,
+ isPremium = isPremium,
+ model = model,
+ actions = chatActions(),
+ )
+ }
+ }
+ }
+
+ private fun chatActions() = ChatActions(
+ onBack = { finish() },
+ onSend = { text ->
+ if (text.isValidMessage()) {
+ viewModel.sendMessage(text, authManager.getUser()?.uid ?: "")
+ } else {
+ showToast(getString(R.string.message_invalid))
+ }
+ },
+ onCopy = { text ->
+ val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
+ showToast(getString(R.string.copied))
+ },
+ onClear = {
+ showConfirmationDialog(
+ title = getString(R.string.clear_chat),
+ message = getString(R.string.clear_chat_confirm),
+ positiveText = getString(R.string.clear),
+ onPositive = { viewModel.clearMessages(); showToast(getString(R.string.chat_cleared)) },
+ )
+ },
+ onLogout = {
+ showConfirmationDialog(
+ title = getString(R.string.sign_out),
+ message = getString(R.string.sign_out_confirm),
+ positiveText = getString(R.string.sign_out),
+ onPositive = { signOut() },
+ )
+ },
+ onUpgrade = { openPremiumUpgrade(AppAnalytics.UpgradeSource.CHAT) },
+ onModelChange = { viewModel.setModel(it) },
+ )
+
+ private fun signOut() {
+ authManager.signOut {
+ authManager.redirectToLogin()
+ showToast(getString(R.string.signed_out))
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ if (!authManager.isUserAuthenticated()) {
+ authManager.redirectToLogin()
+ } else {
+ RevenueCatManager.getInstance().refreshCustomerInfo()
+ }
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/chat/ChatExportDialog.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/ChatExportDialog.kt
similarity index 98%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/chat/ChatExportDialog.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/ChatExportDialog.kt
index 50365c7..7204432 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/chat/ChatExportDialog.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/ChatExportDialog.kt
@@ -1,4 +1,4 @@
-package com.kharagedition.tibetankeyboard.chat
+package com.kharagedition.tibetankeyboard.ui.chat
import android.os.Bundle
import android.view.LayoutInflater
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/chat/ChatHistoryAdapter.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/ChatHistoryAdapter.kt
similarity index 98%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/chat/ChatHistoryAdapter.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/ChatHistoryAdapter.kt
index c49a4d5..957e071 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/chat/ChatHistoryAdapter.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/ChatHistoryAdapter.kt
@@ -1,4 +1,4 @@
-package com.kharagedition.tibetankeyboard.chat
+package com.kharagedition.tibetankeyboard.ui.chat
import android.view.LayoutInflater
import android.view.ViewGroup
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/chat/ChatHistoryBottomSheet.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/ChatHistoryBottomSheet.kt
similarity index 98%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/chat/ChatHistoryBottomSheet.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/ChatHistoryBottomSheet.kt
index e90fecd..45740db 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/chat/ChatHistoryBottomSheet.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/ChatHistoryBottomSheet.kt
@@ -1,4 +1,4 @@
-package com.kharagedition.tibetankeyboard.chat
+package com.kharagedition.tibetankeyboard.ui.chat
import android.os.Bundle
import android.view.LayoutInflater
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/ChatScreen.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/ChatScreen.kt
new file mode 100644
index 0000000..a461d85
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/ChatScreen.kt
@@ -0,0 +1,292 @@
+package com.kharagedition.tibetankeyboard.ui.chat
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.kharagedition.tibetankeyboard.R
+import com.kharagedition.tibetankeyboard.data.model.ChatMessage
+import com.kharagedition.tibetankeyboard.ui.compose.components.AppIcons
+import com.kharagedition.tibetankeyboard.ui.compose.components.CharCounter
+import com.kharagedition.tibetankeyboard.ui.compose.components.ModelSelector
+import com.kharagedition.tibetankeyboard.util.AiLimits
+import com.kharagedition.tibetankeyboard.ui.compose.theme.LocalTibetanFont
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanColors
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanTokens
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+class ChatActions(
+ val onBack: () -> Unit,
+ val onSend: (String) -> Unit,
+ val onCopy: (String) -> Unit,
+ val onClear: () -> Unit,
+ val onLogout: () -> Unit,
+ val onUpgrade: () -> Unit,
+ val onModelChange: (String) -> Unit,
+)
+
+@Composable
+fun ChatScreen(
+ messages: List,
+ isLoading: Boolean,
+ isPremium: Boolean,
+ model: String,
+ actions: ChatActions,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(TibetanColors.Bg900)
+ .statusBarsPadding()
+ .imePadding()
+ ) {
+ ChatHeader(model, actions)
+
+ val listState = rememberLazyListState()
+ LaunchedEffect(messages.size, isLoading) {
+ val target = messages.size - 1 + if (isLoading) 1 else 0
+ if (target >= 0) listState.animateScrollToItem(target.coerceAtLeast(0))
+ }
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.weight(1f).fillMaxWidth(),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ items(messages, key = { it.id }) { msg ->
+ if (msg.isFromUser) UserBubble(msg) else BotBubble(msg, actions.onCopy)
+ }
+ if (isLoading) item { TypingIndicator() }
+ }
+
+ ChatInput(
+ enabled = !isLoading,
+ onSubmit = { text ->
+ if (!isPremium) actions.onUpgrade() else actions.onSend(text)
+ },
+ )
+ }
+}
+
+@Composable
+private fun ChatHeader(model: String, actions: ChatActions) {
+ var menuOpen by remember { mutableStateOf(false) }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 14.dp, end = 8.dp, top = 8.dp, bottom = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Icon(
+ AppIcons.Back, stringResource(R.string.cd_back), tint = TibetanColors.Cream,
+ modifier = Modifier.clip(CircleShape).clickable(onClick = actions.onBack).padding(4.dp).size(22.dp),
+ )
+ Box(
+ modifier = Modifier.size(40.dp).clip(CircleShape).background(TibetanTokens.GoldVertical),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(AppIcons.Bot, null, tint = TibetanColors.Espresso, modifier = Modifier.size(23.dp))
+ }
+ Column(Modifier.weight(1f)) {
+ Text(stringResource(R.string.ai_assistant), color = TibetanColors.Cream, fontSize = 16.sp, fontWeight = FontWeight.Bold)
+ Text(stringResource(R.string.online_tibetan), color = TibetanColors.Jade, fontSize = 12.sp)
+ }
+ ModelSelector(model = model, onModelChange = actions.onModelChange)
+ Box {
+ Icon(
+ AppIcons.More, stringResource(R.string.cd_menu), tint = TibetanColors.Cream,
+ modifier = Modifier.clip(CircleShape).clickable { menuOpen = true }.padding(6.dp).size(22.dp),
+ )
+ DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
+ DropdownMenuItem(text = { Text(stringResource(R.string.clear_chat_action)) }, onClick = { menuOpen = false; actions.onClear() })
+ DropdownMenuItem(text = { Text(stringResource(R.string.sign_out)) }, onClick = { menuOpen = false; actions.onLogout() })
+ }
+ }
+ }
+ Box(Modifier.fillMaxWidth().height(1.dp).background(TibetanColors.Line))
+}
+
+@Composable
+private fun UserBubble(msg: ChatMessage) {
+ Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End) {
+ Box(
+ modifier = Modifier
+ .widthIn(max = 290.dp)
+ .clip(RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 18.dp, bottomEnd = 5.dp))
+ .background(TibetanTokens.GoldVertical)
+ .padding(horizontal = 15.dp, vertical = 11.dp),
+ ) {
+ Text(
+ msg.message, color = TibetanColors.Espresso, fontSize = 16.sp,
+ fontWeight = FontWeight.Medium, lineHeight = 24.sp, fontFamily = LocalTibetanFont.current,
+ )
+ }
+ Text(timeOf(msg.timestamp), color = TibetanColors.CreamFaint, fontSize = 10.5.sp, modifier = Modifier.padding(top = 4.dp, end = 2.dp))
+ }
+}
+
+@Composable
+private fun BotBubble(msg: ChatMessage, onCopy: (String) -> Unit) {
+ Row(horizontalArrangement = Arrangement.spacedBy(9.dp), modifier = Modifier.fillMaxWidth(0.88f)) {
+ Box(
+ modifier = Modifier.size(30.dp).clip(CircleShape).background(TibetanColors.Brown600),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(AppIcons.Bot, null, tint = TibetanColors.Gold300, modifier = Modifier.size(18.dp))
+ }
+ Column {
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 5.dp, bottomEnd = 18.dp))
+ .background(TibetanColors.Brown600)
+ .padding(horizontal = 15.dp, vertical = 12.dp),
+ ) {
+ Text(
+ msg.message, color = TibetanColors.Cream, fontSize = 16.sp,
+ lineHeight = 27.sp, fontFamily = LocalTibetanFont.current,
+ )
+ }
+ Row(
+ modifier = Modifier.padding(top = 6.dp, start = 4.dp).clickable { onCopy(msg.message) },
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(5.dp),
+ ) {
+ Icon(AppIcons.Copy, null, tint = TibetanColors.CreamDim, modifier = Modifier.size(14.dp))
+ Text(stringResource(R.string.copy), color = TibetanColors.CreamDim, fontSize = 11.5.sp)
+ }
+ }
+ }
+}
+
+@Composable
+private fun TypingIndicator() {
+ Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
+ Box(
+ modifier = Modifier.size(30.dp).clip(CircleShape).background(TibetanColors.Brown600),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(AppIcons.Bot, null, tint = TibetanColors.Gold300, modifier = Modifier.size(18.dp))
+ }
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 5.dp, bottomEnd = 18.dp))
+ .background(TibetanColors.Brown600)
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ ) {
+ Text("···", color = TibetanColors.CreamDim, fontSize = 16.sp)
+ }
+ }
+}
+
+@Composable
+private fun ChatInput(enabled: Boolean, onSubmit: (String) -> Unit) {
+ var text by remember { mutableStateOf("") }
+ val overLimit = text.length > AiLimits.MAX_INPUT_CHARS
+ val canSend = enabled && text.isNotBlank() && !overLimit
+ val submit = {
+ val t = text.trim()
+ if (t.isNotEmpty() && t.length <= AiLimits.MAX_INPUT_CHARS) { onSubmit(t); text = "" }
+ }
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(horizontal = 14.dp, vertical = 10.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .height(46.dp)
+ .clip(RoundedCornerShape(23.dp))
+ .background(TibetanColors.Brown700)
+ .border(1.dp, if (overLimit) TibetanColors.MaroonSoft else TibetanColors.Line2, RoundedCornerShape(23.dp))
+ .padding(horizontal = 18.dp),
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ if (text.isEmpty()) {
+ Text("འདིར་འབྲི་རོགས་…", color = TibetanColors.CreamDim, fontSize = 16.sp, fontFamily = LocalTibetanFont.current)
+ }
+ BasicTextField(
+ value = text,
+ onValueChange = { text = it },
+ enabled = enabled,
+ singleLine = true,
+ textStyle = TextStyle(color = TibetanColors.Cream, fontSize = 16.sp, fontFamily = LocalTibetanFont.current),
+ cursorBrush = SolidColor(TibetanColors.Gold300),
+ keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = ImeAction.Send),
+ keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSend = { submit() }),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ Box(
+ modifier = Modifier
+ .size(46.dp)
+ .clip(CircleShape)
+ .background(if (canSend) TibetanTokens.GoldVertical else SolidColor(TibetanColors.Brown600))
+ .clickable(enabled = canSend) { submit() },
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ AppIcons.Send,
+ stringResource(R.string.cd_send),
+ tint = if (canSend) TibetanColors.Espresso else TibetanColors.CreamDim,
+ modifier = Modifier.size(21.dp),
+ )
+ }
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(top = 5.dp, end = 4.dp),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ CharCounter(current = text.length)
+ }
+ }
+}
+
+private fun timeOf(date: Date): String =
+ SimpleDateFormat("h:mm a", Locale.getDefault()).format(date)
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/chat/ChatViewModel.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/ChatViewModel.kt
similarity index 65%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/chat/ChatViewModel.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/ChatViewModel.kt
index 3c975d3..3daebb2 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/chat/ChatViewModel.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/ChatViewModel.kt
@@ -1,11 +1,17 @@
-package com.kharagedition.tibetankeyboard.chat
+package com.kharagedition.tibetankeyboard.ui.chat
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
-import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.kharagedition.tibetankeyboard.analytics.AppAnalytics
+import com.kharagedition.tibetankeyboard.data.model.ChatMessage
+import com.kharagedition.tibetankeyboard.data.repository.ChatRepository
+import com.kharagedition.tibetankeyboard.ui.settings.SettingsPrefs
import kotlinx.coroutines.launch
-class ChatViewModel : ViewModel() {
+
+class ChatViewModel(app: Application) : AndroidViewModel(app) {
private val repository = ChatRepository()
@@ -15,6 +21,16 @@ class ChatViewModel : ViewModel() {
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData = _isLoading
+ /** Currently selected Claude model (persisted in the shared prefs). */
+ private val _model = MutableLiveData(SettingsPrefs.readModel(app))
+ val model: LiveData = _model
+
+ fun setModel(value: String) {
+ SettingsPrefs.putString(getApplication(), SettingsPrefs.KEY_AI_MODEL, value)
+ _model.value = value
+ AppAnalytics.logChatModelChanged(value)
+ }
+
fun sendMessage(messageText: String, userId: String) {
if (messageText.isBlank()) return
@@ -31,11 +47,17 @@ class ChatViewModel : ViewModel() {
// Set loading state to true when waiting for response
_isLoading.value = true
- // Start a coroutine to simulate an AI response
+ val model = _model.value ?: SettingsPrefs.DEFAULT_MODEL
+ AppAnalytics.logChatMessageSent(model)
+
+ // Start a coroutine to fetch the AI response
viewModelScope.launch {
try {
- // Simulate an AI response
- val response = repository.sendMessage(messageText,userId)
+ val response = repository.sendMessage(
+ messageText,
+ userId,
+ model
+ )
// Add assistant message to the list
val updatedList = _messages.value.orEmpty().toMutableList()
@@ -46,8 +68,10 @@ class ChatViewModel : ViewModel() {
)
)
_messages.value = updatedList
+ AppAnalytics.logChatResponseReceived(model, success = true)
} catch (e: Exception) {
+ AppAnalytics.logChatResponseReceived(model, success = false)
// In case of an error, add a predefined error message
val updatedList = _messages.value.orEmpty().toMutableList()
updatedList.add(
@@ -75,12 +99,11 @@ class ChatViewModel : ViewModel() {
}
}
- fun clearMessages() {
+ fun clearMessages() {
viewModelScope.launch {
- repository.resetChat();
+ repository.resetChat()
_messages.value = emptyList()
-
+ AppAnalytics.logChatCleared()
}
-
}
}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/chat/DocumentUploadDialog.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/DocumentUploadDialog.kt
similarity index 99%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/chat/DocumentUploadDialog.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/DocumentUploadDialog.kt
index ec31b05..930defe 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/chat/DocumentUploadDialog.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/DocumentUploadDialog.kt
@@ -1,4 +1,4 @@
-package com.kharagedition.tibetankeyboard.chat
+package com.kharagedition.tibetankeyboard.ui.chat
import android.net.Uri
import android.os.Bundle
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/chat/TutoringModePanel.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/TutoringModePanel.kt
similarity index 98%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/chat/TutoringModePanel.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/TutoringModePanel.kt
index 8675b47..0d7e3de 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/chat/TutoringModePanel.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/chat/TutoringModePanel.kt
@@ -1,4 +1,4 @@
-package com.kharagedition.tibetankeyboard.chat
+package com.kharagedition.tibetankeyboard.ui.chat
import android.content.Context
import android.util.AttributeSet
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/components/AppIcons.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/components/AppIcons.kt
new file mode 100644
index 0000000..1ff2949
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/components/AppIcons.kt
@@ -0,0 +1,67 @@
+package com.kharagedition.tibetankeyboard.ui.compose.components
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.automirrored.rounded.Logout
+import androidx.compose.material.icons.automirrored.rounded.Send
+import androidx.compose.material.icons.rounded.AutoAwesome
+import androidx.compose.material.icons.rounded.Block
+import androidx.compose.material.icons.rounded.Check
+import androidx.compose.material.icons.rounded.ChevronRight
+import androidx.compose.material.icons.rounded.ContentCopy
+import androidx.compose.material.icons.rounded.Download
+import androidx.compose.material.icons.rounded.EmojiEmotions
+import androidx.compose.material.icons.rounded.ExpandMore
+import androidx.compose.material.icons.rounded.Image
+import androidx.compose.material.icons.rounded.Language
+import androidx.compose.material.icons.rounded.Lock
+import androidx.compose.material.icons.rounded.MoreHoriz
+import androidx.compose.material.icons.rounded.Notifications
+import androidx.compose.material.icons.rounded.Palette
+import androidx.compose.material.icons.rounded.SwapHoriz
+import androidx.compose.material.icons.rounded.Settings
+import androidx.compose.material.icons.rounded.Share
+import androidx.compose.material.icons.rounded.SmartToy
+import androidx.compose.material.icons.rounded.Spa
+import androidx.compose.material.icons.rounded.Spellcheck
+import androidx.compose.material.icons.rounded.Star
+import androidx.compose.material.icons.rounded.Translate
+import androidx.compose.material.icons.rounded.Vibration
+import androidx.compose.material.icons.rounded.VolumeUp
+import androidx.compose.material.icons.rounded.WorkspacePremium
+import androidx.compose.ui.graphics.vector.ImageVector
+
+/**
+ * Maps the design's named icons (from the Claude Design handoff) to Material icons,
+ * so screens can stay readable: `AppIcons.Bot`, `AppIcons.Crown`, etc.
+ */
+object AppIcons {
+ val Back: ImageVector = Icons.AutoMirrored.Rounded.ArrowBack
+ val Chevron: ImageVector = Icons.Rounded.ChevronRight
+ val Lotus: ImageVector = Icons.Rounded.Spa
+ val Check: ImageVector = Icons.Rounded.Check
+ val Settings: ImageVector = Icons.Rounded.Settings
+ val Palette: ImageVector = Icons.Rounded.Palette
+ val Share: ImageVector = Icons.Rounded.Share
+ val Star: ImageVector = Icons.Rounded.Star
+ val More: ImageVector = Icons.Rounded.MoreHoriz
+ val Crown: ImageVector = Icons.Rounded.WorkspacePremium
+ val Bot: ImageVector = Icons.Rounded.SmartToy
+ val Sparkle: ImageVector = Icons.Rounded.AutoAwesome
+ val Translate: ImageVector = Icons.Rounded.Translate
+ val Spell: ImageVector = Icons.Rounded.Spellcheck
+ val NoAds: ImageVector = Icons.Rounded.Block
+ val Image: ImageVector = Icons.Rounded.Image
+ val Vibrate: ImageVector = Icons.Rounded.Vibration
+ val Sound: ImageVector = Icons.Rounded.VolumeUp
+ val Bell: ImageVector = Icons.Rounded.Notifications
+ val Logout: ImageVector = Icons.AutoMirrored.Rounded.Logout
+ val Copy: ImageVector = Icons.Rounded.ContentCopy
+ val Send: ImageVector = Icons.AutoMirrored.Rounded.Send
+ val Globe: ImageVector = Icons.Rounded.Language
+ val Emoji: ImageVector = Icons.Rounded.EmojiEmotions
+ val Download: ImageVector = Icons.Rounded.Download
+ val Lock: ImageVector = Icons.Rounded.Lock
+ val ExpandMore: ImageVector = Icons.Rounded.ExpandMore
+ val SwapHoriz: ImageVector = Icons.Rounded.SwapHoriz
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/components/Components.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/components/Components.kt
new file mode 100644
index 0000000..345b7f8
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/components/Components.kt
@@ -0,0 +1,238 @@
+package com.kharagedition.tibetankeyboard.ui.compose.components
+
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.kharagedition.tibetankeyboard.ui.compose.theme.LocalTibetanFont
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanColors
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanTokens
+import com.kharagedition.tibetankeyboard.util.AiLimits
+
+/** Tibetan-script text using the bundled Uchen font. */
+@Composable
+fun BoText(
+ text: String,
+ modifier: Modifier = Modifier,
+ color: Color = TibetanColors.Cream,
+ fontSize: androidx.compose.ui.unit.TextUnit = 16.sp,
+ fontWeight: FontWeight = FontWeight.Normal,
+ lineHeight: androidx.compose.ui.unit.TextUnit = androidx.compose.ui.unit.TextUnit.Unspecified,
+ maxLines: Int = Int.MAX_VALUE,
+) {
+ Text(
+ text = text,
+ modifier = modifier,
+ color = color,
+ fontSize = fontSize,
+ fontWeight = fontWeight,
+ lineHeight = lineHeight,
+ fontFamily = LocalTibetanFont.current,
+ maxLines = maxLines,
+ overflow = TextOverflow.Ellipsis,
+ )
+}
+
+/** Rounded-square icon chip used across rows and grids. */
+@Composable
+fun IconTile(
+ icon: ImageVector,
+ modifier: Modifier = Modifier,
+ size: Dp = 44.dp,
+ iconSize: Dp = 22.dp,
+ background: Color = TibetanColors.Brown600,
+ tint: Color = TibetanColors.Gold300,
+) {
+ Box(
+ modifier = modifier
+ .size(size)
+ .clip(RoundedCornerShape(13.dp))
+ .background(background),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(iconSize))
+ }
+}
+
+/** The gold gradient pill toggle from the design. Purely visual; caller owns state. */
+@Composable
+fun GoldToggle(
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val knobAlign by animateDpAsState(if (checked) 22.dp else 3.dp, label = "knob")
+ Box(
+ modifier = modifier
+ .size(width = 46.dp, height = 27.dp)
+ .clip(TibetanTokens.Pill)
+ .then(
+ if (checked) Modifier.background(TibetanTokens.GoldVertical)
+ else Modifier
+ .background(TibetanColors.Brown700)
+ .border(1.dp, TibetanColors.Line2, TibetanTokens.Pill)
+ )
+ .clickable { onCheckedChange(!checked) },
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ Box(
+ modifier = Modifier
+ .padding(start = knobAlign)
+ .size(21.dp)
+ .clip(CircleShape)
+ .background(if (checked) TibetanColors.Espresso else TibetanColors.CreamDim)
+ )
+ }
+}
+
+/** Uppercase section label (gold or cream-dim variants). */
+@Composable
+fun SectionLabel(
+ text: String,
+ modifier: Modifier = Modifier,
+ color: Color = TibetanColors.Gold300,
+) {
+ Text(
+ text = text.uppercase(),
+ modifier = modifier,
+ color = color,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 0.7.sp,
+ )
+}
+
+/**
+ * Live `current / max` character counter for AI inputs (chat & translate).
+ *
+ * Always visible so the user sees the per-request cap
+ * ([AiLimits.MAX_INPUT_CHARS]) and watches it update as they type. Turns
+ * warning-red once over the limit so the over-cap state reads at a glance.
+ */
+@Composable
+fun CharCounter(
+ current: Int,
+ modifier: Modifier = Modifier,
+ max: Int = AiLimits.MAX_INPUT_CHARS,
+) {
+ val over = current > max
+ Text(
+ text = "$current / $max",
+ modifier = modifier,
+ color = if (over) TibetanColors.MaroonSoft else TibetanColors.CreamDim,
+ fontSize = 11.5.sp,
+ fontWeight = if (over) FontWeight.Bold else FontWeight.Normal,
+ )
+}
+
+/** Small rounded badge, e.g. "PRO". */
+@Composable
+fun PillBadge(
+ text: String,
+ modifier: Modifier = Modifier,
+ background: Brush = TibetanTokens.GoldVertical,
+ textColor: Color = TibetanColors.Espresso,
+) {
+ Box(
+ modifier = modifier
+ .clip(TibetanTokens.Pill)
+ .background(background)
+ .padding(horizontal = 9.dp, vertical = 3.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(text, color = textColor, fontSize = 10.5.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = 0.4.sp)
+ }
+}
+
+/** Full-width gold CTA button. */
+@Composable
+fun GoldButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ brush: Brush = TibetanTokens.GoldVerticalBright,
+ fontSize: androidx.compose.ui.unit.TextUnit = 16.sp,
+) {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(15.dp))
+ .background(brush)
+ .clickable(onClick = onClick)
+ .padding(vertical = 15.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(text, color = TibetanColors.Espresso, fontSize = fontSize, fontWeight = FontWeight.ExtraBold)
+ }
+}
+
+/** Brush-tinted (gold gradient) text — the design's `.text-gold` helper. */
+fun goldTextStyle(base: TextStyle = TextStyle.Default): TextStyle =
+ base.copy(brush = TibetanTokens.GoldText)
+
+@Suppress("unused")
+val solidGoldBrush: Brush = SolidColor(TibetanColors.Gold300)
+
+/** Header row with back arrow + title used by sub-screens. */
+@Composable
+fun BackHeader(
+ title: String,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+ titleColor: Color = TibetanColors.Cream,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(start = 12.dp, end = 18.dp, top = 6.dp, bottom = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ AppIcons.Back,
+ contentDescription = "Back",
+ tint = titleColor,
+ modifier = Modifier
+ .clip(CircleShape)
+ .clickable(onClick = onBack)
+ .padding(4.dp)
+ .size(23.dp),
+ )
+ Text(title, color = titleColor, fontSize = 19.sp, fontWeight = FontWeight.Bold)
+ }
+}
+
+/** Fills the screen with the warm background colour. */
+@Composable
+fun ScreenBackground(
+ color: Color = TibetanColors.Bg900,
+ content: @Composable () -> Unit,
+) {
+ Box(modifier = Modifier.fillMaxSize().background(color)) { content() }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/components/ModelSelector.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/components/ModelSelector.kt
new file mode 100644
index 0000000..dd61249
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/components/ModelSelector.kt
@@ -0,0 +1,83 @@
+package com.kharagedition.tibetankeyboard.ui.compose.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanColors
+import com.kharagedition.tibetankeyboard.ui.settings.PrefOption
+import com.kharagedition.tibetankeyboard.ui.settings.SettingsPrefs
+
+/**
+ * Compact pill that lets the user switch the active AI option.
+ * - Chat uses the defaults (Claude Haiku / Sonnet model).
+ * - Translate passes the translation engine options (Azure / Claude Haiku / Sonnet).
+ */
+@Composable
+fun ModelSelector(
+ model: String,
+ onModelChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ options: List = SettingsPrefs.modelOptions,
+ labelOf: (String) -> String = SettingsPrefs::modelLabel,
+) {
+ var open by remember { mutableStateOf(false) }
+
+ Row(
+ modifier = modifier
+ .clip(RoundedCornerShape(999.dp))
+ .background(TibetanColors.Brown600)
+ .border(1.dp, TibetanColors.Line, RoundedCornerShape(999.dp))
+ .clickable { open = true }
+ .padding(start = 12.dp, end = 8.dp, top = 7.dp, bottom = 7.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(5.dp),
+ ) {
+ Icon(AppIcons.Sparkle, null, tint = TibetanColors.Gold300, modifier = Modifier.size(15.dp))
+ Text(
+ labelOf(model),
+ color = TibetanColors.Cream,
+ fontSize = 12.5.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Icon(AppIcons.ExpandMore, null, tint = TibetanColors.CreamDim, modifier = Modifier.size(16.dp))
+
+ DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
+ options.forEach { opt ->
+ DropdownMenuItem(
+ text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(opt.label, fontWeight = FontWeight.SemiBold)
+ if (opt.value == model) {
+ Icon(AppIcons.Check, null, tint = TibetanColors.Gold300, modifier = Modifier.size(16.dp))
+ }
+ }
+ },
+ onClick = { open = false; onModelChange(opt.value) },
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/components/ScreenScaffold.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/components/ScreenScaffold.kt
new file mode 100644
index 0000000..3d6168f
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/components/ScreenScaffold.kt
@@ -0,0 +1,44 @@
+package com.kharagedition.tibetankeyboard.ui.compose.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanColors
+
+/**
+ * Standard screen container: full-bleed warm background behind the system bars, with
+ * content inset below the status bar / above the navigation bar and (optionally) scrollable.
+ * Removes the repeated `fillMaxSize + background + insets + verticalScroll` boilerplate.
+ */
+@Composable
+fun ScreenScaffold(
+ modifier: Modifier = Modifier,
+ background: Color = TibetanColors.Bg900,
+ scrollable: Boolean = true,
+ horizontalPadding: Dp = 0.dp,
+ bottomPadding: Dp = 24.dp,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ val base = modifier
+ .fillMaxSize()
+ .background(background)
+ .statusBarsPadding()
+ .navigationBarsPadding()
+ Column(
+ modifier = (if (scrollable) base.verticalScroll(rememberScrollState()) else base)
+ .padding(horizontal = horizontalPadding)
+ .padding(bottom = bottomPadding),
+ content = content,
+ )
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/components/TapTargetPrompt.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/components/TapTargetPrompt.kt
new file mode 100644
index 0000000..9b553ab
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/components/TapTargetPrompt.kt
@@ -0,0 +1,225 @@
+package com.kharagedition.tibetankeyboard.ui.compose.components
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.CompositingStrategy
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanColors
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanTokens
+import kotlin.math.roundToInt
+
+/**
+ * A Compose-native "Material tap target" coach-mark. Dims the screen, punches a rounded
+ * spotlight around [targetBounds] (root coordinates, px), pulses a gold ripple to draw the
+ * eye, and floats a description bubble next to it — styled with the warm brown/gold tokens.
+ *
+ * Tapping the spotlight runs [onTargetClick] (the real action); tapping the dimmed area or
+ * "Got it" runs [onDismiss]. Render it as the last child of a full-screen [Box] so it overlays
+ * everything and shares the same root coordinate space as the highlighted target.
+ */
+@Composable
+fun TapTargetPrompt(
+ targetBounds: Rect,
+ title: String,
+ description: String,
+ hint: String,
+ dismissLabel: String,
+ onTargetClick: () -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+ icon: ImageVector? = null,
+) {
+ val density = LocalDensity.current
+ val pad = with(density) { 9.dp.toPx() }
+ val cornerPx = with(density) { 18.dp.toPx() }
+
+ // Rounded spotlight rect around the target (px, root space).
+ val holeLeft = targetBounds.left - pad
+ val holeTop = targetBounds.top - pad
+ val holeW = targetBounds.width + pad * 2
+ val holeH = targetBounds.height + pad * 2
+ val holeBottom = holeTop + holeH
+
+ // Entry fade for the scrim + bubble.
+ val appear = remember { Animatable(0f) }
+ LaunchedEffect(Unit) { appear.animateTo(1f, tween(240)) }
+
+ // Pulsing ripple around the spotlight.
+ val transition = rememberInfiniteTransition(label = "tapTarget")
+ val pulse by transition.animateFloat(
+ initialValue = 0f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(tween(1500, easing = FastOutSlowInEasing), RepeatMode.Restart),
+ label = "pulse",
+ )
+ val ringExpand = with(density) { (pulse * 13.dp.toPx()) }
+ val ringAlpha = (1f - pulse) * 0.55f
+
+ BoxWithConstraints(modifier = modifier.fillMaxSize()) {
+ val noRipple = remember { MutableInteractionSource() }
+
+ // Outside the spotlight: tap to dismiss (transparent, catches every stray tap).
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .clickable(interactionSource = noRipple, indication = null, onClick = onDismiss),
+ )
+
+ // Scrim with a punched-out rounded spotlight + pulsing gold ring.
+ Canvas(
+ modifier = Modifier
+ .fillMaxSize()
+ .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen),
+ ) {
+ drawRect(color = TibetanColors.Espresso.copy(alpha = 0.84f * appear.value))
+ drawRoundRect(
+ color = Color.Black,
+ topLeft = Offset(holeLeft, holeTop),
+ size = Size(holeW, holeH),
+ cornerRadius = CornerRadius(cornerPx, cornerPx),
+ blendMode = BlendMode.Clear,
+ )
+ // Crisp gold edge around the spotlight.
+ drawRoundRect(
+ color = TibetanColors.Gold300.copy(alpha = 0.9f * appear.value),
+ topLeft = Offset(holeLeft, holeTop),
+ size = Size(holeW, holeH),
+ cornerRadius = CornerRadius(cornerPx, cornerPx),
+ style = Stroke(width = with(density) { 2.dp.toPx() }),
+ )
+ // Expanding ripple.
+ drawRoundRect(
+ color = TibetanColors.Gold300.copy(alpha = ringAlpha),
+ topLeft = Offset(holeLeft - ringExpand, holeTop - ringExpand),
+ size = Size(holeW + ringExpand * 2, holeH + ringExpand * 2),
+ cornerRadius = CornerRadius(cornerPx + ringExpand, cornerPx + ringExpand),
+ style = Stroke(width = with(density) { 2.dp.toPx() }),
+ )
+ }
+
+ // Invisible click target sitting exactly over the spotlight -> runs the real action.
+ Box(
+ modifier = Modifier
+ .offset { IntOffset(holeLeft.roundToInt(), holeTop.roundToInt()) }
+ .size(with(density) { holeW.toDp() }, with(density) { holeH.toDp() })
+ .clip(RoundedCornerShape(18.dp))
+ .clickable(interactionSource = noRipple, indication = null, onClick = onTargetClick),
+ )
+
+ // Float the description bubble below the spotlight when it sits in the top half,
+ // otherwise above it. Anchoring to the parent edge avoids measuring the bubble.
+ val gap = with(density) { 14.dp.toPx() }
+ val maxH = constraints.maxHeight
+ val placeBelow = targetBounds.center.y < maxH / 2f
+ val bubbleAlign = if (placeBelow) Alignment.TopStart else Alignment.BottomStart
+
+ Column(
+ modifier = Modifier
+ .align(bubbleAlign)
+ .offset {
+ if (placeBelow) IntOffset(0, (holeBottom + gap).roundToInt())
+ else IntOffset(0, -((maxH - holeTop) + gap).roundToInt())
+ }
+ .fillMaxWidth()
+ .padding(horizontal = 22.dp)
+ .graphicsLayer { alpha = appear.value }
+ .clip(TibetanTokens.RadiusCard)
+ .background(TibetanColors.Brown600)
+ .clickable(interactionSource = noRipple, indication = null, onClick = {})
+ .padding(18.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(11.dp),
+ ) {
+ if (icon != null) {
+ IconTile(icon, size = 38.dp, iconSize = 20.dp)
+ }
+ Text(
+ title,
+ color = TibetanColors.Cream,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ Spacer(Modifier.height(9.dp))
+ Text(
+ description,
+ color = TibetanColors.Cream2,
+ fontSize = 13.5.sp,
+ lineHeight = 19.sp,
+ )
+ Spacer(Modifier.height(14.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ hint,
+ color = TibetanColors.Gold300,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.weight(1f),
+ )
+ Box(
+ modifier = Modifier
+ .clip(TibetanTokens.Pill)
+ .background(TibetanTokens.GoldVertical)
+ .clickable(onClick = onDismiss)
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ ) {
+ Text(
+ dismissLabel,
+ color = TibetanColors.Espresso,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/theme/Color.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/theme/Color.kt
new file mode 100644
index 0000000..8dc9a14
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/theme/Color.kt
@@ -0,0 +1,42 @@
+package com.kharagedition.tibetankeyboard.ui.compose.theme
+
+import androidx.compose.ui.graphics.Color
+
+/**
+ * Warm brown/gold "monk-robe" palette.
+ * Mirrors docs/tibetan-keyboard/project/tokens.css 1:1 so the Compose UI matches
+ * the Claude Design handoff exactly.
+ */
+object TibetanColors {
+ // Brown surface ramp (deep espresso -> warm brown)
+ val Espresso = Color(0xFF1C1305)
+ val Bg900 = Color(0xFF241808)
+ val Bg800 = Color(0xFF2E2009)
+ val Brown700 = Color(0xFF3C2B0E)
+ val Brown600 = Color(0xFF4D3812)
+ val Brown500 = Color(0xFF634916) // the signature brand brown
+ val Brown400 = Color(0xFF7A5C1F)
+ val Brown300 = Color(0xFF94732C)
+
+ // Gold / saffron accent (monk-robe gold)
+ val Gold500 = Color(0xFFC8881F)
+ val Gold400 = Color(0xFFE3A53A)
+ val Gold300 = Color(0xFFF2C264)
+ val Gold200 = Color(0xFFF7D899)
+
+ // Maroon — secondary Tibetan robe tone, used sparingly
+ val Maroon = Color(0xFF8E2E27)
+ val MaroonSoft = Color(0xFFB24A3F)
+ val MaroonDeep = Color(0xFF5C1D18)
+
+ // Text on warm surfaces
+ val Cream = Color(0xFFFCF5E8)
+ val Cream2 = Color(0xFFEBDCC0)
+ val CreamDim = Color(0xFFC7B284)
+ val CreamFaint = Color(0xFFFCF5E8).copy(alpha = 0.55f)
+ val Line = Color(0xFFFCF5E8).copy(alpha = 0.10f)
+ val Line2 = Color(0xFFFCF5E8).copy(alpha = 0.16f)
+
+ // Success / live
+ val Jade = Color(0xFF5BB98C)
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/theme/Theme.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/theme/Theme.kt
new file mode 100644
index 0000000..79db302
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/theme/Theme.kt
@@ -0,0 +1,44 @@
+package com.kharagedition.tibetankeyboard.ui.compose.theme
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontFamily
+
+/** Provides the bundled Tibetan Uchen [FontFamily] down the tree. */
+val LocalTibetanFont = staticCompositionLocalOf { FontFamily.Default }
+
+private val TibetanColorScheme = darkColorScheme(
+ primary = TibetanColors.Gold400,
+ onPrimary = TibetanColors.Espresso,
+ secondary = TibetanColors.Brown500,
+ onSecondary = TibetanColors.Cream,
+ background = TibetanColors.Bg900,
+ onBackground = TibetanColors.Cream,
+ surface = TibetanColors.Brown700,
+ onSurface = TibetanColors.Cream,
+ surfaceVariant = TibetanColors.Brown600,
+ onSurfaceVariant = TibetanColors.Cream2,
+ error = TibetanColors.MaroonSoft,
+)
+
+/**
+ * App-wide Compose theme. Always the warm dark-brown scheme (the design is a
+ * single premium dark-warm look, independent of system light/dark).
+ */
+@Composable
+fun TibetanKeyboardTheme(content: @Composable () -> Unit) {
+ val context = LocalContext.current
+ CompositionLocalProvider(
+ LocalTibetanFont provides TibetanType.tibetan(context)
+ ) {
+ MaterialTheme(
+ colorScheme = TibetanColorScheme,
+ typography = TibetanTypography,
+ content = content,
+ )
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/theme/Tokens.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/theme/Tokens.kt
new file mode 100644
index 0000000..2f50b78
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/theme/Tokens.kt
@@ -0,0 +1,48 @@
+package com.kharagedition.tibetankeyboard.ui.compose.theme
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.unit.dp
+
+/**
+ * Radii, gradients and elevation tokens mirroring tokens.css.
+ * Gradients are exposed as factory functions because Compose [Brush]es are not const.
+ */
+object TibetanTokens {
+ // Radii
+ val RadiusKey = RoundedCornerShape(10.dp)
+ val RadiusCard = RoundedCornerShape(20.dp)
+ val Pill = RoundedCornerShape(999.dp)
+
+ // --grad-gold: linear-gradient(135deg, gold-300 0%, gold-400 48%, gold-500 100%)
+ val GradGold = Brush.linearGradient(
+ colorStops = arrayOf(
+ 0f to TibetanColors.Gold300,
+ 0.48f to TibetanColors.Gold400,
+ 1f to TibetanColors.Gold500,
+ )
+ )
+
+ // The 180deg gold gradient used on pills, toggles, keys: gold-300 -> gold-500
+ val GoldVertical = Brush.verticalGradient(
+ listOf(TibetanColors.Gold300, TibetanColors.Gold500)
+ )
+
+ // Brighter gold for CTAs: gold-200 -> gold-500
+ val GoldVerticalBright = Brush.verticalGradient(
+ listOf(TibetanColors.Gold200, TibetanColors.Gold500)
+ )
+
+ // text-gold: linear-gradient(135deg, gold-200, gold-400)
+ val GoldText = Brush.linearGradient(
+ listOf(TibetanColors.Gold200, TibetanColors.Gold400)
+ )
+
+ // --grad-brown: linear-gradient(170deg, brown-600 0%, bg-800 100%)
+ val GradBrown = Brush.linearGradient(
+ colors = listOf(TibetanColors.Brown600, TibetanColors.Bg800),
+ start = Offset(0f, 0f),
+ end = Offset(0.17f, 1f),
+ )
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/theme/Type.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/theme/Type.kt
new file mode 100644
index 0000000..771cddd
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/compose/theme/Type.kt
@@ -0,0 +1,42 @@
+package com.kharagedition.tibetankeyboard.ui.compose.theme
+
+import android.content.Context
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.font.FontFamily
+
+/**
+ * Typography.
+ *
+ * The design calls for "Plus Jakarta Sans" for UI text and "Noto Sans Tibetan"
+ * for Tibetan script. We use the system sans family for UI (always available,
+ * no network download) and the bundled Uchen font (assets/fonts) for Tibetan
+ * script, which renders the རྒྱ Uchen style the design intends.
+ */
+object TibetanType {
+ /** UI font — system sans (stands in for Plus Jakarta Sans). */
+ val Ui: FontFamily = FontFamily.Default
+
+ /**
+ * Tibetan font. We use the platform default family: Android's Tibetan fallback is
+ * Noto Sans Tibetan, a clean Uchen (དབུ་ཅན — block-letter) face. This guarantees
+ * Uchen rendering, matches the IME (which also uses the system font), and avoids
+ * bundling a font (the previously bundled Uchen TTF was empty/corrupt).
+ */
+ @Suppress("UNUSED_PARAMETER")
+ fun tibetan(context: Context): FontFamily = FontFamily.Default
+}
+
+/** Material3 typography built on the UI font; per-screen sizes are set inline. */
+val TibetanTypography = Typography().run {
+ Typography(
+ displayLarge = displayLarge.copy(fontFamily = TibetanType.Ui),
+ headlineLarge = headlineLarge.copy(fontFamily = TibetanType.Ui),
+ headlineMedium = headlineMedium.copy(fontFamily = TibetanType.Ui),
+ titleLarge = titleLarge.copy(fontFamily = TibetanType.Ui),
+ titleMedium = titleMedium.copy(fontFamily = TibetanType.Ui),
+ bodyLarge = bodyLarge.copy(fontFamily = TibetanType.Ui),
+ bodyMedium = bodyMedium.copy(fontFamily = TibetanType.Ui),
+ labelLarge = labelLarge.copy(fontFamily = TibetanType.Ui),
+ labelMedium = labelMedium.copy(fontFamily = TibetanType.Ui),
+ )
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeActivity.kt
new file mode 100644
index 0000000..26db811
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeActivity.kt
@@ -0,0 +1,320 @@
+package com.kharagedition.tibetankeyboard.ui.home
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.provider.Settings
+import android.util.Log
+import android.view.inputmethod.InputMethodManager
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.google.android.gms.ads.AdListener
+import com.google.android.gms.ads.AdLoader
+import com.google.android.gms.ads.AdRequest
+import com.google.android.gms.ads.LoadAdError
+import com.google.android.gms.ads.nativead.NativeAd
+import com.google.android.gms.ads.nativead.NativeAdOptions
+import com.google.firebase.messaging.FirebaseMessaging
+import com.kharagedition.tibetankeyboard.BuildConfig
+import com.kharagedition.tibetankeyboard.InAppUpdateManager
+import com.kharagedition.tibetankeyboard.R
+import com.kharagedition.tibetankeyboard.analytics.AppAnalytics
+import com.kharagedition.tibetankeyboard.ads.NativeTemplateStyle
+import com.kharagedition.tibetankeyboard.ads.TemplateView
+import com.kharagedition.tibetankeyboard.app.InputMethodActivity
+import com.kharagedition.tibetankeyboard.data.repository.subscriptionCallback
+import com.kharagedition.tibetankeyboard.service.MyFirebaseMessagingService
+import com.kharagedition.tibetankeyboard.ui.about.AboutActivity
+import com.kharagedition.tibetankeyboard.ui.chat.ChatActivity
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanKeyboardTheme
+import com.kharagedition.tibetankeyboard.ui.settings.SettingsActivity
+import com.kharagedition.tibetankeyboard.ui.translate.TranslateActivity
+import com.kharagedition.tibetankeyboard.util.AppConstant
+import com.kharagedition.tibetankeyboard.util.CommonUtils
+import com.kharagedition.tibetankeyboard.util.openPremiumUpgrade
+import kotlinx.coroutines.launch
+
+/**
+ * Home screen. Holds only framework glue — InputMethodManager queries, native-ad Views,
+ * notifications/FCM and navigation. All UI state lives in [HomeViewModel].
+ */
+class HomeActivity : InputMethodActivity() {
+
+ private val viewModel: HomeViewModel by viewModels()
+ private var nativeAd by mutableStateOf(null)
+ private var showUpdateBanner by mutableStateOf(false)
+
+ private lateinit var inAppUpdateManager: InAppUpdateManager
+
+ // Registered before onCreate — handles both flexible and immediate update flow results.
+ private val updateLauncher = registerForActivityResult(
+ ActivityResultContracts.StartIntentSenderForResult()
+ ) { result ->
+ if (result.resultCode != RESULT_OK) {
+ Log.w(TAG, "In-app update flow ended with result: ${result.resultCode}")
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ inAppUpdateManager = InAppUpdateManager(this)
+
+ if (viewModel.isUserAuthenticated()) {
+ viewModel.initializeUserSession(subscriptionCallback(
+ onSuccess = { Log.d(TAG, "✅ RevenueCat initialized: $it") },
+ onError = { Log.e(TAG, "❌ RevenueCat init failed: $it") },
+ ))
+ }
+
+ setContent {
+ TibetanKeyboardTheme {
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ val ad = nativeAd
+ HomeScreen(
+ state = state,
+ actions = homeActions(),
+ adSlot = if (ad != null) { { NativeAdCard(ad) } } else null,
+ showUpdateBanner = showUpdateBanner,
+ onInstallUpdate = {
+ Log.d(UPDATE_TAG, "onInstallUpdate: user tapped install banner → completing update")
+ showUpdateBanner = false
+ if (BuildConfig.DEBUG) {
+ Log.d(UPDATE_TAG, "onInstallUpdate: DEBUG — no real APK downloaded, skipping completeUpdate(). Production would restart the app here.")
+ android.widget.Toast.makeText(this, "DEBUG: Update flow OK — production would restart & install here", android.widget.Toast.LENGTH_LONG).show()
+ } else {
+ inAppUpdateManager.completeFlexibleUpdate()
+ }
+ },
+ )
+ }
+ }
+
+ observeAdGating()
+ refreshSetupState()
+ requestNotificationPermission()
+ subscribeToAllUsersTopic()
+ initializeFirebase()
+
+ val forceImmediate = intent?.getBooleanExtra(MyFirebaseMessagingService.EXTRA_FORCE_UPDATE, false) == true
+ triggerUpdateCheck(forceImmediate)
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ if (intent.getBooleanExtra(MyFirebaseMessagingService.EXTRA_FORCE_UPDATE, false)) {
+ triggerUpdateCheck(forceImmediate = true)
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ refreshSetupState()
+ // CRITICAL: sync purchases on resume to acknowledge pending subscriptions
+ // (prevents Google Play auto-cancelling after 3 days).
+ if (viewModel.isUserAuthenticated()) {
+ viewModel.syncPurchases(subscriptionCallback(
+ onError = { Log.w(TAG, "⚠️ Sync failed in onResume: $it") },
+ ))
+ }
+ viewModel.refreshPremium()
+ // Show the install banner if a flexible update finished downloading while we were away.
+ Log.d(UPDATE_TAG, "onResume: checking if flexible update already downloaded")
+ inAppUpdateManager.checkIfUpdateDownloaded {
+ Log.d(UPDATE_TAG, "onResume: update already downloaded → showing install banner")
+ showUpdateBanner = true
+ }
+ }
+
+ override fun onInputMethodPicked() = refreshSetupState()
+
+ private fun triggerUpdateCheck(forceImmediate: Boolean = false) {
+ Log.d(UPDATE_TAG, "triggerUpdateCheck: forceImmediate=$forceImmediate")
+ inAppUpdateManager.checkForUpdate(
+ launcher = updateLauncher,
+ forceImmediate = forceImmediate,
+ onFlexibleDownloadComplete = {
+ Log.d(UPDATE_TAG, "onFlexibleDownloadComplete → showing install banner")
+ showUpdateBanner = true
+ },
+ )
+ }
+
+ private fun homeActions() = HomeActions(
+ onEnableKeyboard = {
+ AppAnalytics.logHomeAction(AppAnalytics.HomeAction.ENABLE_KEYBOARD)
+ AppAnalytics.logKeyboardEnableClicked()
+ startActivity(Intent(Settings.ACTION_INPUT_METHOD_SETTINGS).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ })
+ },
+ onPickInputMethod = {
+ AppAnalytics.logHomeAction(AppAnalytics.HomeAction.PICK_INPUT_METHOD)
+ pickInput()
+ },
+ onChat = {
+ AppAnalytics.logHomeAction(AppAnalytics.HomeAction.CHAT)
+ openIfPremiumOrUpgrade(ChatActivity::class.java)
+ },
+ onTranslate = {
+ AppAnalytics.logHomeAction(AppAnalytics.HomeAction.TRANSLATE)
+ openIfPremiumOrUpgrade(TranslateActivity::class.java)
+ },
+ onSettings = {
+ AppAnalytics.logHomeAction(AppAnalytics.HomeAction.SETTINGS)
+ startActivity(Intent(this, SettingsActivity::class.java))
+ },
+ onShare = {
+ AppAnalytics.logHomeAction(AppAnalytics.HomeAction.SHARE)
+ shareApp()
+ },
+ onRate = {
+ AppAnalytics.logHomeAction(AppAnalytics.HomeAction.RATE)
+ openView(CommonUtils.PLAY_STORE_URL)
+ },
+ onAbout = {
+ AppAnalytics.logHomeAction(AppAnalytics.HomeAction.ABOUT)
+ startActivity(Intent(this, AboutActivity::class.java))
+ },
+ onUpgrade = {
+ AppAnalytics.logHomeAction(AppAnalytics.HomeAction.UPGRADE)
+ openPremiumUpgrade(AppAnalytics.UpgradeSource.HOME)
+ },
+ )
+
+ /**
+ * PRO feature entry points. Subscribers get the screen. Signed-in free users get the
+ * paywall. Signed-out users can't be classified yet, so we open the screen — it routes
+ * them through login and back, after which a free user is gated on the first action.
+ */
+ private fun openIfPremiumOrUpgrade(target: Class<*>) {
+ if (viewModel.uiState.value.isPremium || !viewModel.isUserAuthenticated()) {
+ startActivity(Intent(this, target))
+ } else {
+ openPremiumUpgrade(AppAnalytics.UpgradeSource.HOME_FEATURE_GATE)
+ }
+ }
+
+ /** Loads/destroys the native ad as premium status changes (ad Views can't live in the VM). */
+ private fun observeAdGating() {
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.uiState.collect { state ->
+ if (state.isPremium) {
+ nativeAd?.destroy()
+ nativeAd = null
+ } else if (nativeAd == null) {
+ loadNativeAd()
+ }
+ }
+ }
+ }
+ }
+
+ /** Reads keyboard-enabled / default-IME status and forwards it to the ViewModel. */
+ private fun refreshSetupState() {
+ val im = applicationContext.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
+ val enabled = im.enabledInputMethodList.toString().contains(PACKAGE)
+ val isDefault = Settings.Secure.getString(
+ contentResolver, Settings.Secure.DEFAULT_INPUT_METHOD
+ )?.contains(PACKAGE) == true
+ viewModel.refreshSetup(enabled, enabled && isDefault)
+ AppAnalytics.setKeyboardSetup(enabled = enabled, isDefault = enabled && isDefault)
+ }
+
+ @SuppressLint("NewApi")
+ private fun loadNativeAd() {
+ val adUnitId = if (BuildConfig.DEBUG) AppConstant.TEST_ADS_NATIVE else AppConstant.PRODUCTION_ADS_NATIVE
+ AdLoader.Builder(this, adUnitId)
+ .forNativeAd { ad: NativeAd ->
+ if (isDestroyed) { ad.destroy(); return@forNativeAd }
+ nativeAd?.destroy()
+ nativeAd = ad
+ }
+ .withAdListener(object : AdListener() {
+ override fun onAdFailedToLoad(adError: LoadAdError) {
+ Log.e(TAG, "onAdFailedToLoad: ${adError.message}")
+ }
+ })
+ .withNativeAdOptions(NativeAdOptions.Builder().build())
+ .build()
+ .loadAd(AdRequest.Builder().build())
+ }
+
+ @Composable
+ private fun NativeAdCard(ad: NativeAd) {
+ AndroidView(
+ factory = { ctx ->
+ (android.view.LayoutInflater.from(ctx).inflate(R.layout.home_native_ad, null) as TemplateView)
+ .apply { setStyles(NativeTemplateStyle.Builder().build()) }
+ },
+ update = { it.setNativeAd(ad) },
+ )
+ }
+
+ private fun shareApp() {
+ try {
+ startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_SUBJECT, getString(R.string.app_name))
+ putExtra(Intent.EXTRA_TEXT, "\nCheck out this Keyboard Application.\n\n${CommonUtils.PLAY_STORE_URL}")
+ }, "choose one"))
+ } catch (_: Exception) {
+ }
+ }
+
+ private fun openView(url: String) = startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
+
+ private fun requestNotificationPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
+ ) {
+ ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 101)
+ }
+ }
+
+ private fun subscribeToAllUsersTopic() {
+ FirebaseMessaging.getInstance().subscribeToTopic("test-users")
+ .addOnCompleteListener { task ->
+ Log.d("FCM", if (task.isSuccessful) "Subscribed to all-users topic" else "Failed to subscribe")
+ }
+ }
+
+ private fun initializeFirebase() {
+ FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
+ if (!task.isSuccessful) {
+ Log.w("FCM", "Fetching FCM token failed", task.exception)
+ return@addOnCompleteListener
+ }
+ Log.d("FCM", "FCM Registration Token: ${task.result}")
+ }
+ }
+
+ override fun onDestroy() {
+ nativeAd?.destroy()
+ inAppUpdateManager.unregisterListener()
+ super.onDestroy()
+ }
+
+ companion object {
+ private const val TAG = "HomeActivity"
+ private const val UPDATE_TAG = "InAppUpdate"
+ private const val PACKAGE = "com.kharagedition.tibetankeyboard"
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeScreen.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeScreen.kt
new file mode 100644
index 0000000..55b39f5
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeScreen.kt
@@ -0,0 +1,520 @@
+package com.kharagedition.tibetankeyboard.ui.home
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.boundsInRoot
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import com.bumptech.glide.Glide
+import com.kharagedition.tibetankeyboard.R
+import com.kharagedition.tibetankeyboard.ui.compose.components.AppIcons
+import com.kharagedition.tibetankeyboard.ui.compose.components.BoText
+import com.kharagedition.tibetankeyboard.ui.compose.components.IconTile
+import com.kharagedition.tibetankeyboard.ui.compose.components.PillBadge
+import com.kharagedition.tibetankeyboard.ui.compose.components.ScreenScaffold
+import com.kharagedition.tibetankeyboard.ui.compose.components.SectionLabel
+import com.kharagedition.tibetankeyboard.ui.compose.components.TapTargetPrompt
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanColors
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanTokens
+import kotlinx.coroutines.delay
+
+/** Immutable UI state driving the Home screen. */
+data class HomeUiState(
+ val keyboardEnabled: Boolean = false,
+ val inputMethodSelected: Boolean = false,
+ val isPremium: Boolean = false,
+)
+
+/** Callbacks for Home actions, owned by the Activity. */
+class HomeActions(
+ val onEnableKeyboard: () -> Unit,
+ val onPickInputMethod: () -> Unit,
+ val onChat: () -> Unit,
+ val onTranslate: () -> Unit,
+ val onSettings: () -> Unit,
+ val onShare: () -> Unit,
+ val onRate: () -> Unit,
+ val onAbout: () -> Unit,
+ val onUpgrade: () -> Unit,
+)
+
+private data class QuickAction(
+ val label: String,
+ val icon: ImageVector,
+ val tag: String? = null,
+ val onClick: () -> Unit,
+)
+
+@Composable
+fun HomeScreen(
+ state: HomeUiState,
+ actions: HomeActions,
+ adSlot: (@Composable () -> Unit)? = null,
+ showUpdateBanner: Boolean = false,
+ onInstallUpdate: () -> Unit = {},
+) {
+ // On-screen bounds of each setup step, captured for the tap-target spotlight.
+ var step1Bounds by remember { mutableStateOf(null) }
+ var step2Bounds by remember { mutableStateOf(null) }
+ // Steps the user has dismissed the coach-mark for, this session.
+ var dismissed by remember { mutableStateOf(setOf()) }
+
+ // The first incomplete step is the one we coach toward (null = setup finished).
+ val activeStep = when {
+ !state.keyboardEnabled -> 1
+ !state.inputMethodSelected -> 2
+ else -> null
+ }
+ val activeBounds = when (activeStep) {
+ 1 -> step1Bounds
+ 2 -> step2Bounds
+ else -> null
+ }
+
+ // Briefly delay the reveal so layout settles (and so it doesn't flash mid-navigation).
+ var ready by remember { mutableStateOf(false) }
+ LaunchedEffect(activeStep) {
+ ready = false
+ if (activeStep != null) {
+ delay(500)
+ ready = true
+ }
+ }
+ val showPrompt = activeStep != null && activeStep !in dismissed && activeBounds != null && ready
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ ScreenScaffold(horizontalPadding = 18.dp) {
+ Spacer(Modifier.height(8.dp))
+ BrandHeader()
+ Spacer(Modifier.height(18.dp))
+ SetupCard(
+ state = state,
+ actions = actions,
+ onStep1Bounds = { step1Bounds = it },
+ onStep2Bounds = { step2Bounds = it },
+ )
+
+ Spacer(Modifier.height(14.dp))
+ if (state.keyboardEnabled && state.inputMethodSelected) {
+ TestKeyboardField()
+ } else {
+ // SetupDemo(if (!state.keyboardEnabled) R.drawable.keyboard else R.drawable.input)
+ }
+
+ Spacer(Modifier.height(22.dp))
+ SectionLabel(stringResource(R.string.quick_actions), color = TibetanColors.CreamDim, modifier = Modifier.padding(start = 2.dp))
+ Spacer(Modifier.height(12.dp))
+ QuickActionsGrid(state, actions)
+
+ if (!state.isPremium) {
+ Spacer(Modifier.height(16.dp))
+ GoProBanner(onUpgrade = actions.onUpgrade)
+ if (adSlot != null) {
+ Spacer(Modifier.height(16.dp))
+ adSlot()
+ }
+ }
+ }
+
+ if (showPrompt && activeStep != null && activeBounds != null) {
+ TapTargetPrompt(
+ targetBounds = activeBounds,
+ title = stringResource(
+ if (activeStep == 1) R.string.setup_coach_step1_title else R.string.setup_coach_step2_title
+ ),
+ description = stringResource(
+ if (activeStep == 1) R.string.setup_coach_step1_desc else R.string.setup_coach_step2_desc
+ ),
+ hint = stringResource(R.string.setup_coach_hint),
+ dismissLabel = stringResource(R.string.setup_coach_got_it),
+ icon = if (activeStep == 1) AppIcons.Globe else AppIcons.SwapHoriz,
+ onTargetClick = {
+ if (activeStep == 1) actions.onEnableKeyboard() else actions.onPickInputMethod()
+ },
+ onDismiss = { dismissed = dismissed + activeStep },
+ )
+ }
+
+ if (showUpdateBanner) {
+ UpdateReadyBanner(
+ onInstall = onInstallUpdate,
+ modifier = Modifier.align(Alignment.BottomCenter),
+ )
+ }
+ }
+}
+
+@Composable
+private fun UpdateReadyBanner(onInstall: () -> Unit, modifier: Modifier = Modifier) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp)
+ .clip(RoundedCornerShape(18.dp))
+ .background(TibetanTokens.GoldVertical)
+ .clickable(onClick = onInstall)
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .size(38.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(TibetanColors.Espresso.copy(alpha = 0.15f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(AppIcons.Download, null, tint = TibetanColors.Espresso, modifier = Modifier.size(20.dp))
+ }
+ Column(Modifier.weight(1f)) {
+ Text("Update Ready to Install", color = TibetanColors.Espresso, fontSize = 14.sp, fontWeight = FontWeight.Bold)
+ Text("Tap here to apply the latest update", color = TibetanColors.Espresso.copy(alpha = 0.72f), fontSize = 12.sp)
+ }
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(99.dp))
+ .background(TibetanColors.Espresso.copy(alpha = 0.15f))
+ .padding(horizontal = 14.dp, vertical = 7.dp),
+ ) {
+ Text("Install", color = TibetanColors.Espresso, fontSize = 13.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+}
+
+@Composable
+private fun BrandHeader() {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(11.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(RoundedCornerShape(12.dp)),
+ //.background(TibetanTokens.GoldVertical),
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ painter = painterResource(R.mipmap.ic_launcher),
+ contentDescription = null,
+ modifier = Modifier.size(72.dp,).clip(RoundedCornerShape(10.dp))
+ )
+ }
+ Column {
+ Text(stringResource(R.string.app_name), color = TibetanColors.Cream, fontSize = 18.sp, fontWeight = FontWeight.ExtraBold)
+ BoText(stringResource(R.string.bo_app_name), color = TibetanColors.CreamDim, fontSize = 13.sp)
+ }
+ }
+}
+
+@Composable
+private fun SetupCard(
+ state: HomeUiState,
+ actions: HomeActions,
+ onStep1Bounds: (Rect) -> Unit = {},
+ onStep2Bounds: (Rect) -> Unit = {},
+) {
+ val doneCount = (if (state.keyboardEnabled) 1 else 0) + (if (state.inputMethodSelected) 1 else 0)
+ val allDone = doneCount == 2
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(TibetanTokens.RadiusCard)
+ .background(TibetanColors.Brown600)
+ .padding(18.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ if (allDone) "You're all set!" else "Almost there!",
+ color = TibetanColors.Cream, fontSize = 15.5.sp, fontWeight = FontWeight.Bold
+ )
+ Text(stringResource(R.string.setup_progress, doneCount), color = TibetanColors.Gold300, fontSize = 12.5.sp, fontWeight = FontWeight.Bold)
+ }
+ Spacer(Modifier.height(12.dp))
+ // progress bar
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(6.dp)
+ .clip(RoundedCornerShape(99.dp))
+ .background(TibetanColors.Bg800)
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(doneCount / 2f)
+ .height(6.dp)
+ .clip(RoundedCornerShape(99.dp))
+ .background(TibetanTokens.GoldVertical)
+ )
+ }
+ Spacer(Modifier.height(18.dp))
+
+ SetupStep(
+ index = 1,
+ label = stringResource(R.string.setup_enable_keyboard),
+ done = state.keyboardEnabled,
+ active = !state.keyboardEnabled,
+ onClick = actions.onEnableKeyboard,
+ modifier = Modifier.onGloballyPositioned { onStep1Bounds(it.boundsInRoot()) },
+ )
+ Spacer(Modifier.height(12.dp))
+ SetupStep(
+ index = 2,
+ label = stringResource(R.string.setup_choose_input),
+ done = state.inputMethodSelected,
+ active = state.keyboardEnabled && !state.inputMethodSelected,
+ onClick = actions.onPickInputMethod,
+ modifier = Modifier.onGloballyPositioned { onStep2Bounds(it.boundsInRoot()) },
+ )
+ }
+}
+
+@Composable
+private fun SetupStep(
+ index: Int,
+ label: String,
+ done: Boolean,
+ active: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ if (active) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(14.dp))
+ .background(TibetanTokens.GoldVertical)
+ .clickable(onClick = onClick)
+ .padding(horizontal = 14.dp, vertical = 13.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Box(
+ modifier = Modifier.size(28.dp).clip(RoundedCornerShape(99.dp))
+ .background(TibetanColors.Espresso.copy(alpha = 0.16f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text("$index", color = TibetanColors.Espresso, fontWeight = FontWeight.ExtraBold, fontSize = 14.sp)
+ }
+ Text(label, modifier = Modifier.weight(1f), color = TibetanColors.Espresso, fontSize = 15.sp, fontWeight = FontWeight.Bold)
+ Icon(AppIcons.Chevron, null, tint = TibetanColors.Espresso, modifier = Modifier.size(20.dp))
+ }
+ } else {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Box(
+ modifier = Modifier.size(28.dp).clip(RoundedCornerShape(99.dp))
+ .then(
+ if (done) Modifier.background(TibetanTokens.GoldVertical)
+ else Modifier.background(TibetanColors.Brown700)
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (done) Icon(AppIcons.Check, null, tint = TibetanColors.Espresso, modifier = Modifier.size(17.dp))
+ else Text("$index", color = TibetanColors.CreamDim, fontWeight = FontWeight.Bold, fontSize = 13.sp)
+ }
+ Text(
+ label,
+ modifier = Modifier.weight(1f),
+ color = TibetanColors.CreamDim,
+ fontSize = 14.5.sp,
+ textDecoration = if (done) TextDecoration.LineThrough else TextDecoration.None,
+ )
+ }
+ }
+}
+
+@Composable
+private fun QuickActionsGrid(state: HomeUiState, actions: HomeActions) {
+ val items = listOf(
+ QuickAction("AI Chat", AppIcons.Bot, tag = if (!state.isPremium) "PRO" else null, onClick = actions.onChat),
+ QuickAction(stringResource(R.string.ai_translate), AppIcons.Translate, tag = if (!state.isPremium) "PRO" else null, onClick = actions.onTranslate),
+ QuickAction("Settings", AppIcons.Settings, onClick = actions.onSettings),
+ QuickAction("Share", AppIcons.Share, onClick = actions.onShare),
+ QuickAction("Rate", AppIcons.Star, onClick = actions.onRate),
+ QuickAction("About", AppIcons.More, onClick = actions.onAbout),
+ )
+ Column(verticalArrangement = Arrangement.spacedBy(11.dp)) {
+ items.chunked(3).forEach { rowItems ->
+ Row(horizontalArrangement = Arrangement.spacedBy(11.dp)) {
+ rowItems.forEach { item ->
+ QuickActionCard(item, modifier = Modifier.weight(1f))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun QuickActionCard(item: QuickAction, modifier: Modifier = Modifier) {
+ Box(
+ modifier = modifier
+ .clip(RoundedCornerShape(16.dp))
+ .background(TibetanColors.Brown700)
+ .border(1.dp, TibetanColors.Line, RoundedCornerShape(16.dp))
+ .clickable(onClick = item.onClick)
+ .padding(start = 8.dp, end = 8.dp, top = 15.dp, bottom = 12.dp),
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(9.dp),
+ ) {
+ IconTile(item.icon, size = 42.dp, iconSize = 21.dp)
+ Text(item.label, color = TibetanColors.Cream2, fontSize = 12.5.sp, fontWeight = FontWeight.SemiBold)
+ }
+ if (item.tag != null) {
+ PillBadge(item.tag, modifier = Modifier.align(Alignment.TopEnd))
+ }
+ }
+}
+
+@Composable
+private fun GoProBanner(onUpgrade: () -> Unit) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(18.dp))
+ .background(
+ androidx.compose.ui.graphics.Brush.linearGradient(
+ listOf(TibetanColors.Maroon, TibetanColors.MaroonDeep)
+ )
+ )
+ .border(1.dp, TibetanColors.Gold200.copy(alpha = 0.22f), RoundedCornerShape(18.dp))
+ .clickable(onClick = onUpgrade)
+ .padding(horizontal = 16.dp, vertical = 15.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(13.dp),
+ ) {
+ Box(
+ modifier = Modifier.size(42.dp).clip(RoundedCornerShape(12.dp)).background(TibetanTokens.GoldVertical),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(AppIcons.Crown, null, tint = TibetanColors.Espresso, modifier = Modifier.size(23.dp))
+ }
+ Column(Modifier.weight(1f)) {
+ Text(stringResource(R.string.go_pro), color = TibetanColors.Cream, fontSize = 14.5.sp, fontWeight = FontWeight.Bold)
+ Text(stringResource(R.string.go_pro_subtitle), color = TibetanColors.Cream2, fontSize = 12.sp)
+ }
+ Box(
+ modifier = Modifier.clip(RoundedCornerShape(999.dp)).background(TibetanColors.Gold200)
+ .padding(horizontal = 14.dp, vertical = 7.dp),
+ ) {
+ Text(stringResource(R.string.upgrade), color = TibetanColors.Espresso, fontSize = 13.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+}
+
+/** Animated setup walkthrough GIF (how to enable / pick the keyboard). */
+@Composable
+private fun SetupDemo(gifResId: Int) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(18.dp))
+ .background(TibetanColors.Brown700)
+ .border(1.dp, TibetanColors.Line, RoundedCornerShape(18.dp))
+ .padding(14.dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Icon(AppIcons.Bell, null, tint = TibetanColors.Gold300, modifier = Modifier.size(16.dp))
+ Text(stringResource(R.string.how_to_set_up), color = TibetanColors.Cream, fontSize = 13.5.sp, fontWeight = FontWeight.Bold)
+ }
+ Spacer(Modifier.height(10.dp))
+ AndroidView(
+ modifier = Modifier.fillMaxWidth().height(190.dp).clip(RoundedCornerShape(12.dp)),
+ factory = { ctx ->
+ android.widget.ImageView(ctx).apply {
+ scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
+ adjustViewBounds = true
+ }
+ },
+ update = { iv -> Glide.with(iv).asGif().load(gifResId).into(iv) },
+ )
+ }
+}
+
+/** Inline field so the user can test the keyboard without leaving Home. */
+@Composable
+private fun TestKeyboardField() {
+ var text by remember { mutableStateOf("") }
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(18.dp))
+ .background(TibetanColors.Brown700)
+ .border(1.dp, TibetanColors.Line, RoundedCornerShape(18.dp))
+ .padding(16.dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Icon(AppIcons.Check, null, tint = TibetanColors.Jade, modifier = Modifier.size(16.dp))
+ Text(stringResource(R.string.try_keyboard_here), color = TibetanColors.Cream, fontSize = 13.5.sp, fontWeight = FontWeight.Bold)
+ }
+ Spacer(Modifier.height(10.dp))
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(52.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(TibetanColors.Bg800)
+ .border(1.dp, TibetanColors.Line2, RoundedCornerShape(12.dp))
+ .padding(horizontal = 14.dp),
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ if (text.isEmpty()) {
+ BoText("འདིར་ཡིག་འབྲི་རོགས་…", color = TibetanColors.CreamDim, fontSize = 15.sp)
+ }
+ BasicTextField(
+ value = text,
+ onValueChange = { text = it },
+ textStyle = TextStyle(color = TibetanColors.Cream, fontSize = 16.sp),
+ cursorBrush = SolidColor(TibetanColors.Gold300),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeViewModel.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeViewModel.kt
new file mode 100644
index 0000000..66e7a6c
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeViewModel.kt
@@ -0,0 +1,53 @@
+package com.kharagedition.tibetankeyboard.ui.home
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.Observer
+import com.kharagedition.tibetankeyboard.auth.AuthManager
+import com.kharagedition.tibetankeyboard.data.repository.RevenueCatManager
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+/**
+ * Owns the Home UI state: keyboard-setup progress (fed by the Activity, which queries the
+ * framework InputMethodManager) and premium status (observed from RevenueCat). Also exposes
+ * the auth/session operations so the Activity stays free of business logic.
+ */
+class HomeViewModel(app: Application) : AndroidViewModel(app) {
+
+ private val authManager = AuthManager(app)
+ private val premiumLiveData = RevenueCatManager.getInstance().isPremiumUser
+
+ private val _uiState = MutableStateFlow(HomeUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val premiumObserver = Observer { isPremium ->
+ _uiState.update { it.copy(isPremium = isPremium) }
+ }
+
+ init {
+ premiumLiveData.observeForever(premiumObserver)
+ RevenueCatManager.getInstance().refreshCustomerInfo()
+ }
+
+ /** Called by the Activity after it reads keyboard-enabled / default-IME status. */
+ fun refreshSetup(keyboardEnabled: Boolean, inputMethodSelected: Boolean) {
+ _uiState.update { it.copy(keyboardEnabled = keyboardEnabled, inputMethodSelected = inputMethodSelected) }
+ }
+
+ fun isUserAuthenticated(): Boolean = authManager.isUserAuthenticated()
+
+ fun initializeUserSession(callback: RevenueCatManager.SubscriptionCallback) =
+ authManager.initializeUserSession(callback)
+
+ fun syncPurchases(callback: RevenueCatManager.SubscriptionCallback) =
+ RevenueCatManager.getInstance().syncPurchases(callback)
+
+ fun refreshPremium() = RevenueCatManager.getInstance().refreshCustomerInfo()
+
+ override fun onCleared() {
+ premiumLiveData.removeObserver(premiumObserver)
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/AIKeyboardCodes.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/AIKeyboardCodes.kt
similarity index 80%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/AIKeyboardCodes.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/AIKeyboardCodes.kt
index 729cd3f..b1d08ed 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/AIKeyboardCodes.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/AIKeyboardCodes.kt
@@ -1,4 +1,4 @@
-package com.kharagedition.tibetankeyboard
+package com.kharagedition.tibetankeyboard.ui.keyboard
object AIKeyboardCodes {
const val AI_OPTIONS = -100
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ai/AIKeyboardInterface.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/AIKeyboardInterface.kt
similarity index 51%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/ai/AIKeyboardInterface.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/AIKeyboardInterface.kt
index 522c0ca..eb58577 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/ai/AIKeyboardInterface.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/AIKeyboardInterface.kt
@@ -1,4 +1,4 @@
-package com.kharagedition.tibetankeyboard.ai
+package com.kharagedition.tibetankeyboard.ui.keyboard
interface AIKeyboardInterface {
fun getCurrentText(): String
@@ -6,4 +6,11 @@ interface AIKeyboardInterface {
fun onTranslateReplace(originalText: String, translatedText: String)
fun onRephraseReplace(originalText: String, rephrasedText: String)
fun onAICancel()
+ fun onSuggestionSelected(word: String)
+
+ /** Open the AI chat screen (PRO users). */
+ fun onOpenChat()
+
+ /** Route a free user to the unlock flow (login if signed out, else the premium paywall). */
+ fun onUnlockPro()
}
\ No newline at end of file
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/AIKeyboardView.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/AIKeyboardView.kt
new file mode 100644
index 0000000..0fea50c
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/AIKeyboardView.kt
@@ -0,0 +1,632 @@
+package com.kharagedition.tibetankeyboard.ui.keyboard
+
+import com.kharagedition.tibetankeyboard.R
+
+import android.animation.AnimatorSet
+import android.animation.ObjectAnimator
+import android.animation.ValueAnimator
+import android.content.Context
+import android.graphics.Color
+import android.util.AttributeSet
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.animation.AccelerateInterpolator
+import android.view.animation.DecelerateInterpolator
+import android.widget.*
+import androidx.core.content.ContextCompat
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.Observer
+import com.kharagedition.tibetankeyboard.analytics.AppAnalytics
+import com.kharagedition.tibetankeyboard.ui.keyboard.AIKeyboardInterface
+import com.kharagedition.tibetankeyboard.data.repository.AIService
+import com.kharagedition.tibetankeyboard.data.model.GrammarResult
+import com.kharagedition.tibetankeyboard.data.model.RephraseResult
+import com.kharagedition.tibetankeyboard.data.model.TranslationResult
+import com.kharagedition.tibetankeyboard.data.repository.RevenueCatManager
+import com.kharagedition.tibetankeyboard.ui.settings.SettingsPrefs
+import kotlinx.coroutines.*
+import com.kharagedition.botok.autocomplete.SuggestionEngine
+
+class AIKeyboardView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : LinearLayout(context, attrs, defStyleAttr) {
+
+ private lateinit var aiToolbar: LinearLayout
+ private lateinit var proPill: View
+ private lateinit var proChatBtn: ImageView
+ private lateinit var proAutoBtn: ImageView
+ private lateinit var proTranslateBtn: ImageView
+ private lateinit var normalKeyboardContainer: FrameLayout
+ private lateinit var aiInterfaceContainer: LinearLayout
+ private lateinit var aiBackBtn: ImageView
+ private lateinit var aiTitleText: TextView
+ private lateinit var originalTextView: TextView
+ private lateinit var suggestedTextView: TextView
+ private lateinit var aiSummaryText: TextView
+ private lateinit var aiReplaceBtn: Button
+ private lateinit var aiCancelBtn: Button
+ private lateinit var aiInrBtn: Button
+ private lateinit var loadingProgressBar: ProgressBar
+ private lateinit var translateSwapBtn: ImageView
+ private lateinit var sourceLanguageText: TextView
+ private lateinit var targetLanguageText: TextView
+ private lateinit var engineSelectorRow: View
+ private lateinit var engineChipAzure: TextView
+ private lateinit var engineChipHaiku: TextView
+ private lateinit var engineChipSonnet: TextView
+
+ private var aiKeyboardInterface: AIKeyboardInterface? = null
+ private var themeColor: String = "#FF704C04"
+ private lateinit var suggestionStrip: SuggestionStripView
+ private var suggestionEngine: SuggestionEngine? = null
+ private var currentOriginalText = ""
+ private var currentSuggestedText = ""
+ private var currentSourceLang = "en" // English
+ private var currentTargetLang = "bo" // Tibetan
+ // Translation engine (azure / claude-haiku / claude-sonnet) — shared with the
+ // standalone Translate screen via SettingsPrefs; re-read each time the panel opens.
+ private var currentEngine = SettingsPrefs.DEFAULT_TRANSLATE_ENGINE
+ private val aiService = AIService()
+ private var isPremiumUser = false
+
+ // Kept as a field so we can removeObserver() on detach — the IME rebuilds this view on every
+ // onStartInputView, and the LiveData lives on a process-wide singleton, so an un-removed
+ // observeForever would leak every previous view (and run applyPremiumState on detached views).
+ private val premiumObserver = Observer { isPremium ->
+ isPremiumUser = isPremium
+ applyPremiumState()
+ }
+
+ init {
+ orientation = VERTICAL
+ setupView()
+ RevenueCatManager.getInstance().isPremiumUser.observeForever(premiumObserver)
+ }
+
+ override fun onDetachedFromWindow() {
+ RevenueCatManager.getInstance().isPremiumUser.removeObserver(premiumObserver)
+ super.onDetachedFromWindow()
+ }
+
+ private fun setupView() {
+ LayoutInflater.from(context).inflate(R.layout.ai_keyboard_layout, this, true)
+
+ initializeViews()
+ setupClickListeners()
+ updateLanguageLabels()
+ loadSuggestionEngine()
+ applyBottomInsetPadding()
+ applyPremiumState()
+ }
+
+ /**
+ * Show/hide and dim the PRO strip controls based on entitlement.
+ * - Free users: gold "PRO" pill + all three feature icons visible but dimmed (locked);
+ * tapping any of them routes to the unlock flow.
+ * - PRO users: the upsell pill and the Autocomplete icon are hidden (autocomplete just
+ * works while typing); Chat + Translate are full-opacity and functional.
+ */
+ private fun applyPremiumState() {
+ val s = ProStripState.forPremium(isPremiumUser)
+ proPill.visibility = if (s.pillVisible) View.VISIBLE else View.GONE
+ proAutoBtn.visibility = if (s.autocompleteVisible) View.VISIBLE else View.GONE
+ proChatBtn.alpha = s.chatAlpha
+ proTranslateBtn.alpha = s.translateAlpha
+ proAutoBtn.alpha = s.autocompleteAlpha
+ // Free users never get suggestions, so clear any stale chips — the strip stays present as
+ // the bar's flexible spacer (weight=1), pushing the upsell pill left and the icons right.
+ if (!isPremiumUser) suggestionStrip.setSuggestions(emptyList())
+ }
+
+ /**
+ * Lift the whole keyboard above the system's IME navigation bar (the hide-keyboard
+ * arrow on the left and the switch-input globe on the right). Without this the root
+ * view draws to the screen's bottom edge and those system buttons overlap our bottom
+ * key row.
+ *
+ * We prefer the REAL navigation-bar inset — on gesture-nav devices that's just the slim
+ * pill height, so we don't over-pad the way the platform navigation_bar_height resource
+ * (≈48dp, the old 3-button height) would. The IME rebuilds this view on every open
+ * (onStartInputView) and the inset only reports non-zero on the first settle, so we cache
+ * the last good value statically and reuse it on later opens — that avoids both the
+ * reopen-collapse and the over-padding from the resource fallback.
+ */
+ private fun applyBottomInsetPadding() {
+ val base = if (cachedNavInset >= 0) cachedNavInset else systemNavBarHeight()
+ updatePadding(bottom = base + EXTRA_BOTTOM_GAP_PX)
+ ViewCompat.setOnApplyWindowInsetsListener(this) { v, insets ->
+ val navBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
+ if (navBars.bottom > 0) cachedNavInset = navBars.bottom
+ val resolved = if (cachedNavInset >= 0) cachedNavInset else navBars.bottom
+ v.updatePadding(bottom = resolved + EXTRA_BOTTOM_GAP_PX)
+ insets
+ }
+ ViewCompat.requestApplyInsets(this)
+ }
+
+ /** Fallback only, used before any real inset arrives; over-reports on gesture nav. */
+ private fun systemNavBarHeight(): Int {
+ val resId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
+ return if (resId > 0) resources.getDimensionPixelSize(resId)
+ else (16 * resources.displayMetrics.density).toInt()
+ }
+
+ /** The breathing room above the system IME bar — tune this single value to taste. */
+ private val EXTRA_BOTTOM_GAP_PX: Int
+ get() = (8 * resources.displayMetrics.density).toInt()
+
+ private fun initializeViews() {
+ aiToolbar = findViewById(R.id.ai_toolbar)
+ proPill = findViewById(R.id.pro_pill)
+ proChatBtn = findViewById(R.id.pro_chat_btn)
+ proAutoBtn = findViewById(R.id.pro_auto_btn)
+ proTranslateBtn = findViewById(R.id.pro_translate_btn)
+ normalKeyboardContainer = findViewById(R.id.normal_keyboard_container)
+ aiInterfaceContainer = findViewById(R.id.ai_interface_container)
+ aiBackBtn = findViewById(R.id.ai_back_btn)
+ aiTitleText = findViewById(R.id.ai_title_text)
+ aiInrBtn = findViewById(R.id.ai_inner_btn)
+ originalTextView = findViewById(R.id.original_text_view)
+ suggestedTextView = findViewById(R.id.suggested_text_view)
+ aiSummaryText = findViewById(R.id.ai_summary_text)
+ aiReplaceBtn = findViewById(R.id.ai_replace_btn)
+ aiCancelBtn = findViewById(R.id.ai_cancel_btn)
+ loadingProgressBar = findViewById(R.id.loading_progress_bar)
+ translateSwapBtn = findViewById(R.id.translate_swap_btn)
+ sourceLanguageText = findViewById(R.id.source_language_text)
+ targetLanguageText = findViewById(R.id.target_language_text)
+ engineSelectorRow = findViewById(R.id.engine_selector_row)
+ engineChipAzure = findViewById(R.id.engine_chip_azure)
+ engineChipHaiku = findViewById(R.id.engine_chip_haiku)
+ engineChipSonnet = findViewById(R.id.engine_chip_sonnet)
+ suggestionStrip = findViewById(R.id.suggestion_strip)
+ suggestionStrip.onSuggestionClick = { word ->
+ aiKeyboardInterface?.onSuggestionSelected(word)
+ }
+ }
+
+ private fun setupClickListeners() {
+ // Gold upsell pill — free users only (hidden for PRO).
+ proPill.setOnClickListener { aiKeyboardInterface?.onUnlockPro() }
+
+ // AI Chat — PRO opens the chat screen; free routes to unlock.
+ proChatBtn.setOnClickListener {
+ if (isPremiumUser) aiKeyboardInterface?.onOpenChat() else aiKeyboardInterface?.onUnlockPro()
+ }
+
+ // Autocomplete — shown to free users only as an upsell.
+ proAutoBtn.setOnClickListener { aiKeyboardInterface?.onUnlockPro() }
+
+ // Translate — PRO opens the translate panel; free routes to unlock.
+ proTranslateBtn.setOnClickListener {
+ if (!isPremiumUser) {
+ aiKeyboardInterface?.onUnlockPro()
+ return@setOnClickListener
+ }
+ val currentText = getCurrentInputText()
+ if (currentText.isEmpty()) {
+ Toast.makeText(context, "Input text is empty", Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+ handleTranslateClick(currentText)
+ showAIInterface()
+ }
+
+ aiInrBtn.setOnClickListener() {
+ val currentText = getCurrentInputText()
+ if(currentText.isEmpty()) {
+ Toast.makeText(context, "Input text is empty", Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+ when (aiInrBtn.text.toString()) {
+ context.resources.getString(R.string.fix) -> handleGrammarClick(currentText)
+ context.resources.getString(R.string.translate) -> handleTranslateClick(currentText)
+ context.resources.getString(R.string.rephrase) -> handleRephraseClick(currentText)
+ }
+ }
+
+ aiBackBtn.setOnClickListener {
+ showNormalKeyboard()
+ aiKeyboardInterface?.onAICancel()
+ }
+
+ aiReplaceBtn.setOnClickListener {
+ if (currentSuggestedText.isNotEmpty()) {
+ when (aiReplaceBtn.text.toString()) {
+ context.resources.getString(R.string.apply_corrections) -> aiKeyboardInterface?.onGrammarReplace(currentOriginalText, currentSuggestedText)
+ context.resources.getString(R.string.use_translation) -> aiKeyboardInterface?.onTranslateReplace(currentOriginalText, currentSuggestedText)
+ context.resources.getString(R.string.use_rephrase) -> aiKeyboardInterface?.onRephraseReplace(currentOriginalText, currentSuggestedText)
+ }
+ }
+ }
+
+ aiCancelBtn.setOnClickListener {
+ showNormalKeyboard()
+ aiKeyboardInterface?.onAICancel()
+ }
+
+ translateSwapBtn.setOnClickListener {
+ swapTranslationLanguages()
+ }
+
+ engineChipAzure.setOnClickListener { setEngine(SettingsPrefs.ENGINE_AZURE) }
+ engineChipHaiku.setOnClickListener { setEngine(SettingsPrefs.ENGINE_HAIKU) }
+ engineChipSonnet.setOnClickListener { setEngine(SettingsPrefs.ENGINE_SONNET) }
+ }
+
+ private fun setEngine(value: String) {
+ if (value == currentEngine) return
+ currentEngine = value
+ SettingsPrefs.putString(context, SettingsPrefs.KEY_TRANSLATE_ENGINE, value)
+ styleEngineChips()
+ // Re-run with the new engine if a translation is on screen.
+ if (currentOriginalText.isNotEmpty() && engineSelectorRow.visibility == View.VISIBLE) {
+ runTranslation()
+ }
+ }
+
+ /** Paint the selected engine chip gold, the rest as subtle outlined pills. */
+ private fun styleEngineChips() {
+ val chips = listOf(
+ engineChipAzure to SettingsPrefs.ENGINE_AZURE,
+ engineChipHaiku to SettingsPrefs.ENGINE_HAIKU,
+ engineChipSonnet to SettingsPrefs.ENGINE_SONNET,
+ )
+ val creamColor = ContextCompat.getColor(context, R.color.tk_cream)
+ for ((chip, value) in chips) {
+ val selected = value == currentEngine
+ chip.setBackgroundResource(
+ if (selected) R.drawable.pro_pill_background else R.drawable.tk_lang_pill
+ )
+ chip.setTextColor(if (selected) Color.parseColor("#3A2606") else creamColor)
+ }
+ }
+
+ private fun handleRephraseClick(currentText: String) {
+ showRephraseInterface(currentText)
+ }
+
+ private fun handleTranslateClick(currentText: String) {
+ showTranslateInterface(currentText)
+ }
+
+ private fun handleGrammarClick(currentText: String) {
+ showGrammarInterface(currentText)
+ }
+
+ private fun getCurrentInputText(): String {
+ val text = aiKeyboardInterface?.getCurrentText() ?: ""
+ return text.trim()
+ }
+
+ fun setAIKeyboardInterface(aiKeyboardInterface: AIKeyboardInterface) {
+ this.aiKeyboardInterface = aiKeyboardInterface
+ }
+
+ fun setThemeColor(color: String) {
+ this.themeColor = color
+ applyTheme()
+ }
+
+ private fun applyTheme() {
+ // Darken the chosen theme colour into a deep espresso surface so the keyboard
+ // matches the premium dark-warm design and the brown key caps pop above it.
+ val surface = darken(Color.parseColor(themeColor), 0.42f)
+ // Tint the root too so the bottom inset padding (the gap above the system IME bar)
+ // matches the keyboard surface rather than showing the lighter root brown.
+ setBackgroundColor(surface)
+ aiToolbar.setBackgroundColor(surface)
+ suggestionStrip.setThemeColor(surface)
+ // The translate/grammar panel keeps a fixed espresso look from XML; its Replace button is
+ // dark-on-gold, so no runtime text-colour override is needed here.
+ }
+
+ private fun darken(color: Int, factor: Float): Int {
+ val red = (Color.red(color) * factor).toInt().coerceIn(0, 255)
+ val green = (Color.green(color) * factor).toInt().coerceIn(0, 255)
+ val blue = (Color.blue(color) * factor).toInt().coerceIn(0, 255)
+ return Color.rgb(red, green, blue)
+ }
+
+ fun showGrammarInterface(text: String) {
+ Log.d("AIKeyboardView", "showGrammarInterface called with text: $text")
+ AppAnalytics.logKeyboardAiUsed(AppAnalytics.KeyboardFeature.GRAMMAR)
+ currentOriginalText = text
+ aiTitleText.text = "Grammar Check"
+ originalTextView.text = text
+ hideTranslationControls()
+ showLoadingState()
+
+
+ CoroutineScope(Dispatchers.Main).launch {
+ try {
+ val result = aiService.checkGrammar(text)
+ showGrammarResult(result)
+ } catch (e: Exception) {
+ Log.e("AIKeyboardView", "Grammar check failed", e)
+ showError("Failed to check grammar")
+ }
+ }
+ }
+
+ fun showTranslateInterface(text: String) {
+ Log.d("AIKeyboardView", "showTranslateInterface called with text: $text")
+ AppAnalytics.logKeyboardAiUsed(AppAnalytics.KeyboardFeature.TRANSLATE)
+ currentOriginalText = text
+ aiTitleText.text = "Translate Text"
+ originalTextView.text = text
+ // Pick up the engine the user last chose (here or on the Translate screen).
+ currentEngine = SettingsPrefs.readTranslateEngine(context)
+ showTranslationControls()
+ runTranslation()
+ }
+
+ /** Translate [currentOriginalText] with the current languages + engine. */
+ private fun runTranslation() {
+ if (currentOriginalText.isEmpty()) return
+ showLoadingState()
+ CoroutineScope(Dispatchers.Main).launch {
+ try {
+ val result = aiService.translateText(
+ currentOriginalText,
+ currentSourceLang,
+ currentTargetLang,
+ currentEngine,
+ )
+ showTranslationResult(result)
+ } catch (e: Exception) {
+ Log.e("AIKeyboardView", "Translation failed", e)
+ showError("Failed to translate text")
+ }
+ }
+ }
+
+ fun showRephraseInterface(text: String) {
+ Log.d("AIKeyboardView", "showRephraseInterface called with text: $text")
+ AppAnalytics.logKeyboardAiUsed(AppAnalytics.KeyboardFeature.REPHRASE)
+
+ currentOriginalText = text
+
+ aiTitleText.text = "Rephrase Text"
+
+ originalTextView.text = text
+
+ hideTranslationControls()
+
+ showLoadingState()
+
+ CoroutineScope(Dispatchers.Main).launch {
+ try {
+
+ val result = aiService.rephraseText(text)
+
+ showRephraseResult(result)
+
+ } catch (e: Exception) {
+ Log.e("AIKeyboardView", "Rephrase failed", e)
+ showError("Failed to rephrase text")
+ }
+ }
+ }
+
+ private fun showTranslationControls() {
+ translateSwapBtn.visibility = View.VISIBLE
+ sourceLanguageText.visibility = View.VISIBLE
+ targetLanguageText.visibility = View.VISIBLE
+ engineSelectorRow.visibility = View.VISIBLE
+ styleEngineChips()
+ updateLanguageLabels()
+ }
+
+ private fun hideTranslationControls() {
+ translateSwapBtn.visibility = View.GONE
+ sourceLanguageText.visibility = View.GONE
+ targetLanguageText.visibility = View.GONE
+ engineSelectorRow.visibility = View.GONE
+ }
+
+ private fun swapTranslationLanguages() {
+ val tempLang = currentSourceLang
+
+ currentSourceLang = currentTargetLang
+
+ currentTargetLang = tempLang
+
+ // Animate the swap
+ val rotateAnimator = ObjectAnimator.ofFloat(translateSwapBtn, "rotation", 0f, 180f)
+
+ rotateAnimator.duration = 30
+
+ rotateAnimator.start()
+
+ updateLanguageLabels()
+
+ // Re-translate with swapped languages if we have text
+ runTranslation()
+ }
+
+ private fun updateLanguageLabels() {
+ sourceLanguageText.text = getLanguageDisplayName(currentSourceLang)
+ targetLanguageText.text = getLanguageDisplayName(currentTargetLang)
+ }
+
+ private fun getLanguageDisplayName(langCode: String): String {
+ return when (langCode) {
+ "bo" -> "བོད་སྐད།" // Tibetan
+ "en" -> "English"
+ else -> langCode
+ }
+ }
+
+ private fun showLoadingState() {
+ aiInrBtn.visibility = View.GONE
+ loadingProgressBar.visibility = View.VISIBLE
+ suggestedTextView.text = "Analyzing text..."
+ aiSummaryText.text = "Please wait while we process your request."
+ aiReplaceBtn.isEnabled = false
+ aiReplaceBtn.alpha = 0.5f
+ }
+
+ private fun showGrammarResult(result: GrammarResult) {
+
+ loadingProgressBar.visibility = View.GONE
+ currentSuggestedText = result.correctedText
+ suggestedTextView.text = result.correctedText
+ aiSummaryText.text = "${result.corrections.size} corrections found: ${result.corrections.joinToString(", ")}"
+ aiReplaceBtn.isEnabled = true
+ aiReplaceBtn.alpha = 1f
+ aiReplaceBtn.text = context.getString(R.string.apply_corrections)
+ aiInrBtn.visibility = View.VISIBLE
+ aiInrBtn.text = context.getString(R.string.fix);
+ }
+
+ private fun showTranslationResult(result: TranslationResult) {
+ loadingProgressBar.visibility = View.GONE
+ currentSuggestedText = result.translatedText
+ suggestedTextView.text = result.translatedText
+ aiSummaryText.text = "Translated from ${result.sourceLanguage} to ${result.targetLanguage}"
+ aiReplaceBtn.isEnabled = true
+ aiReplaceBtn.alpha = 1f
+ aiReplaceBtn.text = context.getString(R.string.use_translation)
+ aiInrBtn.visibility = View.VISIBLE
+ aiInrBtn.text = context.getString(R.string.translate);
+ if(result.error!=null){
+ showError("Translation error: ${result.error}")
+ }
+ }
+
+ private fun showRephraseResult(result: RephraseResult) {
+ loadingProgressBar.visibility = View.GONE
+ currentSuggestedText = result.rephrasedText
+ suggestedTextView.text = result.rephrasedText
+ aiSummaryText.text = "Style: ${result.style} - ${result.improvements.joinToString(", ")}"
+ aiReplaceBtn.isEnabled = true
+ aiReplaceBtn.alpha = 1f
+ aiReplaceBtn.text = context.getString(R.string.use_rephrase)
+ aiInrBtn.visibility = View.VISIBLE
+ aiInrBtn.text = context.getString(R.string.rephrase);
+ }
+
+ private fun showError(message: String) {
+ loadingProgressBar.visibility = View.GONE
+ suggestedTextView.text = "Error occurred"
+ aiSummaryText.text = message
+ aiReplaceBtn.isEnabled = false
+ aiReplaceBtn.alpha = 0.5f
+ aiInrBtn.visibility = View.VISIBLE
+ aiInrBtn.text = context.getString(R.string.retry);
+ }
+
+ /** Small vertical offset for the panel's fade-in/out — a hint of motion, not a full slide. */
+ private val PANEL_SLIDE_PX: Float
+ get() = 26 * resources.displayMetrics.density
+
+ private fun showAIInterface() {
+ // Hide the top bar (suggestion strip is a child of it now) so the panel is the single,
+ // cohesive surface — its own header takes over.
+ aiToolbar.visibility = View.GONE
+ normalKeyboardContainer.visibility = View.GONE
+ // Cancel any in-flight close so a quick reopen doesn't fight the dismiss animation.
+ aiInterfaceContainer.animate().cancel()
+ aiInterfaceContainer.visibility = View.VISIBLE
+ aiInterfaceContainer.alpha = 0f
+ aiInterfaceContainer.translationY = PANEL_SLIDE_PX
+ aiInterfaceContainer.animate()
+ .alpha(1f)
+ .translationY(0f)
+ .setDuration(220)
+ .setInterpolator(DecelerateInterpolator())
+ .start()
+ }
+
+ fun showNormalKeyboard() {
+ if (aiInterfaceContainer.visibility != View.VISIBLE) return
+ aiInterfaceContainer.animate().cancel()
+ aiInterfaceContainer.animate()
+ .alpha(0f)
+ .translationY(PANEL_SLIDE_PX)
+ .setDuration(170)
+ .setInterpolator(AccelerateInterpolator())
+ .withEndAction {
+ aiInterfaceContainer.visibility = View.GONE
+ // Reset so the panel is ready for its next fade-in.
+ aiInterfaceContainer.alpha = 1f
+ aiInterfaceContainer.translationY = 0f
+ // The suggestion strip lives inside aiToolbar, so it returns with it.
+ aiToolbar.visibility = View.VISIBLE
+ normalKeyboardContainer.visibility = View.VISIBLE
+ currentOriginalText = ""
+ currentSuggestedText = ""
+ }
+ .start()
+ }
+
+ fun updateSuggestions(prefix: String) {
+ // Free users get no autocomplete; the strip stays empty (it's the bar's spacer).
+ if (!isPremiumUser) return
+ val engine = suggestionEngine
+ if (engine == null) {
+ Log.d(TAG, "updateSuggestions: engine not loaded yet, prefix='$prefix'")
+ return
+ }
+ if (!engine.isReady) {
+ Log.d(TAG, "updateSuggestions: engine not ready yet, prefix='$prefix'")
+ return
+ }
+ val suggestions = if (prefix.isNotEmpty()) engine.getSuggestions(prefix, 4) else emptyList()
+ Log.d(TAG, "updateSuggestions: prefix='$prefix' → ${suggestions.size} results: $suggestions")
+ // Only swap the chips inside the single top bar — never change its height. Empty list →
+ // blank middle; the bar stays one fixed height so the keyboard never jumps (Gboard flow).
+ suggestionStrip.setSuggestions(suggestions)
+ }
+
+ private fun loadSuggestionEngine() {
+ val cached = sharedEngine
+ if (cached != null && cached.isReady) {
+ Log.d(TAG, "loadSuggestionEngine: using cached engine")
+ suggestionEngine = cached
+ return
+ }
+ Log.d(TAG, "loadSuggestionEngine: starting background load")
+ CoroutineScope(Dispatchers.IO).launch {
+ val engine = SuggestionEngine()
+ val tsvPaths = listOf(
+ "botok/general/dictionary/words/tsikchen.tsv",
+ "botok/general/dictionary/words/uncompound_lexicon.tsv",
+ "botok/general/dictionary/words_non_inflected/particles.tsv"
+ )
+ for (path in tsvPaths) {
+ try {
+ context.assets.open(path).bufferedReader(Charsets.UTF_8).useLines { lines ->
+ engine.addLines(lines)
+ }
+ Log.d(TAG, "loadSuggestionEngine: loaded $path")
+ } catch (e: Exception) {
+ Log.w(TAG, "loadSuggestionEngine: skipping $path — ${e.message}")
+ }
+ }
+ engine.ready()
+ Log.d(TAG, "loadSuggestionEngine: ready, wordCount=${engine.wordCount}")
+ withContext(Dispatchers.Main) {
+ sharedEngine = engine
+ suggestionEngine = engine
+ Log.d(TAG, "loadSuggestionEngine: engine assigned on main thread")
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "AIKeyboardView"
+ @Volatile
+ private var sharedEngine: SuggestionEngine? = null
+ /** Last real navigation-bar inset, remembered across IME view rebuilds. -1 = unknown. */
+ @Volatile
+ private var cachedNavInset = -1
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/CorrectionsAdapter.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/CorrectionsAdapter.kt
similarity index 94%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/CorrectionsAdapter.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/CorrectionsAdapter.kt
index 485427f..60f376b 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/CorrectionsAdapter.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/CorrectionsAdapter.kt
@@ -1,4 +1,6 @@
-package com.kharagedition.tibetankeyboard
+package com.kharagedition.tibetankeyboard.ui.keyboard
+
+import com.kharagedition.tibetankeyboard.R
import android.view.LayoutInflater
import android.view.View
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/EmojiItemList.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/EmojiItemList.kt
similarity index 99%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/EmojiItemList.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/EmojiItemList.kt
index 91fde0d..43ddcd6 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/EmojiItemList.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/EmojiItemList.kt
@@ -1,4 +1,4 @@
-package com.kharagedition.tibetankeyboard
+package com.kharagedition.tibetankeyboard.ui.keyboard
class EmojiItemList {
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/EmojiKeyboardView.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/EmojiKeyboardView.kt
similarity index 99%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/EmojiKeyboardView.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/EmojiKeyboardView.kt
index 46ae36a..853167f 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/EmojiKeyboardView.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/EmojiKeyboardView.kt
@@ -1,4 +1,6 @@
-package com.kharagedition.tibetankeyboard
+package com.kharagedition.tibetankeyboard.ui.keyboard
+
+import com.kharagedition.tibetankeyboard.R
import android.content.Context
import android.graphics.BitmapFactory
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/KeyboardLayoutHint.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/KeyboardLayoutHint.kt
new file mode 100644
index 0000000..e49526f
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/KeyboardLayoutHint.kt
@@ -0,0 +1,54 @@
+package com.kharagedition.tibetankeyboard.ui.keyboard
+
+/**
+ * Lets an in-app text field tell the Tibetan IME which script the user is
+ * expected to type, so the keyboard opens on the matching layout instead of
+ * the user's last global choice.
+ *
+ * Use case: the AI Translate **source** field. With an English source the user
+ * must type Latin, so the field asks the keyboard for QWERTY; with a Tibetan
+ * source it asks for the Uchen layout. Without this the keyboard kept whatever
+ * language was last selected (usually Tibetan) and the user had to tap the
+ * globe key every time.
+ *
+ * Carried over [android.view.inputmethod.EditorInfo.privateImeOptions] — a
+ * private channel between this app and its own IME, scoped to the focused
+ * field. It never writes the persisted keyboard-language preference, so the
+ * user's choice in every other app is left untouched.
+ */
+object KeyboardLayoutHint {
+ private const val KEY = "com.kharagedition.tibetankeyboard.forceLayout"
+ private const val TIBETAN = "bo"
+ private const val LATIN = "en"
+
+ /**
+ * The privateImeOptions value a translate source field should advertise.
+ * Only Tibetan ("bo") needs the Uchen layout; every other source language
+ * (English, Chinese, …) is entered in Latin script, so we force QWERTY.
+ */
+ fun privateImeOption(sourceLang: String): String {
+ val layout = if (sourceLang == TIBETAN) TIBETAN else LATIN
+ return "$KEY=$layout"
+ }
+
+ /**
+ * Reads a focused field's privateImeOptions back into a layout decision.
+ *
+ * @return `true` → force the Tibetan layout, `false` → force QWERTY,
+ * `null` → no hint present, so the keyboard keeps the user's own
+ * language choice (the case for every field outside this app).
+ */
+ fun forcedTibetan(privateImeOptions: String?): Boolean? {
+ val value = privateImeOptions
+ ?.split(',')
+ ?.firstOrNull { it.substringBefore('=').trim() == KEY }
+ ?.substringAfter('=', "")
+ ?.trim()
+ ?: return null
+ return when (value) {
+ TIBETAN -> true
+ LATIN -> false
+ else -> null
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/KeyboardType.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/KeyboardType.kt
similarity index 89%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/ui/KeyboardType.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/KeyboardType.kt
index 4f9b612..fb7cead 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/KeyboardType.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/KeyboardType.kt
@@ -1,4 +1,4 @@
-package com.kharagedition.tibetankeyboard.ui
+package com.kharagedition.tibetankeyboard.ui.keyboard
/**
* Created by kharag on 28,July,2021
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/ProStripState.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/ProStripState.kt
new file mode 100644
index 0000000..412803b
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/ProStripState.kt
@@ -0,0 +1,33 @@
+package com.kharagedition.tibetankeyboard.ui.keyboard
+
+/**
+ * Pure (Android-free) UI state for the keyboard's PRO feature strip, derived from the user's
+ * entitlement. Kept as plain data so the free-vs-PRO presentation rules are unit-testable
+ * without inflating the IME view.
+ *
+ * - Free users: gold upsell pill + Autocomplete chip are visible; all feature icons are dimmed
+ * (locked). Tapping any control routes to the unlock flow.
+ * - PRO users: the pill and Autocomplete chip are hidden (autocomplete just works while typing);
+ * Chat + Translate are full opacity and functional.
+ */
+data class ProStripState(
+ val pillVisible: Boolean,
+ val autocompleteVisible: Boolean,
+ val chatAlpha: Float,
+ val translateAlpha: Float,
+ val autocompleteAlpha: Float,
+) {
+ companion object {
+ const val ACTIVE_ALPHA = 1f
+ const val LOCKED_ALPHA = 0.5f
+
+ fun forPremium(isPremium: Boolean): ProStripState = ProStripState(
+ pillVisible = !isPremium,
+ autocompleteVisible = !isPremium,
+ chatAlpha = if (isPremium) ACTIVE_ALPHA else LOCKED_ALPHA,
+ translateAlpha = if (isPremium) ACTIVE_ALPHA else LOCKED_ALPHA,
+ // Autocomplete is only ever shown to free users, so it always reads as locked.
+ autocompleteAlpha = LOCKED_ALPHA,
+ )
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/SimpleSearchKeyboard.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/SimpleSearchKeyboard.kt
similarity index 96%
rename from app/src/main/java/com/kharagedition/tibetankeyboard/SimpleSearchKeyboard.kt
rename to app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/SimpleSearchKeyboard.kt
index c840336..c6a1573 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/SimpleSearchKeyboard.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/SimpleSearchKeyboard.kt
@@ -1,6 +1,7 @@
-package com.kharagedition.tibetankeyboard
+package com.kharagedition.tibetankeyboard.ui.keyboard
import android.content.Context
+import com.kharagedition.tibetankeyboard.R
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/SuggestionStripView.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/SuggestionStripView.kt
new file mode 100644
index 0000000..6d44cf5
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/SuggestionStripView.kt
@@ -0,0 +1,81 @@
+package com.kharagedition.tibetankeyboard.ui.keyboard
+
+import android.content.Context
+import android.content.res.ColorStateList
+import android.graphics.Color
+import android.graphics.drawable.ColorDrawable
+import android.graphics.drawable.RippleDrawable
+import android.os.Build
+import android.util.AttributeSet
+import android.view.Gravity
+import android.view.View
+import android.widget.LinearLayout
+import android.widget.TextView
+
+/**
+ * Even-width suggestion strip — 4 chips always fill the full keyboard width.
+ * Extends LinearLayout directly (no HorizontalScrollView) so weight distribution works.
+ */
+class SuggestionStripView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : LinearLayout(context, attrs, defStyleAttr) {
+
+ var onSuggestionClick: ((String) -> Unit)? = null
+ private var themeColor = Color.parseColor("#FF704C04")
+
+ private companion object {
+ val GOLD = Color.parseColor("#FFF2C264")
+ val CREAM = Color.parseColor("#FFEBDCC0")
+ }
+
+ init {
+ orientation = HORIZONTAL
+ setBackgroundColor(themeColor)
+ }
+
+ fun setThemeColor(colorInt: Int) {
+ themeColor = colorInt
+ setBackgroundColor(colorInt)
+ }
+
+ fun setSuggestions(suggestions: List) {
+ removeAllViews()
+ suggestions.forEachIndexed { index, word ->
+ if (index > 0) addView(makeDivider())
+ addView(makeChip(word, index == 0))
+ }
+ }
+
+ private fun makeChip(word: String, isTop: Boolean): TextView = TextView(context).apply {
+ text = word
+ textSize = if (isTop) 16f else 15f
+ // Top suggestion is highlighted gold; the rest are warm cream.
+ setTextColor(if (isTop) GOLD else CREAM)
+ typeface = if (isTop) android.graphics.Typeface.DEFAULT_BOLD else android.graphics.Typeface.DEFAULT
+ gravity = Gravity.CENTER
+ setSingleLine(true)
+ // weight=1 so all chips share width equally
+ layoutParams = LayoutParams(0, LayoutParams.MATCH_PARENT, 1f)
+ background = ripple(Color.argb(40, 247, 216, 153))
+ isClickable = true
+ isFocusable = true
+ setOnClickListener { onSuggestionClick?.invoke(word) }
+ }
+
+ private fun makeDivider(): View = View(context).apply {
+ setBackgroundColor(Color.argb(28, 252, 245, 232))
+ layoutParams = LayoutParams(dp(1), dp(18)).apply {
+ gravity = Gravity.CENTER_VERTICAL
+ }
+ }
+
+ private fun ripple(color: Int) =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
+ RippleDrawable(ColorStateList.valueOf(color), ColorDrawable(Color.TRANSPARENT), null)
+ else
+ ColorDrawable(Color.TRANSPARENT)
+
+ private fun dp(v: Int) = (v * resources.displayMetrics.density + 0.5f).toInt()
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/TibetanKeyboardView.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/TibetanKeyboardView.kt
new file mode 100644
index 0000000..d5f9460
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/TibetanKeyboardView.kt
@@ -0,0 +1,62 @@
+package com.kharagedition.tibetankeyboard.ui.keyboard
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.LinearGradient
+import android.graphics.Paint
+import android.graphics.Shader
+import android.inputmethodservice.KeyboardView
+import android.util.AttributeSet
+
+/**
+ * KeyboardView that paints the Enter/return key as a gold cap (the design's signature
+ * accent) on top of whatever key background the current layout uses. Purely visual —
+ * touch handling and key codes are untouched.
+ */
+class TibetanKeyboardView(context: Context?, attrs: AttributeSet?) : KeyboardView(context, attrs) {
+
+ private val goldPaint = Paint(Paint.ANTI_ALIAS_FLAG)
+ private val density = resources.displayMetrics.density
+ private val radius = 10f * density
+ private val inset = 3f * density
+
+ @SuppressLint("DrawAllocation")
+ @Deprecated("Deprecated in Java")
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+ val kb = keyboard ?: return
+ for (key in kb.keys) {
+ val codes = key.codes ?: continue
+ if (codes.isEmpty() || codes[0] != ENTER_CODE) continue
+
+ val left = key.x + inset
+ val top = key.y + inset
+ val right = key.x + key.width - inset
+ val bottom = key.y + key.height - inset
+
+ goldPaint.shader = LinearGradient(left, top, left, bottom, GOLD_TOP, GOLD_BOTTOM, Shader.TileMode.CLAMP)
+ canvas.drawRoundRect(left, top, right, bottom, radius, radius, goldPaint)
+
+ // Re-draw the return icon (super already drew the white one underneath the gold).
+ key.icon?.let { icon ->
+ val d = icon.mutate()
+ val w = d.intrinsicWidth
+ val h = d.intrinsicHeight
+ val cx = key.x + key.width / 2
+ val cy = key.y + key.height / 2
+ d.setBounds(cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2)
+ d.setTint(ESPRESSO)
+ d.draw(canvas)
+ }
+ }
+ }
+
+ companion object {
+ private const val ENTER_CODE = -4 // Keyboard.KEYCODE_DONE
+ private val GOLD_TOP = Color.parseColor("#F2C264")
+ private val GOLD_BOTTOM = Color.parseColor("#C8881F")
+ private val ESPRESSO = Color.parseColor("#1C1305")
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/login/LoginActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/login/LoginActivity.kt
new file mode 100644
index 0000000..689280e
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/login/LoginActivity.kt
@@ -0,0 +1,273 @@
+package com.kharagedition.tibetankeyboard.ui.login
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.google.android.gms.auth.api.signin.GoogleSignIn
+import com.google.android.gms.auth.api.signin.GoogleSignInClient
+import com.google.android.gms.auth.api.signin.GoogleSignInOptions
+import com.google.android.gms.common.api.ApiException
+import com.google.firebase.FirebaseNetworkException
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.FirebaseAuthUserCollisionException
+import com.google.firebase.auth.FirebaseUser
+import com.google.firebase.auth.GoogleAuthProvider
+import com.google.firebase.auth.ktx.auth
+import com.google.firebase.ktx.Firebase
+import com.kharagedition.tibetankeyboard.R
+import com.kharagedition.tibetankeyboard.data.local.UserPreferences
+import com.kharagedition.tibetankeyboard.data.repository.UserRepository
+import com.kharagedition.tibetankeyboard.ui.chat.ChatActivity
+import com.kharagedition.tibetankeyboard.ui.subscription.PremiumActivity
+import com.kharagedition.tibetankeyboard.data.repository.RevenueCatManager
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanKeyboardTheme
+import android.util.Log
+import com.kharagedition.tibetankeyboard.analytics.AppAnalytics
+import com.kharagedition.tibetankeyboard.app.TibetanKeyboardApp
+import kotlinx.coroutines.launch
+
+class LoginActivity : AppCompatActivity() {
+
+ private lateinit var auth: FirebaseAuth
+ private lateinit var googleSignInClient: GoogleSignInClient
+ private lateinit var userPreferences: UserPreferences
+ private lateinit var userRepository: UserRepository
+ private lateinit var revenueCatManager: RevenueCatManager
+
+ private val viewModel: LoginViewModel by viewModels()
+
+ private val googleSignInLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
+ try {
+ val account = task.getResult(ApiException::class.java)!!
+ firebaseAuthWithGoogle(account.idToken!!)
+ } catch (e: ApiException) {
+ hideLoading()
+ Toast.makeText(this, "Google sign in failed: ${e.message}", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Initialize Firebase Auth
+ auth = Firebase.auth
+ userRepository = UserRepository()
+ userPreferences = UserPreferences(this)
+ revenueCatManager = RevenueCatManager.getInstance()
+
+ // Check if user is already signed in
+ if (auth.currentUser != null && userPreferences.isUserLoggedIn()) {
+ Log.d("LoginActivity", "User already logged in, initializing RevenueCat...")
+ initializeRevenueCatAndNavigate()
+ return
+ }
+
+ configureGoogleSignIn()
+
+ setContent {
+ TibetanKeyboardTheme {
+ val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
+ LoginScreen(
+ isLoading = isLoading,
+ onGoogleSignIn = { signInWithGoogle() },
+ onTerms = { openTermsOfService() },
+ onPrivacy = { openPrivacyPolicy() },
+ )
+ }
+ }
+ }
+
+ private fun openTermsOfService() {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://kharagedition.github.io/term-and-condition/tibetan-keyboard.html"))
+ startActivity(intent)
+ }
+
+ private fun openPrivacyPolicy() {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://kharagedition.github.io/privacy-policy/tibetan-keyboard"))
+ startActivity(intent)
+ }
+
+ private fun configureGoogleSignIn() {
+ val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
+ .requestIdToken(getString(R.string.default_web_client_id))
+ .requestEmail()
+ .requestProfile()
+ .build()
+
+ googleSignInClient = GoogleSignIn.getClient(this, gso)
+ }
+
+ private fun signInWithGoogle() {
+ showLoading()
+ val signInIntent = googleSignInClient.signInIntent
+ googleSignInLauncher.launch(signInIntent)
+ }
+
+ private fun firebaseAuthWithGoogle(idToken: String) {
+ val credential = GoogleAuthProvider.getCredential(idToken, null)
+ auth.signInWithCredential(credential)
+ .addOnCompleteListener(this) { task ->
+ hideLoading()
+ if (task.isSuccessful) {
+ val user = auth.currentUser
+ user?.let { firebaseUser ->
+ handleSuccessfulLogin(firebaseUser, task.result?.additionalUserInfo?.isNewUser ?: false)
+ }
+ } else {
+ handleLoginError(task.exception)
+ }
+ }
+ }
+
+ private fun handleSuccessfulLogin(firebaseUser: FirebaseUser, isNewUser: Boolean) {
+ // Product analytics: tie events to this user and record the sign-in.
+ AppAnalytics.setUser(firebaseUser.uid)
+ AppAnalytics.logLogin(isNewUser)
+
+ // Persist login state — this alone is enough to treat the user as signed in.
+ userPreferences.saveUserLoginState(
+ isLoggedIn = true,
+ userId = firebaseUser.uid,
+ userName = firebaseUser.displayName ?: "User",
+ userEmail = firebaseUser.email ?: "",
+ userPhotoUrl = firebaseUser.photoUrl?.toString() ?: ""
+ )
+
+ // Kick off RevenueCat init in the background (idempotent). Destination screens read the
+ // premium entitlement from its LiveData, so the redirect must NOT wait on this callback —
+ // previously a callback that never fired left users stuck on the login screen until they
+ // force-restarted the app.
+ revenueCatManager.initialize(applicationContext, auth, null)
+
+ // Best-effort Firestore profile sync + analytics, off the redirect path. Uses a standalone
+ // scope so finishing LoginActivity below doesn't cancel these writes.
+ val uid = firebaseUser.uid
+ val name = firebaseUser.displayName ?: "User"
+ val email = firebaseUser.email ?: ""
+ val photo = firebaseUser.photoUrl?.toString() ?: ""
+ val hasName = firebaseUser.displayName != null
+ (application as TibetanKeyboardApp).applicationScope.launch {
+ runCatching {
+ userRepository.createOrUpdateUser(
+ uid = uid,
+ displayName = name,
+ email = email,
+ photoUrl = photo,
+ context = applicationContext,
+ isNewUser = isNewUser
+ )
+ }.onFailure { Log.w("LoginActivity", "createOrUpdateUser failed (non-blocking)", it) }
+ runCatching {
+ userRepository.trackUserEvent(
+ uid,
+ if (isNewUser) "user_registration_completed" else "user_login_success",
+ mapOf(
+ "login_method" to "google",
+ "user_email" to email,
+ "has_display_name" to hasName
+ )
+ )
+ }
+ }
+
+ // Redirect immediately — never gated on the network calls above.
+ hideLoading()
+ Toast.makeText(
+ this,
+ if (isNewUser) "Welcome, $name!" else "Welcome back, $name!",
+ Toast.LENGTH_SHORT
+ ).show()
+ navigateToChatActivity()
+ }
+
+ /**
+ * Initialize RevenueCat after login and navigate to next screen
+ */
+ private fun initializeRevenueCatAndNavigate() {
+ Log.d("LoginActivity", "Initializing RevenueCat after login...")
+ revenueCatManager.initialize(this, auth, object : RevenueCatManager.SubscriptionCallback {
+ override fun onSuccess(message: String) {
+ Log.d("LoginActivity", "✅ RevenueCat initialized: $message")
+ navigateToChatActivity()
+ }
+
+ override fun onError(error: String) {
+ Log.e("LoginActivity", "❌ RevenueCat init failed: $error")
+ navigateToChatActivity()
+ }
+
+ override fun onUserCancelled() {}
+ })
+ }
+ private fun handleLoginError(exception: Exception?) {
+ val errorMessage = when (exception) {
+ is FirebaseAuthUserCollisionException -> "An account already exists with this email"
+ is FirebaseNetworkException -> "Network error. Please check your connection"
+ else -> "Authentication failed: ${exception?.message}"
+ }
+
+ Toast.makeText(this, errorMessage, Toast.LENGTH_LONG).show()
+
+ AppAnalytics.logLoginFailed(exception?.message ?: "Unknown error")
+
+ // Track failed login attempt
+ lifecycleScope.launch {
+ userRepository.trackUserEvent(
+ "anonymous",
+ "login_failed",
+ mapOf(
+ "error_message" to (exception?.message ?: "Unknown error"),
+ "login_method" to "google"
+ )
+ )
+ }
+ }
+
+ private fun showLoading() = viewModel.setLoading(true)
+
+ private fun hideLoading() = viewModel.setLoading(false)
+
+ private fun navigateToChatActivity() {
+ // If the login was launched to reach a specific screen (e.g. the AI Translate
+ // screen sends the user through login), return there. Otherwise fall back to the
+ // PRO-upsell → paywall vs normal → chat routing.
+ val target = intent?.getStringExtra(EXTRA_POST_LOGIN_TARGET)
+ val openPremium = intent?.getBooleanExtra(EXTRA_OPEN_PREMIUM_AFTER_LOGIN, false) == true
+ val next = if (!target.isNullOrEmpty()) {
+ Intent().setClassName(this, target)
+ } else when (postLoginDestination(openPremium)) {
+ PostLoginDestination.PREMIUM -> Intent(this, PremiumActivity::class.java)
+ PostLoginDestination.CHAT -> Intent(this, ChatActivity::class.java)
+ }
+ startActivity(next)
+ finish()
+ }
+
+ companion object {
+ /** Set true to route to the premium paywall after a successful login. */
+ const val EXTRA_OPEN_PREMIUM_AFTER_LOGIN = "open_premium_after_login"
+
+ /** Fully-qualified Activity class name to open after a successful login. */
+ const val EXTRA_POST_LOGIN_TARGET = "post_login_target"
+ }
+
+ public override fun onStart() {
+ super.onStart()
+ // Check if user is signed in and update UI accordingly
+ val currentUser = auth.currentUser
+ if (currentUser != null && userPreferences.isUserLoggedIn()) {
+ navigateToChatActivity()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/login/LoginScreen.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/login/LoginScreen.kt
new file mode 100644
index 0000000..58fc42c
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/login/LoginScreen.kt
@@ -0,0 +1,133 @@
+package com.kharagedition.tibetankeyboard.ui.login
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.kharagedition.tibetankeyboard.R
+import com.kharagedition.tibetankeyboard.ui.compose.components.AppIcons
+import com.kharagedition.tibetankeyboard.ui.compose.components.BoText
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanColors
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanTokens
+
+@Composable
+fun LoginScreen(
+ isLoading: Boolean,
+ onGoogleSignIn: () -> Unit,
+ onTerms: () -> Unit,
+ onPrivacy: () -> Unit,
+) {
+ Box(
+ modifier = Modifier.fillMaxSize().background(TibetanColors.Bg900).systemBarsPadding(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ // brand
+ Box(
+ modifier = Modifier.size(80.dp).clip(RoundedCornerShape(22.dp)).background(TibetanTokens.GoldVertical),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(AppIcons.Lotus, null, tint = TibetanColors.Espresso, modifier = Modifier.size(44.dp))
+ }
+ Spacer(Modifier.height(20.dp))
+ Text(stringResource(R.string.app_name), color = TibetanColors.Cream, fontSize = 24.sp, fontWeight = FontWeight.ExtraBold)
+ Spacer(Modifier.height(4.dp))
+ BoText(stringResource(R.string.bo_app_name), color = TibetanColors.Gold300, fontSize = 16.sp)
+
+ Spacer(Modifier.height(40.dp))
+ Text(stringResource(R.string.welcome), color = TibetanColors.Cream, fontSize = 20.sp, fontWeight = FontWeight.Bold)
+ Spacer(Modifier.height(6.dp))
+ Text(
+ stringResource(R.string.login_subtitle),
+ color = TibetanColors.CreamDim, fontSize = 14.sp, lineHeight = 21.sp,
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(Modifier.height(28.dp))
+ GoogleSignInButton(isLoading = isLoading, onClick = onGoogleSignIn)
+ }
+
+ // privacy footer
+ Row(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .statusBarsPadding()
+ .padding(horizontal = 32.dp, vertical = 24.dp),
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(stringResource(R.string.by_signing_in), color = TibetanColors.CreamFaint, fontSize = 12.sp)
+ Row(horizontalArrangement = Arrangement.Center) {
+ Text(
+ stringResource(R.string.terms_of_service),
+ color = TibetanColors.Gold300, fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.clickable(onClick = onTerms),
+ )
+ Text(stringResource(R.string.and_word), color = TibetanColors.CreamFaint, fontSize = 12.sp)
+ Text(
+ stringResource(R.string.privacy_policy),
+ color = TibetanColors.Gold300, fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.clickable(onClick = onPrivacy),
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun GoogleSignInButton(isLoading: Boolean, onClick: () -> Unit) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(54.dp)
+ .clip(RoundedCornerShape(27.dp))
+ .background(TibetanColors.Cream)
+ .clickable(enabled = !isLoading, onClick = onClick),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(color = TibetanColors.Brown600, strokeWidth = 2.5.dp, modifier = Modifier.size(24.dp))
+ } else {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
+ Image(
+ painter = painterResource(R.drawable.ic_google),
+ contentDescription = null,
+ contentScale = ContentScale.Fit,
+ modifier = Modifier.size(20.dp),
+ )
+ Spacer(Modifier.size(12.dp))
+ Text(stringResource(R.string.sign_in_with_google), color = TibetanColors.Espresso, fontSize = 15.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/login/LoginViewModel.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/login/LoginViewModel.kt
new file mode 100644
index 0000000..2a1dc4d
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/login/LoginViewModel.kt
@@ -0,0 +1,19 @@
+package com.kharagedition.tibetankeyboard.ui.login
+
+import androidx.lifecycle.ViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * Holds the Login screen's loading state. The Google/Firebase sign-in flow itself stays in
+ * the Activity because it depends on ActivityResult launchers and Activity-scoped callbacks.
+ */
+class LoginViewModel : ViewModel() {
+ private val _isLoading = MutableStateFlow(false)
+ val isLoading: StateFlow = _isLoading.asStateFlow()
+
+ fun setLoading(loading: Boolean) {
+ _isLoading.value = loading
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/login/PostLoginDestination.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/login/PostLoginDestination.kt
new file mode 100644
index 0000000..2bbb11d
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/login/PostLoginDestination.kt
@@ -0,0 +1,12 @@
+package com.kharagedition.tibetankeyboard.ui.login
+
+/** Screen to open after a successful login. */
+enum class PostLoginDestination { PREMIUM, CHAT }
+
+/**
+ * Pure decision for where to go after login. When the login was launched from a PRO upsell
+ * (the keyboard's PRO strip sets [LoginActivity.EXTRA_OPEN_PREMIUM_AFTER_LOGIN]) we forward to the
+ * paywall; otherwise the chat screen. Separated out so the routing is unit-testable.
+ */
+fun postLoginDestination(openPremiumAfterLogin: Boolean): PostLoginDestination =
+ if (openPremiumAfterLogin) PostLoginDestination.PREMIUM else PostLoginDestination.CHAT
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsActivity.kt
new file mode 100644
index 0000000..ea2c89d
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsActivity.kt
@@ -0,0 +1,87 @@
+package com.kharagedition.tibetankeyboard.ui.settings
+
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.google.android.gms.ads.AdRequest
+import com.google.android.gms.ads.AdSize
+import com.google.android.gms.ads.AdView
+import com.kharagedition.tibetankeyboard.BuildConfig
+import com.kharagedition.tibetankeyboard.R
+import com.kharagedition.tibetankeyboard.analytics.AppAnalytics
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanKeyboardTheme
+import com.kharagedition.tibetankeyboard.util.openPremiumUpgrade
+import com.kharagedition.tibetankeyboard.util.showConfirmationDialog
+import com.kharagedition.tibetankeyboard.util.showToast
+
+/** Settings screen — pure framework glue (banner ad View, navigation, confirm dialog). */
+class SettingsActivity : AppCompatActivity() {
+
+ private val viewModel: SettingsViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ TibetanKeyboardTheme {
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ SettingsScreen(state = state, actions = settingsActions(), adSlot = { BannerAd() })
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ viewModel.refreshPremium()
+ viewModel.reload()
+ }
+
+ private fun settingsActions() = SettingsActions(
+ onBack = { finish() },
+ onColorChange = viewModel::setColor,
+ onStyleChange = viewModel::setStyle,
+ onVibrate = viewModel::setVibrate,
+ onSound = viewModel::setSound,
+ onNotification = viewModel::setNotification,
+ onUpgrade = { openPremiumUpgrade(AppAnalytics.UpgradeSource.SETTINGS) },
+ onLogout = {
+ showConfirmationDialog(
+ title = getString(R.string.sign_out),
+ message = getString(R.string.sign_out_confirm),
+ positiveText = getString(R.string.sign_out),
+ onPositive = { signOut() },
+ )
+ },
+ )
+
+ private fun signOut() {
+ viewModel.signOut {
+ viewModel.redirectToLogin()
+ showToast(getString(R.string.signed_out))
+ }
+ }
+
+ @Composable
+ private fun BannerAd() {
+ AndroidView(factory = { ctx ->
+ AdView(ctx).apply {
+ setAdSize(AdSize.BANNER)
+ adUnitId = if (BuildConfig.DEBUG) {
+ TEST_BANNER_AD_UNIT
+ } else {
+ PROD_BANNER_AD_UNIT
+ }
+ loadAd(AdRequest.Builder().build())
+ }
+ })
+ }
+
+ companion object {
+ private const val PROD_BANNER_AD_UNIT = "ca-app-pub-8284901143739274/3790581011"
+ private const val TEST_BANNER_AD_UNIT = "ca-app-pub-3940256099942544/6300978111"
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsPrefs.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsPrefs.kt
new file mode 100644
index 0000000..2a989b2
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsPrefs.kt
@@ -0,0 +1,115 @@
+package com.kharagedition.tibetankeyboard.ui.settings
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.preference.PreferenceManager
+
+/**
+ * Single source of truth for the keyboard preference keys/values.
+ *
+ * IMPORTANT: these live in [PreferenceManager.getDefaultSharedPreferences], the
+ * exact store the IME service (TibetanKeyboard) reads at runtime — so changing
+ * them here updates the live keyboard. Do not rename the keys/values.
+ */
+object SettingsPrefs {
+ const val KEY_COLOR = "colors"
+ const val KEY_STYLE = "keyboard_style"
+ const val KEY_VIBRATE = "vibrate"
+ const val KEY_SOUND = "sound"
+ const val KEY_NOTIFICATION = "event_notification"
+ const val KEY_AI_MODEL = "ai_model"
+ const val KEY_TRANSLATE_ENGINE = "translate_engine"
+
+ const val COLOR_BROWN = "#FF704C04"
+ const val COLOR_BLACK = "#FF000000"
+ const val COLOR_GREEN = "#FF007500"
+
+ const val STYLE_CLASSIC = "classic"
+ const val STYLE_MODERN = "modern"
+ const val STYLE_BORDERLESS = "borderless"
+
+ // Free-tier defaults. Everything else is PRO-only.
+ const val DEFAULT_COLOR = COLOR_BROWN
+ const val DEFAULT_STYLE = STYLE_BORDERLESS
+
+ // Claude models the user can switch between (must match the backend allow-list).
+ const val MODEL_HAIKU = "claude-haiku-4-5"
+ const val MODEL_SONNET = "claude-sonnet-4-6"
+ const val DEFAULT_MODEL = MODEL_HAIKU
+
+ // Translation engines (must match the backend translateProvider allow-list).
+ // Azure is a separate provider; the two Claude values reuse the model ids.
+ // Azure is the default — it transparently falls back to Claude server-side,
+ // so translation works even before the Azure key is configured.
+ const val ENGINE_AZURE = "azure"
+ const val ENGINE_HAIKU = MODEL_HAIKU
+ const val ENGINE_SONNET = MODEL_SONNET
+ const val DEFAULT_TRANSLATE_ENGINE = ENGINE_AZURE
+
+ // Brown is free; black & green are PRO.
+ val colorOptions = listOf(
+ PrefOption("Brown", "Warm monk-robe brown", COLOR_BROWN, premium = false),
+ PrefOption("Black", "Deep neutral dark", COLOR_BLACK, premium = true),
+ PrefOption("Green", "Forest green", COLOR_GREEN, premium = true),
+ )
+
+ // Borderless is free; the two raised/flat styles are PRO.
+ val styleOptions = listOf(
+ PrefOption("Borderless", "Minimal — glyphs only, no boxes", STYLE_BORDERLESS, premium = false),
+ PrefOption("Flat modern", "Subtle flat keys (Google style)", STYLE_MODERN, premium = true),
+ PrefOption("Raised key caps", "3D gradient key caps", STYLE_CLASSIC, premium = true),
+ )
+
+ val modelOptions = listOf(
+ PrefOption("Haiku 4.5", "Fast & efficient", MODEL_HAIKU, premium = false),
+ PrefOption("Sonnet 4.6", "Most capable", MODEL_SONNET, premium = false),
+ )
+
+ // Translation engine choices shown in the engine selector (Translate screen + keyboard).
+ val translateEngineOptions = listOf(
+ PrefOption("Azure", "Fast neural translation", ENGINE_AZURE, premium = false),
+ PrefOption("Claude Haiku", "Fast & efficient", ENGINE_HAIKU, premium = false),
+ PrefOption("Claude Sonnet", "Most nuanced", ENGINE_SONNET, premium = false),
+ )
+
+ fun colorLabel(value: String) = colorOptions.firstOrNull { it.value == value }?.label ?: "Brown"
+ fun styleLabel(value: String) = styleOptions.firstOrNull { it.value == value }?.label ?: "Borderless"
+ fun modelLabel(value: String) = modelOptions.firstOrNull { it.value == value }?.label ?: "Haiku 4.5"
+ fun engineLabel(value: String) =
+ translateEngineOptions.firstOrNull { it.value == value }?.label ?: "Azure"
+
+ /** True when [value] is a PRO-only keyboard colour. */
+ fun isColorPremium(value: String) = colorOptions.firstOrNull { it.value == value }?.premium == true
+
+ /** True when [value] is a PRO-only keyboard layout. */
+ fun isStylePremium(value: String) = styleOptions.firstOrNull { it.value == value }?.premium == true
+
+ private fun prefs(context: Context): SharedPreferences =
+ PreferenceManager.getDefaultSharedPreferences(context)
+
+ fun read(context: Context): SettingsState {
+ val p = prefs(context)
+ return SettingsState(
+ color = p.getString(KEY_COLOR, DEFAULT_COLOR) ?: DEFAULT_COLOR,
+ style = p.getString(KEY_STYLE, DEFAULT_STYLE) ?: DEFAULT_STYLE,
+ vibrate = p.getBoolean(KEY_VIBRATE, false),
+ sound = p.getBoolean(KEY_SOUND, true),
+ eventNotification = p.getBoolean(KEY_NOTIFICATION, true),
+ )
+ }
+
+ fun readModel(context: Context): String =
+ prefs(context).getString(KEY_AI_MODEL, DEFAULT_MODEL) ?: DEFAULT_MODEL
+
+ fun readTranslateEngine(context: Context): String =
+ prefs(context).getString(KEY_TRANSLATE_ENGINE, DEFAULT_TRANSLATE_ENGINE)
+ ?: DEFAULT_TRANSLATE_ENGINE
+
+ fun putString(context: Context, key: String, value: String) {
+ prefs(context).edit().putString(key, value).apply()
+ }
+
+ fun putBoolean(context: Context, key: String, value: Boolean) {
+ prefs(context).edit().putBoolean(key, value).apply()
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsScreen.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsScreen.kt
new file mode 100644
index 0000000..a3c3708
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsScreen.kt
@@ -0,0 +1,306 @@
+package com.kharagedition.tibetankeyboard.ui.settings
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Icon
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.RadioButtonDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.kharagedition.tibetankeyboard.R
+import com.kharagedition.tibetankeyboard.ui.compose.components.AppIcons
+import com.kharagedition.tibetankeyboard.ui.compose.components.BackHeader
+import com.kharagedition.tibetankeyboard.ui.compose.components.GoldToggle
+import com.kharagedition.tibetankeyboard.ui.compose.components.ScreenScaffold
+import com.kharagedition.tibetankeyboard.ui.compose.components.IconTile
+import com.kharagedition.tibetankeyboard.ui.compose.components.SectionLabel
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanColors
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanTokens
+
+/** A selectable option backing a SharedPreferences value. */
+data class PrefOption(
+ val label: String,
+ val sub: String?,
+ val value: String,
+ val premium: Boolean = false,
+)
+
+data class SettingsState(
+ val color: String = SettingsPrefs.DEFAULT_COLOR,
+ val style: String = SettingsPrefs.DEFAULT_STYLE,
+ val vibrate: Boolean = false,
+ val sound: Boolean = true,
+ val eventNotification: Boolean = true,
+ val isPremium: Boolean = false,
+ val isAuthenticated: Boolean = false,
+)
+
+class SettingsActions(
+ val onBack: () -> Unit,
+ val onColorChange: (String) -> Unit,
+ val onStyleChange: (String) -> Unit,
+ val onVibrate: (Boolean) -> Unit,
+ val onSound: (Boolean) -> Unit,
+ val onNotification: (Boolean) -> Unit,
+ val onUpgrade: () -> Unit,
+ val onLogout: () -> Unit,
+)
+
+@Composable
+fun SettingsScreen(
+ state: SettingsState,
+ actions: SettingsActions,
+ adSlot: (@Composable () -> Unit)? = null,
+) {
+ var dialog by remember { mutableStateOf(null) } // "color" | "style" | null
+
+ ScreenScaffold {
+ BackHeader(stringResource(R.string.title_activity_settings), onBack = actions.onBack)
+
+ Column(Modifier.padding(horizontal = 18.dp)) {
+ SectionLabel(stringResource(R.string.design_and_ui))
+ Spacer(Modifier.height(9.dp))
+ SettingsGroup {
+ SettingsRow(
+ icon = AppIcons.Image,
+ title = stringResource(R.string.keyboard_background_title),
+ subtitle = SettingsPrefs.colorLabel(state.color),
+ onClick = { dialog = "color" },
+ trailing = { Chevron() },
+ )
+ Divider()
+ SettingsRow(
+ icon = AppIcons.Palette,
+ title = stringResource(R.string.keyboard_layout_title),
+ subtitle = SettingsPrefs.styleLabel(state.style),
+ onClick = { dialog = "style" },
+ trailing = { Chevron() },
+ )
+ }
+
+ Spacer(Modifier.height(18.dp))
+ SectionLabel(stringResource(R.string.sound_and_control))
+ Spacer(Modifier.height(9.dp))
+ SettingsGroup {
+ SettingsRow(
+ icon = AppIcons.Vibrate,
+ title = stringResource(R.string.vibrate_on_tap),
+ trailing = { GoldToggle(state.vibrate, actions.onVibrate) },
+ )
+ Divider()
+ SettingsRow(
+ icon = AppIcons.Sound,
+ title = stringResource(R.string.sound_on_tap),
+ trailing = { GoldToggle(state.sound, actions.onSound) },
+ )
+ }
+
+ Spacer(Modifier.height(18.dp))
+ SectionLabel(stringResource(R.string.title_notification))
+ Spacer(Modifier.height(9.dp))
+ SettingsGroup {
+ SettingsRow(
+ icon = AppIcons.Bell,
+ title = stringResource(R.string.event_notifications),
+ trailing = { GoldToggle(state.eventNotification, actions.onNotification) },
+ )
+ }
+
+ Spacer(Modifier.height(18.dp))
+ SectionLabel(stringResource(R.string.title_account))
+ Spacer(Modifier.height(9.dp))
+ SettingsGroup {
+ SettingsRow(
+ icon = AppIcons.Crown,
+ title = stringResource(R.string.subscription),
+ subtitle = stringResource(if (state.isPremium) R.string.premium_plan else R.string.free_plan),
+ trailing = {
+ if (!state.isPremium) {
+ Box(
+ modifier = Modifier
+ .clip(TibetanTokens.Pill)
+ .background(TibetanTokens.GoldVertical)
+ .clickable(onClick = actions.onUpgrade)
+ .padding(horizontal = 12.dp, vertical = 6.dp),
+ ) {
+ Text(stringResource(R.string.upgrade), color = TibetanColors.Espresso, fontSize = 12.5.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+ },
+ )
+ if (state.isAuthenticated) {
+ Divider()
+ SettingsRow(
+ icon = AppIcons.Logout,
+ title = stringResource(R.string.logout),
+ onClick = actions.onLogout,
+ trailing = { Chevron() },
+ )
+ }
+ }
+
+ if (!state.isPremium && adSlot != null) {
+ Spacer(Modifier.height(20.dp))
+ adSlot()
+ }
+ }
+ }
+
+ when (dialog) {
+ "color" -> OptionPickerDialog(
+ title = stringResource(R.string.keyboard_background_title),
+ options = SettingsPrefs.colorOptions,
+ selected = state.color,
+ isPremium = state.isPremium,
+ onSelect = { actions.onColorChange(it); dialog = null },
+ onUpgrade = { dialog = null; actions.onUpgrade() },
+ onDismiss = { dialog = null },
+ )
+ "style" -> OptionPickerDialog(
+ title = stringResource(R.string.keyboard_layout_title),
+ options = SettingsPrefs.styleOptions,
+ selected = state.style,
+ isPremium = state.isPremium,
+ onSelect = { actions.onStyleChange(it); dialog = null },
+ onUpgrade = { dialog = null; actions.onUpgrade() },
+ onDismiss = { dialog = null },
+ )
+ }
+}
+
+@Composable
+private fun SettingsGroup(content: @Composable () -> Unit) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(TibetanColors.Brown700)
+ .border(1.dp, TibetanColors.Line, RoundedCornerShape(16.dp))
+ .padding(horizontal = 14.dp, vertical = 2.dp),
+ ) { content() }
+}
+
+@Composable
+private fun SettingsRow(
+ icon: ImageVector,
+ title: String,
+ subtitle: String? = null,
+ onClick: (() -> Unit)? = null,
+ trailing: @Composable () -> Unit = {},
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
+ .padding(vertical = 11.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ IconTile(icon, size = 40.dp, iconSize = 20.dp, background = TibetanColors.Brown700)
+ Column(Modifier.weight(1f)) {
+ Text(title, color = TibetanColors.Cream, fontSize = 14.5.sp, fontWeight = FontWeight.SemiBold)
+ if (subtitle != null) {
+ Text(subtitle, color = TibetanColors.CreamDim, fontSize = 12.5.sp)
+ }
+ }
+ trailing()
+ }
+}
+
+@Composable
+private fun Divider() {
+ Box(Modifier.fillMaxWidth().height(1.dp).background(TibetanColors.Line))
+}
+
+@Composable
+private fun Chevron() {
+ Icon(AppIcons.Chevron, null, tint = TibetanColors.CreamDim, modifier = Modifier.size(20.dp))
+}
+
+@Composable
+private fun OptionPickerDialog(
+ title: String,
+ options: List,
+ selected: String,
+ isPremium: Boolean,
+ onSelect: (String) -> Unit,
+ onUpgrade: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ containerColor = TibetanColors.Brown700,
+ title = { Text(title, color = TibetanColors.Cream, fontWeight = FontWeight.Bold) },
+ text = {
+ Column {
+ options.forEach { opt ->
+ val locked = opt.premium && !isPremium
+ val onClick = { if (locked) onUpgrade() else onSelect(opt.value) }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .selectable(selected = opt.value == selected, onClick = onClick)
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(
+ selected = opt.value == selected,
+ onClick = onClick,
+ enabled = !locked,
+ colors = RadioButtonDefaults.colors(
+ selectedColor = TibetanColors.Gold300,
+ unselectedColor = TibetanColors.CreamDim,
+ disabledUnselectedColor = TibetanColors.CreamFaint,
+ ),
+ )
+ Column(Modifier.weight(1f).padding(start = 4.dp)) {
+ Text(
+ opt.label,
+ color = if (locked) TibetanColors.CreamDim else TibetanColors.Cream,
+ fontSize = 15.sp,
+ )
+ if (opt.sub != null) Text(opt.sub, color = TibetanColors.CreamDim, fontSize = 12.sp)
+ }
+ if (locked) {
+ Icon(
+ AppIcons.Lock,
+ contentDescription = stringResource(R.string.premium_feature),
+ tint = TibetanColors.Gold300,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.done), color = TibetanColors.Gold300) }
+ },
+ )
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsViewModel.kt
new file mode 100644
index 0000000..ef71df5
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsViewModel.kt
@@ -0,0 +1,72 @@
+package com.kharagedition.tibetankeyboard.ui.settings
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.Observer
+import com.kharagedition.tibetankeyboard.analytics.AppAnalytics
+import com.kharagedition.tibetankeyboard.auth.AuthManager
+import com.kharagedition.tibetankeyboard.data.repository.RevenueCatManager
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+/**
+ * Owns the Settings UI state. Reads/writes the keyboard preferences (the same store the IME
+ * reads) and exposes premium status + auth actions, keeping the Activity free of logic.
+ */
+class SettingsViewModel(app: Application) : AndroidViewModel(app) {
+
+ private val authManager = AuthManager(app)
+ private val premiumLiveData = RevenueCatManager.getInstance().isPremiumUser
+
+ private val _uiState = MutableStateFlow(
+ SettingsPrefs.read(app).copy(isAuthenticated = authManager.isUserAuthenticated())
+ )
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val premiumObserver = Observer { isPremium ->
+ _uiState.update { it.copy(isPremium = isPremium) }
+ }
+
+ init {
+ premiumLiveData.observeForever(premiumObserver)
+ RevenueCatManager.getInstance().refreshCustomerInfo()
+ }
+
+ fun setColor(value: String) = write(SettingsPrefs.KEY_COLOR, value) { it.copy(color = value) }
+ .also { AppAnalytics.logSettingChanged(AppAnalytics.Setting.COLOR, value) }
+ fun setStyle(value: String) = write(SettingsPrefs.KEY_STYLE, value) { it.copy(style = value) }
+ .also { AppAnalytics.logSettingChanged(AppAnalytics.Setting.STYLE, value) }
+ fun setVibrate(on: Boolean) = write(SettingsPrefs.KEY_VIBRATE, on) { it.copy(vibrate = on) }
+ .also { AppAnalytics.logSettingChanged(AppAnalytics.Setting.VIBRATE, on.toString()) }
+ fun setSound(on: Boolean) = write(SettingsPrefs.KEY_SOUND, on) { it.copy(sound = on) }
+ .also { AppAnalytics.logSettingChanged(AppAnalytics.Setting.SOUND, on.toString()) }
+ fun setNotification(on: Boolean) = write(SettingsPrefs.KEY_NOTIFICATION, on) { it.copy(eventNotification = on) }
+ .also { AppAnalytics.logSettingChanged(AppAnalytics.Setting.NOTIFICATION, on.toString()) }
+
+ private fun write(key: String, value: String, reduce: (SettingsState) -> SettingsState) {
+ SettingsPrefs.putString(getApplication(), key, value)
+ _uiState.update(reduce)
+ }
+
+ private fun write(key: String, value: Boolean, reduce: (SettingsState) -> SettingsState) {
+ SettingsPrefs.putBoolean(getApplication(), key, value)
+ _uiState.update(reduce)
+ }
+
+ /** Reload prefs in case they changed elsewhere (e.g. legacy preference screen). */
+ fun reload() {
+ val current = _uiState.value
+ _uiState.value = SettingsPrefs.read(getApplication())
+ .copy(isPremium = current.isPremium, isAuthenticated = authManager.isUserAuthenticated())
+ }
+
+ fun signOut(onComplete: () -> Unit) = authManager.signOut(onComplete)
+ fun redirectToLogin() = authManager.redirectToLogin()
+ fun refreshPremium() = RevenueCatManager.getInstance().refreshCustomerInfo()
+
+ override fun onCleared() {
+ premiumLiveData.removeObserver(premiumObserver)
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/splash/SplashScreenActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/splash/SplashScreenActivity.kt
new file mode 100644
index 0000000..6e2591c
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/splash/SplashScreenActivity.kt
@@ -0,0 +1,135 @@
+package com.kharagedition.tibetankeyboard.ui.splash
+
+import android.content.Intent
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import androidx.activity.compose.setContent
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.google.android.gms.ads.MobileAds
+import com.kharagedition.tibetankeyboard.R
+import com.kharagedition.tibetankeyboard.analytics.AppAnalytics
+import com.kharagedition.tibetankeyboard.service.MyFirebaseMessagingService
+import com.kharagedition.tibetankeyboard.ui.compose.components.AppIcons
+
+import com.kharagedition.tibetankeyboard.ui.compose.components.BoText
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanColors
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanKeyboardTheme
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanTokens
+import com.kharagedition.tibetankeyboard.ui.home.HomeActivity
+
+class SplashScreenActivity : AppCompatActivity() {
+
+ private val splashDelayMs = 1100L
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ MobileAds.initialize(this) {}
+
+ if (intent?.getBooleanExtra(MyFirebaseMessagingService.EXTRA_FROM_NOTIFICATION, false) == true) {
+ AppAnalytics.logNotificationOpened()
+ }
+
+ val versionName = try {
+ packageManager.getPackageInfo(packageName, 0).versionName
+ } catch (_: Exception) {
+ null
+ }
+
+ setContent {
+ TibetanKeyboardTheme {
+ SplashContent(versionName)
+ }
+ }
+
+ Handler(Looper.getMainLooper()).postDelayed({
+ val forceUpdate = intent?.getBooleanExtra(MyFirebaseMessagingService.EXTRA_FORCE_UPDATE, false) == true
+ startActivity(Intent(this, HomeActivity::class.java).apply {
+ if (forceUpdate) putExtra(MyFirebaseMessagingService.EXTRA_FORCE_UPDATE, true)
+ })
+ finish()
+ }, splashDelayMs)
+ }
+}
+
+@Composable
+private fun SplashContent(versionName: String?) {
+ val transition = rememberInfiniteTransition(label = "splash")
+ val glow by transition.animateFloat(
+ initialValue = 0.7f, targetValue = 1f,
+ animationSpec = infiniteRepeatable(tween(900, easing = LinearEasing), RepeatMode.Reverse),
+ label = "glow",
+ )
+
+ Box(modifier = Modifier.fillMaxSize().background(TibetanColors.Bg900), contentAlignment = Alignment.Center) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Box(
+ modifier = Modifier
+ .size(84.dp)
+ .alpha(glow)
+ .clip(RoundedCornerShape(24.dp))
+ .background(TibetanTokens.GoldVertical),
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ painter = painterResource(R.mipmap.ic_launcher),
+ contentDescription = null,
+ modifier = Modifier.size(72.dp,).clip(RoundedCornerShape(10.dp))
+ )
+ }
+ Spacer(Modifier.height(22.dp))
+ Text(stringResource(R.string.app_name), color = TibetanColors.Cream, fontSize = 24.sp, fontWeight = FontWeight.ExtraBold)
+ Spacer(Modifier.height(4.dp))
+ BoText(stringResource(R.string.bo_app_name), color = TibetanColors.Gold300, fontSize = 16.sp)
+ Spacer(Modifier.height(6.dp))
+ Text(stringResource(R.string.author_name), color = TibetanColors.CreamDim, fontSize = 13.sp)
+ }
+
+ Column(
+ modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 44.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ CircularProgressIndicator(
+ color = TibetanColors.Gold400,
+ trackColor = TibetanColors.Brown700,
+ strokeWidth = 3.dp,
+ modifier = Modifier.size(30.dp),
+ )
+ if (versionName != null) {
+ Spacer(Modifier.height(14.dp))
+ Text(stringResource(R.string.version_label, versionName), color = TibetanColors.CreamFaint, fontSize = 12.sp)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumActivity.kt
new file mode 100644
index 0000000..031b33e
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumActivity.kt
@@ -0,0 +1,74 @@
+package com.kharagedition.tibetankeyboard.ui.subscription
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.kharagedition.tibetankeyboard.R
+import com.kharagedition.tibetankeyboard.analytics.AppAnalytics
+import com.kharagedition.tibetankeyboard.data.repository.RevenueCatManager
+import com.kharagedition.tibetankeyboard.data.repository.subscriptionCallback
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanKeyboardTheme
+import com.kharagedition.tibetankeyboard.ui.home.HomeActivity
+import com.kharagedition.tibetankeyboard.util.showToast
+
+/** Compose paywall. UI state in [PremiumViewModel]; purchase/restore call RevenueCat with this Activity. */
+class PremiumActivity : AppCompatActivity() {
+
+ private val viewModel: PremiumViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ AppAnalytics.logPaywallViewed()
+ setContent {
+ TibetanKeyboardTheme {
+ val priceLabel by viewModel.priceLabel.collectAsStateWithLifecycle()
+ val isPremium by viewModel.isPremium.collectAsStateWithLifecycle()
+ // Premium users have nothing to buy — close the paywall.
+ LaunchedEffect(isPremium) { if (isPremium) closePaywall() }
+
+ PremiumScreen(
+ priceLabel = priceLabel,
+ onBack = { closePaywall() },
+ onPurchase = { purchasePremium() },
+ onRestore = { restorePurchases() },
+ )
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ viewModel.refreshPrice()
+ }
+
+ /**
+ * Close the paywall without ever leaving an empty back stack. If this is the task root
+ * (e.g. reached right after a login that finished the launching screen), go to Home.
+ */
+ private fun closePaywall() {
+ if (isTaskRoot) {
+ startActivity(Intent(this, HomeActivity::class.java))
+ }
+ finish()
+ }
+
+ private fun purchasePremium() {
+ AppAnalytics.logPurchaseStarted()
+ RevenueCatManager.getInstance().purchasePremium(this, subscriptionCallback(
+ onSuccess = { AppAnalytics.logPurchaseCompleted(); showToast(it); closePaywall() },
+ onError = { AppAnalytics.logPurchaseFailed(it); showToast(it) },
+ ))
+ }
+
+ private fun restorePurchases() {
+ RevenueCatManager.getInstance().syncPurchases(subscriptionCallback(
+ onSuccess = { AppAnalytics.logPurchaseRestored(); showToast(getString(R.string.purchases_restored)) },
+ onError = { showToast(it) },
+ ))
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumScreen.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumScreen.kt
new file mode 100644
index 0000000..a993915
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumScreen.kt
@@ -0,0 +1,145 @@
+package com.kharagedition.tibetankeyboard.ui.subscription
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.kharagedition.tibetankeyboard.R
+import com.kharagedition.tibetankeyboard.ui.compose.components.AppIcons
+import com.kharagedition.tibetankeyboard.ui.compose.components.BackHeader
+import com.kharagedition.tibetankeyboard.ui.compose.components.GoldButton
+import com.kharagedition.tibetankeyboard.ui.compose.components.IconTile
+import com.kharagedition.tibetankeyboard.ui.compose.components.ScreenScaffold
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanColors
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanTokens
+
+private data class Feature(val icon: ImageVector, val title: String, val desc: String)
+
+@Composable
+fun PremiumScreen(
+ priceLabel: String,
+ onBack: () -> Unit,
+ onPurchase: () -> Unit,
+ onRestore: () -> Unit,
+) {
+ val features = listOf(
+ Feature(AppIcons.NoAds, "Remove all ads", "A clean, distraction-free keyboard"),
+ Feature(AppIcons.Bot, "AI Chat assistant", "Chat & compose in Tibetan with AI"),
+ Feature(AppIcons.Sparkle, "Next-word suggestions", "Smart Tibetan word prediction"),
+ Feature(AppIcons.Palette, "Premium themes", "Exclusive keyboard layouts & colors"),
+ Feature(AppIcons.Translate, "AI Translate", "Tibetan ⇄ English ⇄ Chinese with AI"),
+ )
+
+ ScreenScaffold(bottomPadding = 16.dp) {
+ BackHeader(stringResource(R.string.premium_title), onBack = onBack)
+
+ // hero
+ Box(
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(22.dp))
+ .background(
+ Brush.verticalGradient(
+ listOf(TibetanColors.MaroonDeep, TibetanColors.Maroon, TibetanColors.MaroonDeep)
+ )
+ )
+ .border(1.dp, TibetanColors.Gold200.copy(alpha = 0.25f), RoundedCornerShape(22.dp))
+ .padding(horizontal = 20.dp, vertical = 24.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Box(
+ modifier = Modifier.size(60.dp).clip(RoundedCornerShape(18.dp)).background(TibetanTokens.GoldVerticalBright),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(AppIcons.Crown, null, tint = TibetanColors.Espresso, modifier = Modifier.size(32.dp))
+ }
+ Spacer(Modifier.height(12.dp))
+ Text(stringResource(R.string.unlock_everything), color = TibetanColors.Cream, fontSize = 22.sp, fontWeight = FontWeight.ExtraBold)
+ Spacer(Modifier.height(5.dp))
+ Text(
+ "Power up your Tibetan typing with AI,\nsmart suggestions and no ads.",
+ color = TibetanColors.Cream2, fontSize = 13.5.sp, lineHeight = 20.sp,
+ textAlign = androidx.compose.ui.text.style.TextAlign.Center,
+ )
+ }
+ }
+
+ // features
+ Column(
+ modifier = Modifier.padding(horizontal = 18.dp, vertical = 20.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ features.forEach { f ->
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
+ IconTile(f.icon, size = 42.dp, iconSize = 21.dp)
+ Column(Modifier.weight(1f)) {
+ Text(f.title, color = TibetanColors.Cream, fontSize = 14.5.sp, fontWeight = FontWeight.Bold)
+ Text(f.desc, color = TibetanColors.CreamDim, fontSize = 12.5.sp)
+ }
+ Icon(AppIcons.Check, null, tint = TibetanColors.Gold300, modifier = Modifier.size(20.dp))
+ }
+ }
+ }
+
+ // price + cta
+ Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(TibetanColors.Brown600)
+ .border(1.5.dp, TibetanColors.Gold400, RoundedCornerShape(16.dp))
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(stringResource(R.string.monthly), color = TibetanColors.Cream, fontSize = 15.sp, fontWeight = FontWeight.Bold)
+ Text(stringResource(R.string.cancel_anytime), color = TibetanColors.CreamDim, fontSize = 12.sp)
+ }
+ Text(priceLabel, color = TibetanColors.Gold300, fontSize = 20.sp, fontWeight = FontWeight.ExtraBold)
+ }
+ Spacer(Modifier.height(12.dp))
+ GoldButton(stringResource(R.string.start_subscription), onClick = onPurchase)
+ Spacer(Modifier.height(11.dp))
+ Text(
+ stringResource(R.string.restore_purchase),
+ color = TibetanColors.Gold300, fontSize = 12.5.sp, fontWeight = FontWeight.Bold,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onRestore)
+ .padding(vertical = 4.dp),
+ textAlign = androidx.compose.ui.text.style.TextAlign.Center,
+ )
+ Spacer(Modifier.height(4.dp))
+ Text(
+ stringResource(R.string.terms_privacy),
+ color = TibetanColors.CreamFaint, fontSize = 11.5.sp,
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = androidx.compose.ui.text.style.TextAlign.Center,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumViewModel.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumViewModel.kt
new file mode 100644
index 0000000..4c20228
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumViewModel.kt
@@ -0,0 +1,43 @@
+package com.kharagedition.tibetankeyboard.ui.subscription
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.Observer
+import com.kharagedition.tibetankeyboard.data.repository.RevenueCatManager
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/** Holds the paywall's display state (price + premium status). Purchasing stays in the
+ * Activity since RevenueCat requires an Activity reference. */
+class PremiumViewModel(app: Application) : AndroidViewModel(app) {
+
+ private val premiumLiveData = RevenueCatManager.getInstance().isPremiumUser
+
+ private val _priceLabel = MutableStateFlow(
+ RevenueCatManager.getInstance().getPremiumPackageInfo().second ?: DEFAULT_PRICE
+ )
+ val priceLabel: StateFlow = _priceLabel.asStateFlow()
+
+ private val _isPremium = MutableStateFlow(false)
+ val isPremium: StateFlow = _isPremium.asStateFlow()
+
+ private val premiumObserver = Observer { _isPremium.value = it }
+
+ init {
+ premiumLiveData.observeForever(premiumObserver)
+ RevenueCatManager.getInstance().refreshCustomerInfo()
+ }
+
+ fun refreshPrice() {
+ _priceLabel.value = RevenueCatManager.getInstance().getPremiumPackageInfo().second ?: DEFAULT_PRICE
+ }
+
+ override fun onCleared() {
+ premiumLiveData.removeObserver(premiumObserver)
+ }
+
+ companion object {
+ private const val DEFAULT_PRICE = "Premium"
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/translate/TranslateActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/translate/TranslateActivity.kt
new file mode 100644
index 0000000..b2e7099
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/translate/TranslateActivity.kt
@@ -0,0 +1,73 @@
+package com.kharagedition.tibetankeyboard.ui.translate
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.kharagedition.tibetankeyboard.R
+import com.kharagedition.tibetankeyboard.analytics.AppAnalytics
+import com.kharagedition.tibetankeyboard.auth.AuthManager
+import com.kharagedition.tibetankeyboard.data.repository.subscriptionCallback
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanKeyboardTheme
+import com.kharagedition.tibetankeyboard.util.openPremiumUpgrade
+import com.kharagedition.tibetankeyboard.util.showToast
+
+/** AI Translate screen — pure framework glue (auth, clipboard, purchase, navigation). */
+class TranslateActivity : AppCompatActivity() {
+
+ private val viewModel: TranslateViewModel by viewModels()
+ private lateinit var authManager: AuthManager
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ authManager = AuthManager(this)
+
+ if (!authManager.isUserAuthenticated()) {
+ authManager.redirectToLogin(target = TranslateActivity::class.java)
+ return
+ }
+
+ authManager.initializeUserSession(subscriptionCallback(
+ onError = { android.util.Log.w("TranslateActivity", "RevenueCat init: $it") },
+ ))
+
+ AppAnalytics.logTranslateOpened()
+
+ setContent {
+ TibetanKeyboardTheme {
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ TranslateScreen(state = state, actions = translateActions())
+ }
+ }
+ }
+
+ private fun translateActions() = TranslateActions(
+ onBack = { finish() },
+ onInputChange = viewModel::setInput,
+ onSourceLang = viewModel::setSourceLang,
+ onTargetLang = viewModel::setTargetLang,
+ onSwap = viewModel::swapLanguages,
+ onTranslate = viewModel::translate,
+ onModelChange = viewModel::setModel,
+ onCopy = { text ->
+ val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ clipboard.setPrimaryClip(ClipData.newPlainText("translation", text))
+ showToast(getString(R.string.copied))
+ },
+ onUpgrade = { openPremiumUpgrade(AppAnalytics.UpgradeSource.TRANSLATE) },
+ )
+
+ override fun onResume() {
+ super.onResume()
+ if (!authManager.isUserAuthenticated()) {
+ authManager.redirectToLogin(target = TranslateActivity::class.java)
+ } else {
+ viewModel.refreshPremium()
+ }
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/translate/TranslateScreen.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/translate/TranslateScreen.kt
new file mode 100644
index 0000000..05a4a16
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/translate/TranslateScreen.kt
@@ -0,0 +1,306 @@
+package com.kharagedition.tibetankeyboard.ui.translate
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.PlatformImeOptions
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.kharagedition.tibetankeyboard.R
+import com.kharagedition.tibetankeyboard.ui.compose.components.AppIcons
+import com.kharagedition.tibetankeyboard.ui.compose.components.BackHeader
+import com.kharagedition.tibetankeyboard.ui.compose.components.CharCounter
+import com.kharagedition.tibetankeyboard.ui.compose.components.ModelSelector
+import com.kharagedition.tibetankeyboard.ui.compose.components.ScreenScaffold
+import com.kharagedition.tibetankeyboard.ui.compose.components.SectionLabel
+import com.kharagedition.tibetankeyboard.ui.keyboard.KeyboardLayoutHint
+import com.kharagedition.tibetankeyboard.ui.compose.theme.LocalTibetanFont
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanColors
+import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanTokens
+import com.kharagedition.tibetankeyboard.ui.settings.SettingsPrefs
+import com.kharagedition.tibetankeyboard.util.AiLimits
+
+class TranslateActions(
+ val onBack: () -> Unit,
+ val onInputChange: (String) -> Unit,
+ val onSourceLang: (String) -> Unit,
+ val onTargetLang: (String) -> Unit,
+ val onSwap: () -> Unit,
+ val onTranslate: () -> Unit,
+ val onModelChange: (String) -> Unit,
+ val onCopy: (String) -> Unit,
+ val onUpgrade: () -> Unit,
+)
+
+private val LANGUAGES = listOf("bo", "en", "zh-CN")
+
+@Composable
+private fun langLabel(code: String): String = when (code) {
+ "bo" -> stringResource(R.string.lang_tibetan)
+ "en" -> stringResource(R.string.lang_english)
+ "zh-CN" -> stringResource(R.string.lang_chinese)
+ else -> code
+}
+
+@Composable
+fun TranslateScreen(state: TranslateUiState, actions: TranslateActions) {
+ val focusManager = LocalFocusManager.current
+ val keyboardController = LocalSoftwareKeyboardController.current
+ ScreenScaffold(horizontalPadding = 18.dp) {
+ BackHeader(stringResource(R.string.ai_translate), onBack = actions.onBack)
+
+ Spacer(Modifier.height(6.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ModelSelector(
+ model = state.model,
+ onModelChange = actions.onModelChange,
+ options = SettingsPrefs.translateEngineOptions,
+ labelOf = SettingsPrefs::engineLabel,
+ )
+ }
+
+ Spacer(Modifier.height(14.dp))
+ LanguageBar(state, actions)
+
+ Spacer(Modifier.height(14.dp))
+ InputCard(state, actions)
+
+ Spacer(Modifier.height(14.dp))
+ TranslateButton(
+ isLoading = state.isLoading,
+ enabled = state.input.length <= AiLimits.MAX_INPUT_CHARS,
+ onClick = {
+ // Dismiss the soft keyboard so the result is fully visible.
+ // hide() is what actually collapses the IME; clearFocus() alone
+ // does not on most devices.
+ keyboardController?.hide()
+ focusManager.clearFocus(force = true)
+ if (!state.isPremium) actions.onUpgrade() else actions.onTranslate()
+ },
+ )
+
+ Spacer(Modifier.height(18.dp))
+ OutputCard(state, actions)
+ }
+}
+
+@Composable
+private fun LanguageBar(state: TranslateUiState, actions: TranslateActions) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(14.dp))
+ .background(TibetanColors.Brown700)
+ .border(1.dp, TibetanColors.Line, RoundedCornerShape(14.dp))
+ .padding(horizontal = 10.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ LanguageChip(state.sourceLang, Modifier.weight(1f), onPick = actions.onSourceLang)
+ Box(
+ modifier = Modifier
+ .size(34.dp)
+ .clip(RoundedCornerShape(99.dp))
+ .background(TibetanColors.Brown600)
+ .clickable(onClick = actions.onSwap),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ AppIcons.SwapHoriz,
+ contentDescription = stringResource(R.string.cd_swap_languages),
+ tint = TibetanColors.Gold300,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ LanguageChip(state.targetLang, Modifier.weight(1f), onPick = actions.onTargetLang)
+ }
+}
+
+@Composable
+private fun LanguageChip(code: String, modifier: Modifier = Modifier, onPick: (String) -> Unit) {
+ var open by remember { mutableStateOf(false) }
+ Box(modifier = modifier) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(10.dp))
+ .clickable { open = true }
+ .padding(horizontal = 12.dp, vertical = 9.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ Text(langLabel(code), color = TibetanColors.Cream, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
+ Icon(AppIcons.ExpandMore, null, tint = TibetanColors.CreamDim, modifier = Modifier.size(16.dp))
+ }
+ DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
+ LANGUAGES.forEach { lang ->
+ DropdownMenuItem(
+ text = { Text(langLabel(lang)) },
+ onClick = { open = false; onPick(lang) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun InputCard(state: TranslateUiState, actions: TranslateActions) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(TibetanTokens.RadiusCard)
+ .background(TibetanColors.Brown700)
+ .border(1.dp, TibetanColors.Line, TibetanTokens.RadiusCard)
+ .padding(16.dp),
+ ) {
+ SectionLabel(langLabel(state.sourceLang), color = TibetanColors.CreamDim)
+ Spacer(Modifier.height(10.dp))
+ Box(modifier = Modifier.fillMaxWidth().heightIn(min = 96.dp)) {
+ if (state.input.isEmpty()) {
+ Text(
+ stringResource(R.string.translate_input_hint),
+ color = TibetanColors.CreamDim,
+ fontSize = 16.sp,
+ fontFamily = LocalTibetanFont.current,
+ )
+ }
+ BasicTextField(
+ value = state.input,
+ onValueChange = actions.onInputChange,
+ textStyle = TextStyle(color = TibetanColors.Cream, fontSize = 16.sp, fontFamily = LocalTibetanFont.current),
+ cursorBrush = SolidColor(TibetanColors.Gold300),
+ // Tell our IME which script the source language expects so it opens
+ // on the right layout (QWERTY for an English source, Uchen for
+ // Tibetan) instead of leaving the user on the last-used language.
+ keyboardOptions = remember(state.sourceLang) {
+ KeyboardOptions(
+ platformImeOptions = PlatformImeOptions(
+ privateImeOptions = KeyboardLayoutHint.privateImeOption(state.sourceLang),
+ ),
+ )
+ },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ Spacer(Modifier.height(8.dp))
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ CharCounter(current = state.input.length)
+ }
+ }
+}
+
+@Composable
+private fun TranslateButton(isLoading: Boolean, enabled: Boolean, onClick: () -> Unit) {
+ val active = enabled && !isLoading
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(14.dp))
+ .background(if (enabled) TibetanTokens.GoldVertical else SolidColor(TibetanColors.Brown600))
+ .clickable(enabled = active, onClick = onClick)
+ .padding(vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ val contentColor = if (enabled) TibetanColors.Espresso else TibetanColors.CreamDim
+ if (isLoading) {
+ CircularProgressIndicator(
+ color = contentColor,
+ strokeWidth = 2.dp,
+ modifier = Modifier.size(20.dp),
+ )
+ } else {
+ Icon(AppIcons.Translate, null, tint = contentColor, modifier = Modifier.size(20.dp))
+ Spacer(Modifier.size(8.dp))
+ Text(stringResource(R.string.translate), color = contentColor, fontSize = 15.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+}
+
+@Composable
+private fun OutputCard(state: TranslateUiState, actions: TranslateActions) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(TibetanTokens.RadiusCard)
+ .background(TibetanColors.Brown600)
+ .border(1.dp, TibetanColors.Line, TibetanTokens.RadiusCard)
+ .padding(16.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ SectionLabel(langLabel(state.targetLang), color = TibetanColors.Gold300)
+ if (state.output.isNotEmpty()) {
+ Row(
+ modifier = Modifier.clickable { actions.onCopy(state.output) },
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(5.dp),
+ ) {
+ Icon(AppIcons.Copy, null, tint = TibetanColors.CreamDim, modifier = Modifier.size(15.dp))
+ Text(stringResource(R.string.copy), color = TibetanColors.CreamDim, fontSize = 12.sp)
+ }
+ }
+ }
+ Spacer(Modifier.height(10.dp))
+ val body = when {
+ state.error != null -> state.error
+ state.output.isNotEmpty() -> state.output
+ else -> stringResource(R.string.translate_empty_hint)
+ }
+ Text(
+ body,
+ color = when {
+ state.error != null -> TibetanColors.Cream2
+ state.output.isNotEmpty() -> TibetanColors.Cream
+ else -> TibetanColors.CreamDim
+ },
+ fontSize = 16.sp,
+ lineHeight = 26.sp,
+ fontFamily = LocalTibetanFont.current,
+ modifier = Modifier.fillMaxWidth().heightIn(min = 80.dp),
+ )
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/translate/TranslateViewModel.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/translate/TranslateViewModel.kt
new file mode 100644
index 0000000..8d054e1
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/translate/TranslateViewModel.kt
@@ -0,0 +1,114 @@
+package com.kharagedition.tibetankeyboard.ui.translate
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.Observer
+import androidx.lifecycle.viewModelScope
+import com.kharagedition.tibetankeyboard.analytics.AppAnalytics
+import com.kharagedition.tibetankeyboard.data.repository.AIService
+import com.kharagedition.tibetankeyboard.data.repository.RevenueCatManager
+import com.kharagedition.tibetankeyboard.ui.settings.SettingsPrefs
+import com.kharagedition.tibetankeyboard.util.AiLimits
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+/** Immutable UI state for the AI Translate screen. */
+data class TranslateUiState(
+ val sourceLang: String = "bo",
+ val targetLang: String = "en",
+ val input: String = "",
+ val output: String = "",
+ val isLoading: Boolean = false,
+ val model: String = SettingsPrefs.DEFAULT_TRANSLATE_ENGINE,
+ val isPremium: Boolean = false,
+ val error: String? = null,
+)
+
+/**
+ * Owns the AI Translate state. Translation runs through [AIService] (Claude
+ * backend) using the user-selected model; premium status is observed from
+ * RevenueCat.
+ */
+class TranslateViewModel(app: Application) : AndroidViewModel(app) {
+
+ private val aiService = AIService()
+ private val premiumLiveData = RevenueCatManager.getInstance().isPremiumUser
+
+ private val _uiState = MutableStateFlow(
+ TranslateUiState(model = SettingsPrefs.readTranslateEngine(app))
+ )
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val premiumObserver = Observer { isPremium ->
+ _uiState.update { it.copy(isPremium = isPremium) }
+ }
+
+ init {
+ premiumLiveData.observeForever(premiumObserver)
+ RevenueCatManager.getInstance().refreshCustomerInfo()
+ }
+
+ fun setInput(value: String) = _uiState.update { it.copy(input = value) }
+
+ fun setSourceLang(value: String) = _uiState.update { it.copy(sourceLang = value) }
+
+ fun setTargetLang(value: String) = _uiState.update { it.copy(targetLang = value) }
+
+ fun swapLanguages() {
+ AppAnalytics.logTranslateLanguagesSwapped()
+ _uiState.update {
+ it.copy(sourceLang = it.targetLang, targetLang = it.sourceLang, input = it.output, output = it.input)
+ }
+ }
+
+ fun setModel(value: String) {
+ SettingsPrefs.putString(getApplication(), SettingsPrefs.KEY_TRANSLATE_ENGINE, value)
+ _uiState.update { it.copy(model = value) }
+ AppAnalytics.logTranslateEngineChanged(value)
+ }
+
+ fun refreshPremium() = RevenueCatManager.getInstance().refreshCustomerInfo()
+
+ fun translate() {
+ val state = _uiState.value
+ if (state.input.isBlank() || state.isLoading) return
+ // Mirror the backend per-request cap (api-backend constants.ts). Guard
+ // here too so the abuse limit holds even if the UI button is bypassed.
+ if (state.input.length > AiLimits.MAX_INPUT_CHARS) {
+ _uiState.update {
+ it.copy(error = "Maximum ${AiLimits.MAX_INPUT_CHARS} characters allowed.", output = "")
+ }
+ return
+ }
+
+ _uiState.update { it.copy(isLoading = true, error = null, output = "") }
+ viewModelScope.launch {
+ val result = aiService.translateText(
+ text = state.input.trim(),
+ sourceLang = state.sourceLang,
+ targetLang = state.targetLang,
+ model = state.model,
+ )
+ AppAnalytics.logTranslatePerformed(
+ sourceLang = state.sourceLang,
+ targetLang = state.targetLang,
+ engine = state.model,
+ success = result.error == null,
+ )
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ output = if (result.error == null) result.translatedText else "",
+ error = result.error,
+ )
+ }
+ }
+ }
+
+ override fun onCleared() {
+ premiumLiveData.removeObserver(premiumObserver)
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/util/AiLimits.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/util/AiLimits.kt
new file mode 100644
index 0000000..c139027
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/util/AiLimits.kt
@@ -0,0 +1,43 @@
+package com.kharagedition.tibetankeyboard.util
+
+/**
+ * Frontend mirror of the backend AI request limits.
+ *
+ * The single source of truth lives on the server in
+ * `api-backend/src/config/constants.ts` (the `LIMITS` object). Keep these values
+ * in sync: the backend rejects any request that exceeds them, so the app
+ * validates locally first to give instant feedback and avoid burning a wasted
+ * round-trip and paid AI tokens.
+ */
+object AiLimits {
+ /**
+ * Hard per-request input cap. The backend applies this GLOBALLY to every
+ * text-bearing field (chat message / translate text) on every endpoint, for
+ * BOTH free and pro users — it's the abuse guard against pasting a whole
+ * document into a single call. Mirrors `LIMITS.MAX_INPUT_CHARS`.
+ */
+ const val MAX_INPUT_CHARS = 2000
+
+ /** Minimum non-blank length. Mirrors `LIMITS.MIN_TEXT_LENGTH`. */
+ const val MIN_TEXT_LENGTH = 1
+
+ /**
+ * Free-tier daily CHARACTER cap for translation (pro users unlimited).
+ * Mirrors `LIMITS.DAILY_TRANSLATION_CHARS`. In-app these features are
+ * pro-gated, so this is enforced server-side; kept here for parity.
+ */
+ const val DAILY_TRANSLATION_CHARS = 5000
+
+ /**
+ * Free-tier daily MESSAGE cap for chat (pro users unlimited).
+ * Mirrors `LIMITS.FREE_DAILY_CHAT_MESSAGES`.
+ */
+ const val FREE_DAILY_CHAT_MESSAGES = 100
+
+ /** True when [length] exceeds the hard per-request cap. */
+ fun isOverInputLimit(length: Int): Boolean = length > MAX_INPUT_CHARS
+
+ /** True when [text] is a valid, sendable input (non-blank and within the cap). */
+ fun isValidInput(text: String): Boolean =
+ text.trim().length in MIN_TEXT_LENGTH..MAX_INPUT_CHARS
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/util/ApiErrors.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/util/ApiErrors.kt
new file mode 100644
index 0000000..2819050
--- /dev/null
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/util/ApiErrors.kt
@@ -0,0 +1,72 @@
+package com.kharagedition.tibetankeyboard.util
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import retrofit2.HttpException
+import java.io.IOException
+import java.net.SocketTimeoutException
+import java.net.UnknownHostException
+
+/**
+ * Turns any network/API failure into a single, user-friendly [ApiError] so the
+ * UI never shows a raw exception string (e.g. "HTTP 429", "Unable to resolve
+ * host…"). The [ApiError.type] lets a screen react (e.g. show an upgrade prompt
+ * on [ApiErrorType.RATE_LIMITED]); the [ApiError.message] is a clean English
+ * sentence — for non-2xx responses we prefer the backend's own `message` field
+ * (which is already written to be user-facing, e.g. the daily-limit copy).
+ */
+enum class ApiErrorType {
+ NO_INTERNET,
+ TIMEOUT,
+ RATE_LIMITED,
+ BAD_REQUEST,
+ SERVER,
+ UNKNOWN,
+}
+
+data class ApiError(
+ val type: ApiErrorType,
+ val message: String,
+)
+
+@Serializable
+private data class ApiErrorBody(
+ val error: String? = null,
+ val message: String? = null,
+)
+
+object ApiErrors {
+
+ // Default English copy (used when the server sends no usable message).
+ const val NO_INTERNET = "No internet connection. Please check your network and try again."
+ const val TIMEOUT = "The request timed out. Please try again."
+ const val RATE_LIMITED = "You've reached the limit for now. Please try again later."
+ const val BAD_REQUEST = "We couldn't process that request. Please try again."
+ const val SERVER = "Our service is temporarily unavailable. Please try again shortly."
+ const val UNKNOWN = "Something went wrong. Please try again."
+
+ private val json = Json { ignoreUnknownKeys = true; isLenient = true }
+
+ fun from(t: Throwable): ApiError = when (t) {
+ is HttpException -> fromHttp(t)
+ is SocketTimeoutException -> ApiError(ApiErrorType.TIMEOUT, TIMEOUT)
+ is UnknownHostException -> ApiError(ApiErrorType.NO_INTERNET, NO_INTERNET)
+ is IOException -> ApiError(ApiErrorType.NO_INTERNET, NO_INTERNET)
+ else -> ApiError(ApiErrorType.UNKNOWN, UNKNOWN)
+ }
+
+ private fun fromHttp(e: HttpException): ApiError {
+ val serverMessage = runCatching {
+ e.response()?.errorBody()?.string()
+ ?.takeIf { it.isNotBlank() }
+ ?.let { json.decodeFromString(it).message }
+ }.getOrNull()?.takeIf { it.isNotBlank() }
+
+ return when (e.code()) {
+ 429 -> ApiError(ApiErrorType.RATE_LIMITED, serverMessage ?: RATE_LIMITED)
+ in 400..499 -> ApiError(ApiErrorType.BAD_REQUEST, serverMessage ?: BAD_REQUEST)
+ in 500..599 -> ApiError(ApiErrorType.SERVER, serverMessage ?: SERVER)
+ else -> ApiError(ApiErrorType.UNKNOWN, serverMessage ?: UNKNOWN)
+ }
+ }
+}
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/util/BottomSheetDialog.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/util/BottomSheetDialog.kt
deleted file mode 100644
index 2b1d8e4..0000000
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/util/BottomSheetDialog.kt
+++ /dev/null
@@ -1,129 +0,0 @@
-package com.kharagedition.tibetankeyboard.util
-
-import android.content.Intent
-import android.net.Uri
-import android.os.Bundle
-import android.util.Log
-import android.view.LayoutInflater
-import android.view.View
-import android.view.View.VISIBLE
-import android.view.ViewGroup
-import com.google.android.gms.ads.*
-import com.google.android.material.bottomsheet.BottomSheetDialogFragment
-import com.kharagedition.tibetankeyboard.R
-import com.kharagedition.tibetankeyboard.databinding.BottomSheetLayoutBinding
-
-class BottomSheetDialog(showAd: Boolean) : BottomSheetDialogFragment(),View.OnClickListener {
- lateinit var bottomSheetLayoutBinding: BottomSheetLayoutBinding
- private var showAd: Boolean = false
- init {
- this.showAd = showAd
- }
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ) : View {
- bottomSheetLayoutBinding = BottomSheetLayoutBinding.inflate(layoutInflater,container,false)
-
- bottomSheetLayoutBinding.aboutToolbar.setNavigationIcon(R.drawable.ic_baseline_arrow_back_ios_24)
- bottomSheetLayoutBinding.aboutToolbar.setNavigationOnClickListener {
- dismiss()
- }
- initAdmobAds()
- bottomSheetLayoutBinding.share.setOnClickListener(this)
- bottomSheetLayoutBinding.rate.setOnClickListener(this)
- bottomSheetLayoutBinding.gmail.setOnClickListener(this)
- bottomSheetLayoutBinding.github.setOnClickListener(this)
- bottomSheetLayoutBinding.instagram.setOnClickListener(this)
- bottomSheetLayoutBinding.facebook.setOnClickListener(this)
- bottomSheetLayoutBinding.downloadCalendar.setOnClickListener(this)
- bottomSheetLayoutBinding.downloadPrayer.setOnClickListener(this)
- return bottomSheetLayoutBinding.root
- }
- private fun initAdmobAds() {
- if(context!=null && showAd){
- val adRequest = AdRequest.Builder().build()
- bottomSheetLayoutBinding.adView.loadAd(adRequest)
- bottomSheetLayoutBinding.adView.adListener = object: AdListener() {
- override fun onAdLoaded() {
- Log.e("TAG", "onAdLoaded: ")
- bottomSheetLayoutBinding.bannerAdsLayout.visibility = VISIBLE
- }
-
- override fun onAdFailedToLoad(adError: LoadAdError) {
- Log.e("TAG", "onAdFailedToLoad: " + adError.message)
- }
-
- override fun onAdOpened() {
- Log.e("TAG", "onAdLoaded: ")
- }
-
- override fun onAdClicked() {
- // Code to be executed when the user clicks on an ad.
- }
-
- override fun onAdClosed() {
- Log.e("TAG", "onAdLoaded: ")
- }
- }
- }else{
- bottomSheetLayoutBinding.bannerAdsLayout.visibility = View.GONE
- }
-
-
- }
-
- override fun onClick(v: View?) {
- when(v?.id){
- R.id.back_icon -> {
- dismiss()
- }
- R.id.share -> {
- try {
- val shareIntent = Intent(Intent.ACTION_SEND)
- shareIntent.type = "text/plain"
- shareIntent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.app_name))
- var shareMessage = "\nCheck out this Dictionary Application.\n\n"
- shareMessage =
- """
- ${shareMessage}https://play.google.com/store/apps/details?id=com.kharagedition.tibetandictionary
- """.trimIndent()
- shareIntent.putExtra(Intent.EXTRA_TEXT, shareMessage)
- startActivity(Intent.createChooser(shareIntent, "choose one"))
- } catch (e: Exception) {
- //e.toString()
- }
- }
- R.id.rate -> {
- openView(CommonUtils.PLAY_STORE_URL)
- }
- R.id.gmail -> {
- val intent = Intent(Intent.ACTION_SENDTO)
- intent.type = "text/plain"
- intent.data = Uri.parse("mailto:developer.kharag@gmail.com")
- //intent.putExtra(Intent.EXTRA_EMAIL, "developer.kharag@gmail.com")
- intent.putExtra(Intent.EXTRA_SUBJECT, "Prayer Request")
- startActivity(Intent.createChooser(intent, "Send Email"))
- }
- R.id.github -> {
- openView(CommonUtils.GITHUB_URL)
- }
- R.id.instagram -> {
- openView(CommonUtils.INSTAGRAM_URL)
- }
- R.id.facebook -> {
- openView(CommonUtils.FACEBOOK_URL)
- }
- R.id.download_prayer -> {
- openView(CommonUtils.PLAY_STORE_DICTIONARY_URL)
- }
- R.id.download_calendar -> {
- openView(CommonUtils.PLAY_STORE_CALENDAR_URL)
- }
- }
- }
- private fun openView(url: String) {
- startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/util/TKExtension.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/util/TKExtension.kt
index 0a4619e..4fcfb1e 100644
--- a/app/src/main/java/com/kharagedition/tibetankeyboard/util/TKExtension.kt
+++ b/app/src/main/java/com/kharagedition/tibetankeyboard/util/TKExtension.kt
@@ -6,17 +6,37 @@ import android.view.View
import android.view.WindowManager
import android.widget.ImageView
import android.widget.Toast
-import androidx.appcompat.app.AlertDialog
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.bumptech.glide.Glide
import com.google.android.material.snackbar.Snackbar
import com.kharagedition.tibetankeyboard.R
+import com.kharagedition.tibetankeyboard.analytics.AppAnalytics
+import com.kharagedition.tibetankeyboard.auth.AuthManager
+import com.kharagedition.tibetankeyboard.auth.UnlockDestination
+import com.kharagedition.tibetankeyboard.auth.UnlockRouter
/**
* Extension functions for common UI operations
*/
+/**
+ * THE single entry point for every "unlock PRO / upgrade" action across the app and IME.
+ * Never show a custom paywall dialog — route here so users always land on the same
+ * "Unlock everything" subscription page (signing in first if needed).
+ */
+fun Context.openPremiumUpgrade(source: String = AppAnalytics.UpgradeSource.UNKNOWN) {
+ AppAnalytics.logUpgradeClicked(source)
+ val authManager = AuthManager(this)
+ when (UnlockRouter.destinationFor(authManager.isUserAuthenticated())) {
+ UnlockDestination.PREMIUM -> authManager.openPremium()
+ // Keep the caller (Home/Settings) beneath so closing the paywall after login
+ // can never leave an empty back stack and close the app.
+ UnlockDestination.LOGIN_THEN_PREMIUM ->
+ authManager.redirectToLogin(openPremiumAfter = true, finishCaller = false)
+ }
+}
+
/**
* Setup edge-to-edge display with proper window insets handling
*/
@@ -60,7 +80,7 @@ fun Context.showConfirmationDialog(
onPositive: () -> Unit,
onNegative: (() -> Unit)? = null
) {
- AlertDialog.Builder(this, R.style.CustomAlertDialog)
+ com.google.android.material.dialog.MaterialAlertDialogBuilder(this, R.style.CustomAlertDialog)
.setTitle(title)
.setMessage(message)
.setPositiveButton(positiveText) { _, _ -> onPositive() }
@@ -98,8 +118,11 @@ fun Context.showLongToast(message: String) {
}
/**
- * Validate message length
+ * Validate message length against the shared AI request cap.
+ *
+ * Defaults to [AiLimits.MAX_INPUT_CHARS] so the client rejects at the same
+ * threshold the backend does (`api-backend/src/config/constants.ts`).
*/
-fun String.isValidMessage(maxLength: Int = 300): Boolean {
+fun String.isValidMessage(maxLength: Int = AiLimits.MAX_INPUT_CHARS): Boolean {
return isNotBlank() && length <= maxLength
}
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ai_cancel_button_background.xml b/app/src/main/res/drawable/ai_cancel_button_background.xml
index ef9e115..538a10f 100644
--- a/app/src/main/res/drawable/ai_cancel_button_background.xml
+++ b/app/src/main/res/drawable/ai_cancel_button_background.xml
@@ -1,17 +1,18 @@
+
-
-
-
-
+
+
+
-
-
-
-
+
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/res/drawable/ai_replace_button_background.xml b/app/src/main/res/drawable/ai_replace_button_background.xml
index 73feb00..a1b1eb9 100644
--- a/app/src/main/res/drawable/ai_replace_button_background.xml
+++ b/app/src/main/res/drawable/ai_replace_button_background.xml
@@ -1,15 +1,24 @@
+
-
-
-
+
+
-
-
-
+
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/res/drawable/ic_pro_autocomplete.xml b/app/src/main/res/drawable/ic_pro_autocomplete.xml
new file mode 100644
index 0000000..0aae6a4
--- /dev/null
+++ b/app/src/main/res/drawable/ic_pro_autocomplete.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_pro_translate.xml b/app/src/main/res/drawable/ic_pro_translate.xml
new file mode 100644
index 0000000..8f85673
--- /dev/null
+++ b/app/src/main/res/drawable/ic_pro_translate.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/key_background_borderless.xml b/app/src/main/res/drawable/key_background_borderless.xml
new file mode 100644
index 0000000..25a9ca9
--- /dev/null
+++ b/app/src/main/res/drawable/key_background_borderless.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/normal_classic_brown.xml b/app/src/main/res/drawable/normal_classic_brown.xml
index b53911c..09caa7f 100644
--- a/app/src/main/res/drawable/normal_classic_brown.xml
+++ b/app/src/main/res/drawable/normal_classic_brown.xml
@@ -1,11 +1,25 @@
+
- -
+
+
-
-
+
+
-
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/normal_modern_brown.xml b/app/src/main/res/drawable/normal_modern_brown.xml
index d7810aa..ccecc54 100644
--- a/app/src/main/res/drawable/normal_modern_brown.xml
+++ b/app/src/main/res/drawable/normal_modern_brown.xml
@@ -1,30 +1,11 @@
+
-
-
- -
+
-
-
-
+
+
+
-
-
- -
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/pressed_borderless.xml b/app/src/main/res/drawable/pressed_borderless.xml
new file mode 100644
index 0000000..fed3d1d
--- /dev/null
+++ b/app/src/main/res/drawable/pressed_borderless.xml
@@ -0,0 +1,10 @@
+
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/pressed_classic_brown.xml b/app/src/main/res/drawable/pressed_classic_brown.xml
index 4bd6760..2a7849f 100644
--- a/app/src/main/res/drawable/pressed_classic_brown.xml
+++ b/app/src/main/res/drawable/pressed_classic_brown.xml
@@ -1,5 +1,11 @@
-
-
-
-
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/pressed_modern_brown.xml b/app/src/main/res/drawable/pressed_modern_brown.xml
index f3af5ef..f3a875e 100644
--- a/app/src/main/res/drawable/pressed_modern_brown.xml
+++ b/app/src/main/res/drawable/pressed_modern_brown.xml
@@ -1,18 +1,11 @@
+
-
-
- -
+
-
-
-
-
+
+
+
-
-
diff --git a/app/src/main/res/drawable/pro_pill_background.xml b/app/src/main/res/drawable/pro_pill_background.xml
new file mode 100644
index 0000000..f6152d9
--- /dev/null
+++ b/app/src/main/res/drawable/pro_pill_background.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/tk_lang_pill.xml b/app/src/main/res/drawable/tk_lang_pill.xml
new file mode 100644
index 0000000..ada0ab0
--- /dev/null
+++ b/app/src/main/res/drawable/tk_lang_pill.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/tk_swap_bg.xml b/app/src/main/res/drawable/tk_swap_bg.xml
new file mode 100644
index 0000000..91b2510
--- /dev/null
+++ b/app/src/main/res/drawable/tk_swap_bg.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/tk_translate_card.xml b/app/src/main/res/drawable/tk_translate_card.xml
new file mode 100644
index 0000000..39cf988
--- /dev/null
+++ b/app/src/main/res/drawable/tk_translate_card.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/tk_translate_card_accent.xml b/app/src/main/res/drawable/tk_translate_card_accent.xml
new file mode 100644
index 0000000..52c623c
--- /dev/null
+++ b/app/src/main/res/drawable/tk_translate_card_accent.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/translation_controls_background.xml b/app/src/main/res/drawable/translation_controls_background.xml
index 08b938b..878dd01 100644
--- a/app/src/main/res/drawable/translation_controls_background.xml
+++ b/app/src/main/res/drawable/translation_controls_background.xml
@@ -1,5 +1,7 @@
-
-
+
+
+
+
-
diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml
deleted file mode 100644
index 667a48e..0000000
--- a/app/src/main/res/layout/activity_chat.xml
+++ /dev/null
@@ -1,216 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_grammar.xml b/app/src/main/res/layout/activity_grammar.xml
deleted file mode 100644
index 5c9c15a..0000000
--- a/app/src/main/res/layout/activity_grammar.xml
+++ /dev/null
@@ -1,128 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml
deleted file mode 100644
index cba81fc..0000000
--- a/app/src/main/res/layout/activity_home.xml
+++ /dev/null
@@ -1,518 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml
deleted file mode 100644
index f9199e3..0000000
--- a/app/src/main/res/layout/activity_login.xml
+++ /dev/null
@@ -1,160 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_splash_screen.xml b/app/src/main/res/layout/activity_splash_screen.xml
deleted file mode 100644
index 0b093fb..0000000
--- a/app/src/main/res/layout/activity_splash_screen.xml
+++ /dev/null
@@ -1,88 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_transliteration.xml b/app/src/main/res/layout/activity_transliteration.xml
deleted file mode 100644
index 0ef1192..0000000
--- a/app/src/main/res/layout/activity_transliteration.xml
+++ /dev/null
@@ -1,147 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/ai_keyboard_layout.xml b/app/src/main/res/layout/ai_keyboard_layout.xml
index 0b82e7c..13ab1e8 100644
--- a/app/src/main/res/layout/ai_keyboard_layout.xml
+++ b/app/src/main/res/layout/ai_keyboard_layout.xml
@@ -6,91 +6,96 @@
android:orientation="vertical"
android:background="#FF704C04">
-
+
-
-
-
+
+
+
+
+
+
+
+
+
+ android:layout_height="match_parent"
+ android:layout_weight="1" />
-
+
+
-
-
+
+
-
-
-
-
-
+
+
@@ -118,72 +123,159 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:layout_height="0dp"
+ android:layout_weight="1"
+ android:clipToPadding="false"
+ android:scrollbars="none">
+
+ android:paddingHorizontal="14dp"
+ android:paddingTop="12dp"
+ android:paddingBottom="14dp">
-
+
-
-
+ android:layout_marginBottom="12dp"
+ android:visibility="gone">
-
+
+
-
+ android:layout_height="32dp"
+ android:gravity="center"
+ android:paddingHorizontal="14dp"
+ android:layout_marginStart="6dp"
+ android:text="@string/engine_azure"
+ android:textSize="12sp"
+ android:textStyle="bold"
+ android:textColor="@color/tk_cream"
+ android:background="@drawable/tk_lang_pill"
+ android:clickable="true"
+ android:focusable="true" />
+
+
+
+
@@ -193,201 +285,188 @@
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
- android:layout_marginBottom="12dp"
- android:background="@drawable/translation_controls_background"
- android:padding="4dp">
+ android:layout_marginBottom="12dp">
+ android:tint="@color/tk_gold" />
-
+ android:orientation="vertical"
+ android:background="@drawable/tk_translate_card"
+ android:padding="14dp"
+ android:layout_marginBottom="10dp">
-
-
-
-
-
-
-
-
-
-
-
-
+ android:text="@string/translate_original"
+ android:textSize="11sp"
+ android:textStyle="bold"
+ android:letterSpacing="0.08"
+ android:textColor="@color/tk_cream_dim"
+ android:layout_marginBottom="6dp" />
-
-
-
-
-
-
-
-
-
+ android:text="Your original text will appear here..."
+ android:textSize="15sp"
+ android:lineSpacingExtra="2dp"
+ android:textColor="@color/tk_cream"
+ android:textColorHighlight="#66F2C264"
+ android:textIsSelectable="true" />
-
-
+
+
+
+ android:orientation="vertical"
+ android:background="@drawable/tk_translate_card_accent"
+ android:padding="14dp">
-
-
-
-
-
-
-
-
-
-
-
+ android:text="@string/translate_suggested"
+ android:textSize="11sp"
+ android:textStyle="bold"
+ android:letterSpacing="0.08"
+ android:textColor="@color/tk_gold"
+ android:layout_marginBottom="6dp" />
+
+
+
+
+
+
+ android:orientation="vertical"
+ android:background="@drawable/tk_translate_card"
+ android:padding="14dp"
+ android:layout_marginTop="10dp">
-
-
-
+ android:letterSpacing="0.08"
+ android:textColor="@color/tk_cream_dim"
+ android:layout_marginBottom="6dp" />
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/bottom_sheet_layout.xml b/app/src/main/res/layout/bottom_sheet_layout.xml
deleted file mode 100644
index 2c80447..0000000
--- a/app/src/main/res/layout/bottom_sheet_layout.xml
+++ /dev/null
@@ -1,479 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/gnt_medium_template_view.xml b/app/src/main/res/layout/gnt_medium_template_view.xml
index 75b48e8..4fae6e6 100644
--- a/app/src/main/res/layout/gnt_medium_template_view.xml
+++ b/app/src/main/res/layout/gnt_medium_template_view.xml
@@ -1,221 +1,128 @@
-
+
+
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:background="@drawable/gnt_outline_shape"
+ android:minHeight="50dp"
+ android:padding="12dp">
-
+
+ app:layout_constraintTop_toTopOf="parent" />
-
-
- android:layout_width="@dimen/gnt_no_size"
- android:layout_height="@dimen/gnt_no_size"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent"
- app:layout_constraintBottom_toBottomOf="parent"
- android:orientation="horizontal"
- >
-
-
-
+
-
-
-
-
-
-
+
-
-
+
-
+
+
+
+
-
-
+
+
-
-
+ app:layout_constraintTop_toBottomOf="@id/media_view" />
+
+ app:layout_constraintTop_toBottomOf="@id/body" />
diff --git a/app/src/main/res/layout/home_native_ad.xml b/app/src/main/res/layout/home_native_ad.xml
new file mode 100644
index 0000000..50ae9e1
--- /dev/null
+++ b/app/src/main/res/layout/home_native_ad.xml
@@ -0,0 +1,7 @@
+
+
diff --git a/app/src/main/res/layout/item_chat_assistant.xml b/app/src/main/res/layout/item_chat_assistant.xml
deleted file mode 100644
index b28d1cb..0000000
--- a/app/src/main/res/layout/item_chat_assistant.xml
+++ /dev/null
@@ -1,55 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/item_chat_user.xml b/app/src/main/res/layout/item_chat_user.xml
deleted file mode 100644
index 8fd3c4d..0000000
--- a/app/src/main/res/layout/item_chat_user.xml
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_grammar_correction.xml b/app/src/main/res/layout/item_grammar_correction.xml
deleted file mode 100644
index 010335b..0000000
--- a/app/src/main/res/layout/item_grammar_correction.xml
+++ /dev/null
@@ -1,112 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/keyboard_black.xml b/app/src/main/res/layout/keyboard_black.xml
index 6c110cb..881ece7 100644
--- a/app/src/main/res/layout/keyboard_black.xml
+++ b/app/src/main/res/layout/keyboard_black.xml
@@ -1,5 +1,5 @@
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/keyboard_black_modern.xml b/app/src/main/res/layout/keyboard_black_modern.xml
index fcd00c7..0ab7de2 100644
--- a/app/src/main/res/layout/keyboard_black_modern.xml
+++ b/app/src/main/res/layout/keyboard_black_modern.xml
@@ -1,5 +1,5 @@
-
-
+
diff --git a/app/src/main/res/layout/keyboard_borderless.xml b/app/src/main/res/layout/keyboard_borderless.xml
new file mode 100644
index 0000000..386d0f4
--- /dev/null
+++ b/app/src/main/res/layout/keyboard_borderless.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/keyboard_brown.xml b/app/src/main/res/layout/keyboard_brown.xml
index eef377d..a4bc58a 100644
--- a/app/src/main/res/layout/keyboard_brown.xml
+++ b/app/src/main/res/layout/keyboard_brown.xml
@@ -1,5 +1,5 @@
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/keyboard_brown_modern.xml b/app/src/main/res/layout/keyboard_brown_modern.xml
index 7aebd07..7a2ef5c 100644
--- a/app/src/main/res/layout/keyboard_brown_modern.xml
+++ b/app/src/main/res/layout/keyboard_brown_modern.xml
@@ -1,5 +1,5 @@
-
-
+
diff --git a/app/src/main/res/layout/keyboard_green.xml b/app/src/main/res/layout/keyboard_green.xml
index 66d70d4..b548d27 100644
--- a/app/src/main/res/layout/keyboard_green.xml
+++ b/app/src/main/res/layout/keyboard_green.xml
@@ -1,5 +1,5 @@
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/keyboard_green_modern.xml b/app/src/main/res/layout/keyboard_green_modern.xml
index abed298..f68ffc7 100644
--- a/app/src/main/res/layout/keyboard_green_modern.xml
+++ b/app/src/main/res/layout/keyboard_green_modern.xml
@@ -1,5 +1,5 @@
-
-
+
diff --git a/app/src/main/res/layout/settings_activity.xml b/app/src/main/res/layout/settings_activity.xml
deleted file mode 100644
index be68b71..0000000
--- a/app/src/main/res/layout/settings_activity.xml
+++ /dev/null
@@ -1,90 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/menu/chat_menu.xml b/app/src/main/res/menu/chat_menu.xml
deleted file mode 100644
index c8cb764..0000000
--- a/app/src/main/res/menu/chat_menu.xml
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 54ff2c4..7b2a8b2 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,5 +1,16 @@
+
+ #FF241808
+ #FF2E2009
+ #FF3C2B0E
+ #FF4D3812
+ #FFF2C264
+ #FFC8881F
+ #FFFCF5E8
+ #FFC7B284
+ #FF1C1305
+
#FFBB86FC
#FF6200EE
#FF704C04
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 856f940..cd28c17 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,5 +1,6 @@
Tibetan Keyboard
+ Tibetan (Lhasa)
by Kharag Edition
\u003F
\u0040
@@ -70,6 +71,17 @@
116616062750-5kt7phap4d1o0vcusigk3ttg3dlcijfd.apps.googleusercontent.com
Fix
Translate
+ PRO
+ Unlock PRO
+ AI Chat
+ Autocomplete
+ ORIGINAL
+ ✦ SUGGESTED
+ SUMMARY
+ ENGINE
+ Azure
+ Haiku
+ Sonnet
Rephrase
Retry
Use Translation
@@ -143,9 +155,7 @@
Premium Feature
- This feature is available only for Premium users.\n\nUpgrade now to unlock all features!\n1. AI Chat\n2. Google Translate\n3. Ad-free Experience\n4. Priority Support
Upgrade
- Maybe Later
Daily limit exceeded.\n\nUpgrade to Premium for unlimited access!
You have %d uses remaining today.\n\nUpgrade to Premium for unlimited access!
@@ -157,6 +167,7 @@
Message is required
Text is required
Message too long or short
+ Maximum %1$d characters allowed.
Analysis complete
Please enter text to analyze
@@ -183,4 +194,85 @@
MMM dd, HH:mm
+
+ Are you sure you want to sign out?
+ Signed out successfully
+ Purchases restored
+
+
+ Message too long or short
+ Copied
+ Are you sure you want to clear all messages?
+ Clear
+ Chat cleared
+
+
+ བོད་ཡིག་མཐེབ་གཞོང་
+ Back
+ Menu
+ Send
+ Quick actions
+ Themes
+ About
+ Go Pro
+ No ads · AI · word suggestions
+ How to set up
+ Try your keyboard here
+ Enable Tibetan Keyboard
+ Choose input method
+ %1$d of 2
+
+ Turn on Tibetan Keyboard
+ Tap here to open your phone\'s keyboard settings, then switch on Tibetan Keyboard in the list.
+ Switch to Tibetan Keyboard
+ Tap here, then pick Tibetan Keyboard from the popup to start typing.
+ Got it
+ Tap the highlighted step to continue
+ Setup guide overlay
+ Design & UI
+ Sound & Control
+ Notification
+ Account
+ Keyboard Background
+ Keyboard Layout
+ Vibrate on tap
+ Sound on tap
+ Event notifications
+ Subscription
+ Premium plan
+ Free plan
+ Logout
+ Done
+ AI Assistant
+ Online · Tibetan
+ Clear chat
+ Copy
+ Tibetan Keyboard Pro
+ Unlock everything
+ Monthly
+ Cancel anytime
+ Start subscription
+ Restore purchase
+ Terms · Privacy
+ About this app
+ MORE FROM KHARAGEDITION
+ GitHub
+ Contact
+ Get
+ Welcome
+ By signing in, you agree to our
+ Terms of Service
+ and
+ Privacy Policy
+ Version %1$s
+
+
+ AI Translate
+ Enter text to translate…
+ The translation will appear here.
+ Swap languages
+ Tibetan
+ English
+ Chinese
+
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index a498ca2..cd1478a 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -34,10 +34,25 @@
- @null
-
+
+
+
+