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 @@ - - - - - - - - - - - - - - - - - - - - - -