diff --git a/api-backend/src/index.ts b/api-backend/src/index.ts index 9da647f..96dcb0f 100644 --- a/api-backend/src/index.ts +++ b/api-backend/src/index.ts @@ -716,6 +716,72 @@ app.post("/api/chat/tutoring/mode", async (req, res) => { } }); +// ============================================ +// JOURNEY (typing streak) — community comparison +// ============================================ + +// Numbers-only, opt-in weekly sync for the app's typing-streak feature. +// PRIVACY: the request carries exactly two aggregate integers (words typed this +// week + streak length). No typed content ever reaches this endpoint, and the +// stored doc holds only those numbers. Percentile = share of other participants +// with a lower weekly word count, computed with cheap aggregate count queries. +app.post("/journey/weekly", async (req, res) => { + try { + const userId = req.headers["userid"] as string; + if (!userId || typeof userId !== "string" || userId.length > 128) { + return res.status(400).json({ + success: false, + error: "userid header required", + }); + } + + const wordsThisWeek = Number(req.body?.wordsThisWeek); + const streakDays = Number(req.body?.streakDays); + const validCount = (n: number, max: number) => + Number.isInteger(n) && n >= 0 && n <= max; + if (!validCount(wordsThisWeek, 1_000_000) || !validCount(streakDays, 36_500)) { + return res.status(400).json({ + success: false, + error: "wordsThisWeek and streakDays must be non-negative integers", + }); + } + + const db = admin.firestore(); + const col = db.collection("journey_stats"); + await col.doc(userId).set( + { + wordsThisWeek, + streakDays, + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + }, + { merge: true } + ); + + const [totalSnap, belowSnap] = await Promise.all([ + col.count().get(), + col.where("wordsThisWeek", "<", wordsThisWeek).count().get(), + ]); + const totalUsers = totalSnap.data().count; + const below = belowSnap.data().count; + // Share of *other* participants this user out-typed. A lone first user beats 100%. + const percentile = + totalUsers <= 1 ? 100 : Math.round((below / (totalUsers - 1)) * 100); + + logger.info("Journey weekly sync", { userId, wordsThisWeek, streakDays, percentile }); + + return res.json({ + success: true, + data: { percentile, totalUsers }, + }); + } catch (error) { + console.error("Journey weekly sync error:", error); + return res.status(500).json({ + success: false, + error: "Failed to record weekly stats", + }); + } +}); + // Error handling middleware app.use(errorHandler); diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e80c976..4783eb3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -51,6 +51,10 @@ android:name=".ui.about.AboutActivity" android:exported="false" android:parentActivityName=".ui.home.HomeActivity" /> + ? = null + + // ── time ───────────────────────────────────────────────────────────────── + + /** Local-timezone epoch day, so the streak rolls over at the user's midnight. */ + fun todayEpochDay(now: Long = System.currentTimeMillis()): Long = + (now + TimeZone.getDefault().getOffset(now)) / MILLIS_PER_DAY + + // ── recording (called from the IME) ────────────────────────────────────── + + /** + * Count [count] Tibetan code points typed. Also advances the streak on the first + * activity of the day. Returns the streak milestone crossed today, if any, so the + * caller can celebrate/log it — this store never logs analytics itself. + */ + @Synchronized + fun recordTibetanChars(count: Int): Int? { + if (count <= 0) return null + val today = todayEpochDay() + prefs.edit().putInt(charsKey(today), charsOn(today) + count).apply() + return touchStreak(today) + } + + /** + * Count one completed Tibetan word. [word] is hashed for the vocabulary size and the + * text itself is discarded immediately — never persisted, never transmitted. + */ + @Synchronized + fun recordWordTyped(word: String): Int? { + val trimmed = word.trim { it.isWhitespace() || it == TSHEK } + if (trimmed.isEmpty() || !containsTibetan(trimmed)) return null + val today = todayEpochDay() + val edit = prefs.edit() + .putInt(wordsKey(today), wordsOn(today) + 1) + .putLong(KEY_TOTAL_WORDS, totalWords() + 1) + + val vocab = vocabSet() + if (vocab.size < MAX_VOCAB_ENTRIES && vocab.add(hash(trimmed))) { + edit.putStringSet(KEY_VOCAB_HASHES, HashSet(vocab)) + } + edit.apply() + return touchStreak(today) + } + + /** Advance the streak for [today]; returns a crossed milestone (or null). */ + private fun touchStreak(today: Long): Int? { + val before = streak() + if (before.lastActiveEpochDay == today) return null // fast path: already counted + val after = StreakLogic.recordActivity(before, today) + prefs.edit() + .putInt(KEY_STREAK_LEN, after.lengthDays) + .putLong(KEY_STREAK_LAST_DAY, after.lastActiveEpochDay) + .putInt(KEY_STREAK_BEST, after.bestDays) + .apply() + pruneOldDays(today) + return StreakLogic.milestoneCrossed(before.lengthDays, after.lengthDays) + } + + // ── reading (Journey screen / toolbar chip / reminder worker) ──────────── + + fun streak(): StreakState = StreakState( + lengthDays = prefs.getInt(KEY_STREAK_LEN, 0), + lastActiveEpochDay = prefs.getLong(KEY_STREAK_LAST_DAY, -1L), + bestDays = prefs.getInt(KEY_STREAK_BEST, 0), + ) + + /** The streak number to show right now (0 once a full day has been missed). */ + fun displayStreak(): Int = StreakLogic.displayLength(streak(), todayEpochDay()) + + fun charsOn(epochDay: Long): Int = prefs.getInt(charsKey(epochDay), 0) + + fun wordsOn(epochDay: Long): Int = prefs.getInt(wordsKey(epochDay), 0) + + fun wordsToday(): Int = wordsOn(todayEpochDay()) + + /** Words per day for the last 7 days, oldest first (today last). */ + fun weekWords(today: Long = todayEpochDay()): List = + (6 downTo 0).map { wordsOn(today - it) } + + fun wordsThisWeek(today: Long = todayEpochDay()): Int = weekWords(today).sum() + + /** Tibetan characters typed in the last 7 days — feeds the PRO-autocomplete value teaser. */ + fun charsThisWeek(today: Long = todayEpochDay()): Int = + (6 downTo 0).sumOf { charsOn(today - it) } + + fun totalWords(): Long = prefs.getLong(KEY_TOTAL_WORDS, 0L) + + /** Distinct Tibetan words ever typed (counted via one-way hashes — see privacy contract). */ + fun vocabularySize(): Int = vocabSet().size + + // ── community-comparison opt-in (numbers-only sync; default OFF) ───────── + + fun isSyncEnabled(): Boolean = prefs.getBoolean(KEY_SYNC_ENABLED, false) + + fun setSyncEnabled(enabled: Boolean) = + prefs.edit().putBoolean(KEY_SYNC_ENABLED, enabled).apply() + + // ── weekly-notification guard (used by the reminder worker) ────────────── + + /** True once per ISO-ish week bucket; guards the weekly insight notification. */ + fun markWeeklyNotified(today: Long = todayEpochDay()): Boolean { + val week = today / 7 + if (prefs.getLong(KEY_LAST_WEEKLY_NOTIFIED, -1L) == week) return false + prefs.edit().putLong(KEY_LAST_WEEKLY_NOTIFIED, week).apply() + return true + } + + // ── internals ──────────────────────────────────────────────────────────── + + private fun vocabSet(): MutableSet { + vocabCache?.let { return it } + synchronized(this) { + vocabCache?.let { return it } + val loaded = HashSet(prefs.getStringSet(KEY_VOCAB_HASHES, emptySet()) ?: emptySet()) + vocabCache = loaded + return loaded + } + } + + /** One-way, non-reversible fingerprint of a word (first 9 bytes of SHA-256). */ + private fun hash(word: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(word.toByteArray(Charsets.UTF_8)) + return Base64.encodeToString(digest, 0, 9, Base64.NO_WRAP or Base64.NO_PADDING) + } + + private fun containsTibetan(s: String): Boolean = s.any { it.code in TIBETAN_BLOCK } + + /** Drop per-day counters older than [KEEP_DAYS]; runs at most once per streak advance. */ + private fun pruneOldDays(today: Long) { + val cutoff = today - KEEP_DAYS + val stale = prefs.all.keys.filter { key -> + dayOfKey(key)?.let { it < cutoff } == true + } + if (stale.isEmpty()) return + prefs.edit().apply { stale.forEach { remove(it) } }.apply() + } + + private fun charsKey(epochDay: Long) = "chars_d$epochDay" + private fun wordsKey(epochDay: Long) = "words_d$epochDay" + + private fun dayOfKey(key: String): Long? { + val prefix = when { + key.startsWith("chars_d") -> "chars_d" + key.startsWith("words_d") -> "words_d" + else -> return null + } + return key.removePrefix(prefix).toLongOrNull() + } + + companion object { + private const val PREFS_NAME = "typing_stats" + private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000 + private const val KEEP_DAYS = 60L + private const val MAX_VOCAB_ENTRIES = 20_000 + private const val TSHEK = '་' + private val TIBETAN_BLOCK = 0x0F00..0x0FFF + + private const val KEY_STREAK_LEN = "streak_len" + private const val KEY_STREAK_LAST_DAY = "streak_last_day" + private const val KEY_STREAK_BEST = "streak_best" + private const val KEY_TOTAL_WORDS = "total_words" + private const val KEY_VOCAB_HASHES = "vocab_hashes" + private const val KEY_SYNC_ENABLED = "journey_sync_enabled" + private const val KEY_LAST_WEEKLY_NOTIFIED = "last_weekly_notified" + + @Volatile + private var instance: TypingStatsStore? = null + + fun getInstance(context: Context): TypingStatsStore = + instance ?: synchronized(this) { + instance ?: TypingStatsStore(context).also { instance = it } + } + + /** True when [codePoint] belongs to the Tibetan Unicode block. */ + fun isTibetanCodePoint(codePoint: Int): Boolean = codePoint in TIBETAN_BLOCK + } +} diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/data/model/JourneyModels.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/data/model/JourneyModels.kt new file mode 100644 index 0000000..487fb9f --- /dev/null +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/data/model/JourneyModels.kt @@ -0,0 +1,28 @@ +package com.kharagedition.tibetankeyboard.data.model + +import kotlinx.serialization.Serializable + +/** + * Community-comparison payload for the Journey feature. PRIVACY: these two aggregate + * numbers are the ONLY thing the Journey feature ever sends off-device, and only after + * the user opts in on the Journey screen. No typed content, ever. + */ +@Serializable +data class JourneyWeeklyRequest( + val wordsThisWeek: Int, + val streakDays: Int, +) + +@Serializable +data class JourneyWeeklyData( + /** Share of other participants this user out-typed this week (0–100). */ + val percentile: Int = 0, + val totalUsers: Int = 0, +) + +@Serializable +data class JourneyWeeklyResponse( + val success: Boolean = false, + val data: JourneyWeeklyData? = null, + val message: String? = null, +) diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/data/remote/JourneyAPI.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/data/remote/JourneyAPI.kt new file mode 100644 index 0000000..0db3cd8 --- /dev/null +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/data/remote/JourneyAPI.kt @@ -0,0 +1,18 @@ +package com.kharagedition.tibetankeyboard.data.remote + +import com.kharagedition.tibetankeyboard.data.model.JourneyWeeklyRequest +import com.kharagedition.tibetankeyboard.data.model.JourneyWeeklyResponse +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.Headers +import retrofit2.http.POST + +interface JourneyAPI { + /** Numbers-only weekly sync for the opt-in community comparison (see JourneyModels). */ + @Headers("Content-Type: application/json", "Accept: application/json") + @POST("journey/weekly") + suspend fun submitWeekly( + @Body request: JourneyWeeklyRequest, + @Header("userid") userId: String, + ): JourneyWeeklyResponse +} diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/data/remote/RetrofitClient.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/data/remote/RetrofitClient.kt index ea7f29d..323851c 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/data/remote/RetrofitClient.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/data/remote/RetrofitClient.kt @@ -43,6 +43,15 @@ object RetrofitClient { .build() .create(YigChikTranslateAPI::class.java) } + val journeyAPI: JourneyAPI by lazy { + Retrofit.Builder() + .baseUrl("https://asia-south1-tibetan-keyboard.cloudfunctions.net/api/") + .client(httpClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(JourneyAPI::class.java) + } + val aiAPI: YigChikAIAPI by lazy { Retrofit.Builder() .baseUrl("https://yig-chik-gfg2cdb5a3dycvh8.centralindia-01.azurewebsites.net/") diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/JourneyRepository.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/JourneyRepository.kt new file mode 100644 index 0000000..f60c022 --- /dev/null +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/JourneyRepository.kt @@ -0,0 +1,34 @@ +package com.kharagedition.tibetankeyboard.data.repository + +import android.util.Log +import com.kharagedition.tibetankeyboard.data.model.JourneyWeeklyData +import com.kharagedition.tibetankeyboard.data.model.JourneyWeeklyRequest +import com.kharagedition.tibetankeyboard.data.remote.RetrofitClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Backend calls for the Journey feature's opt-in community comparison. + * Sends two aggregate numbers (weekly word count + streak days) — never typed content. + */ +class JourneyRepository { + + /** Submit this week's numbers; returns the community percentile, or null on any failure. */ + suspend fun submitWeekly(userId: String, wordsThisWeek: Int, streakDays: Int): JourneyWeeklyData? = + withContext(Dispatchers.IO) { + try { + val response = RetrofitClient.journeyAPI.submitWeekly( + JourneyWeeklyRequest(wordsThisWeek = wordsThisWeek, streakDays = streakDays), + userId, + ) + if (response.success) response.data else null + } catch (e: Exception) { + Log.w(TAG, "submitWeekly failed: ${e.message}") + null + } + } + + companion object { + private const val TAG = "JourneyRepository" + } +} diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/service/TibetanKeyboard.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/service/TibetanKeyboard.kt index d13a0bc..58fed8e 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/service/TibetanKeyboard.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/service/TibetanKeyboard.kt @@ -28,12 +28,15 @@ import androidx.preference.PreferenceManager import com.google.firebase.auth.FirebaseAuth import com.kharagedition.tibetankeyboard.analytics.AppAnalytics import com.kharagedition.tibetankeyboard.analytics.UserActivityTracker +import com.kharagedition.tibetankeyboard.data.local.TypingStatsStore 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.util.openPremiumUpgrade import com.kharagedition.tibetankeyboard.ui.chat.ChatActivity +import com.kharagedition.tibetankeyboard.ui.journey.JourneyActivity +import com.kharagedition.tibetankeyboard.ui.journey.WordSegmenter import com.kharagedition.tibetankeyboard.ui.keyboard.AIKeyboardInterface import com.kharagedition.tibetankeyboard.ui.keyboard.KeyboardLayoutHint import com.kharagedition.tibetankeyboard.ui.keyboard.TibetanKeyboardView @@ -69,6 +72,10 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa // Tshek (་) is NOT a boundary — it is part of the Tibetan word. private var currentWordLength = 0 + // Journey streak/stats. PRIVACY: the store only ever receives counts and one-way word + // hashes — no typed text is persisted or transmitted (see TypingStatsStore's contract). + private val typingStats by lazy { TypingStatsStore.getInstance(this) } + enum class KeyboardMode { NORMAL, AI_GRAMMAR, @@ -311,11 +318,24 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa // 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') { + // A chunk just finished: segment it into real dictionary words (Tibetan has + // no spaces between words, so the whole chunk can be a full clause) and fold + // each into the Journey stats. Only one-way hashes survive (vocabulary size), + // never the text. + if (currentWordLength > 0) { + recordChunkAsWords(currentPrefix(inputConnection)) + } currentWordLength = 0 } else { currentWordLength++ } inputConnection.commitText(code.toString(), 1) + // Journey streak: count Tibetan code points typed (a number, nothing else). + if (TypingStatsStore.isTibetanCodePoint(code.code)) { + typingStats.recordTibetanChars(1)?.let { milestone -> + AppAnalytics.logStreakMilestone(milestone) + } + } aiKeyboardView?.updateSuggestions(currentPrefix(inputConnection)) } } @@ -503,6 +523,10 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa if (currentWordLength > 0) ic.deleteSurroundingText(currentWordLength, 0) ic.commitText(word, 1) currentWordLength = 0 + // Journey stats: an accepted suggestion completes a word (hash-only, see store contract). + typingStats.recordWordTyped(word)?.let { milestone -> + AppAnalytics.logStreakMilestone(milestone) + } aiKeyboardView?.updateSuggestions("") } @@ -517,6 +541,26 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa openPremiumUpgrade(AppAnalytics.UpgradeSource.KEYBOARD) } + override fun onOpenJourney() { + AppAnalytics.logJourneyOpened(AppAnalytics.JourneySource.KEYBOARD) + startActivity( + Intent(this, JourneyActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + + /** + * Segments a completed chunk into real dictionary words (see [WordSegmenter]) and folds + * each into Journey stats, rather than counting the whole space/shad-delimited chunk as a + * single "word". + */ + private fun recordChunkAsWords(chunk: String) { + WordSegmenter.segment(chunk, AIKeyboardView.dictionaryOrNull()).forEach { word -> + typingStats.recordWordTyped(word)?.let { milestone -> + AppAnalytics.logStreakMilestone(milestone) + } + } + } + // 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 { 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 index 26db811..6603c4f 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeActivity.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeActivity.kt @@ -43,6 +43,7 @@ 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.journey.JourneyActivity import com.kharagedition.tibetankeyboard.ui.settings.SettingsActivity import com.kharagedition.tibetankeyboard.ui.translate.TranslateActivity import com.kharagedition.tibetankeyboard.util.AppConstant @@ -126,6 +127,7 @@ class HomeActivity : InputMethodActivity() { override fun onResume() { super.onResume() refreshSetupState() + viewModel.refreshJourney() // CRITICAL: sync purchases on resume to acknowledge pending subscriptions // (prevents Google Play auto-cancelling after 3 days). if (viewModel.isUserAuthenticated()) { @@ -196,6 +198,11 @@ class HomeActivity : InputMethodActivity() { AppAnalytics.logHomeAction(AppAnalytics.HomeAction.UPGRADE) openPremiumUpgrade(AppAnalytics.UpgradeSource.HOME) }, + onJourney = { + AppAnalytics.logHomeAction(AppAnalytics.HomeAction.JOURNEY) + AppAnalytics.logJourneyOpened(AppAnalytics.JourneySource.HOME) + startActivity(Intent(this, JourneyActivity::class.java)) + }, ) /** 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 index 55b39f5..fd64bfb 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeScreen.kt @@ -58,6 +58,8 @@ data class HomeUiState( val keyboardEnabled: Boolean = false, val inputMethodSelected: Boolean = false, val isPremium: Boolean = false, + val streakDays: Int = 0, + val wordsToday: Int = 0, ) /** Callbacks for Home actions, owned by the Activity. */ @@ -71,6 +73,7 @@ class HomeActions( val onRate: () -> Unit, val onAbout: () -> Unit, val onUpgrade: () -> Unit, + val onJourney: () -> Unit, ) private data class QuickAction( @@ -136,6 +139,13 @@ fun HomeScreen( // SetupDemo(if (!state.keyboardEnabled) R.drawable.keyboard else R.drawable.input) } + // The streak banner is the Journey's Home-screen trigger — always present once + // setup is done, whether the flame is lit (celebrate) or not (invite). + if (state.keyboardEnabled && state.inputMethodSelected) { + Spacer(Modifier.height(14.dp)) + JourneyCard(state, onClick = actions.onJourney) + } + 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)) @@ -413,6 +423,45 @@ private fun QuickActionCard(item: QuickAction, modifier: Modifier = Modifier) { } } +/** Streak banner — tap-through to the Journey (streak & insights) screen. */ +@Composable +private fun JourneyCard(state: HomeUiState, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(TibetanColors.Brown700) + .border(1.dp, TibetanColors.Line, RoundedCornerShape(18.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier + .size(42.dp) + .clip(RoundedCornerShape(12.dp)) + .background(TibetanColors.Brown600), + contentAlignment = Alignment.Center, + ) { + Text("🔥", fontSize = 22.sp) + } + Column(Modifier.weight(1f)) { + Text( + if (state.streakDays > 0) stringResource(R.string.home_journey_streak, state.streakDays) + else stringResource(R.string.home_journey_start), + color = TibetanColors.Cream, fontSize = 14.5.sp, fontWeight = FontWeight.Bold, + ) + Text( + if (state.streakDays > 0) stringResource(R.string.home_journey_subtitle_active, state.wordsToday) + else stringResource(R.string.home_journey_subtitle_idle), + color = TibetanColors.CreamDim, fontSize = 12.sp, + ) + } + Icon(AppIcons.Chevron, null, tint = TibetanColors.CreamDim, modifier = Modifier.size(20.dp)) + } +} + @Composable private fun GoProBanner(onUpgrade: () -> Unit) { Row( 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 index 66e7a6c..863b4de 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeViewModel.kt @@ -4,6 +4,7 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.Observer import com.kharagedition.tibetankeyboard.auth.AuthManager +import com.kharagedition.tibetankeyboard.data.local.TypingStatsStore import com.kharagedition.tibetankeyboard.data.repository.RevenueCatManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -37,6 +38,12 @@ class HomeViewModel(app: Application) : AndroidViewModel(app) { _uiState.update { it.copy(keyboardEnabled = keyboardEnabled, inputMethodSelected = inputMethodSelected) } } + /** Re-read the on-device streak numbers for the Journey banner (cheap; every resume). */ + fun refreshJourney() { + val stats = TypingStatsStore.getInstance(getApplication()) + _uiState.update { it.copy(streakDays = stats.displayStreak(), wordsToday = stats.wordsToday()) } + } + fun isUserAuthenticated(): Boolean = authManager.isUserAuthenticated() fun initializeUserSession(callback: RevenueCatManager.SubscriptionCallback) = diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/CommunityInsight.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/CommunityInsight.kt new file mode 100644 index 0000000..782fa78 --- /dev/null +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/CommunityInsight.kt @@ -0,0 +1,17 @@ +package com.kharagedition.tibetankeyboard.ui.journey + +/** Aspirational framing for the opt-in community comparison — same tone as [StreakLogic]'s milestones. */ +enum class CommunityTier { + ELITE, TOP, ABOVE_AVERAGE, BUILDING; +} + +object CommunityInsight { + + /** Percentile-to-tier bucketing. Kept coarse so small denominators don't jitter the label. */ + fun tierFor(percentile: Int): CommunityTier = when { + percentile >= 95 -> CommunityTier.ELITE + percentile >= 80 -> CommunityTier.TOP + percentile >= 50 -> CommunityTier.ABOVE_AVERAGE + else -> CommunityTier.BUILDING + } +} diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/JourneyActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/JourneyActivity.kt new file mode 100644 index 0000000..1281d8f --- /dev/null +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/JourneyActivity.kt @@ -0,0 +1,87 @@ +package com.kharagedition.tibetankeyboard.ui.journey + +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.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.ui.compose.theme.TibetanKeyboardTheme +import com.kharagedition.tibetankeyboard.util.CommonUtils +import com.kharagedition.tibetankeyboard.util.openPremiumUpgrade + +/** + * "Your Tibetan Journey" — streak & typing insights. Framework glue only: + * navigation, the share intent and the upgrade route. All state lives in [JourneyViewModel]. + */ +class JourneyActivity : AppCompatActivity() { + + private val viewModel: JourneyViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // The keyboard/Home log their own open events; notifications carry their source here. + intent.getStringExtra(EXTRA_SOURCE)?.let { AppAnalytics.logJourneyOpened(it) } + + setContent { + TibetanKeyboardTheme { + val state by viewModel.uiState.collectAsStateWithLifecycle() + JourneyScreen( + state = state, + actions = JourneyActions( + onBack = { finish() }, + onUpgrade = { openPremiumUpgrade(AppAnalytics.UpgradeSource.JOURNEY) }, + onShare = { shareStreak() }, + onToggleSync = { viewModel.setSyncEnabled(it) }, + onSignIn = { + AuthManager(this).redirectToLogin( + target = JourneyActivity::class.java, + finishCaller = false, + ) + }, + ), + ) + } + } + } + + override fun onResume() { + super.onResume() + viewModel.refresh() + } + + private fun shareStreak() { + val days = viewModel.uiState.value.streakDays + if (days <= 0) return + AppAnalytics.logJourneyShared(days) + try { + startActivity( + Intent.createChooser( + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + getString( + R.string.journey_share_text, + days, + getString(R.string.app_name), + CommonUtils.PLAY_STORE_URL, + ), + ) + }, + null, + ) + ) + } catch (_: Exception) { + } + } + + companion object { + /** Optional [AppAnalytics.JourneySource] value describing what opened this screen. */ + const val EXTRA_SOURCE = "journey_source" + } +} diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/JourneyScreen.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/JourneyScreen.kt new file mode 100644 index 0000000..f0b6464 --- /dev/null +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/JourneyScreen.kt @@ -0,0 +1,454 @@ +package com.kharagedition.tibetankeyboard.ui.journey + +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.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.GoldButton +import com.kharagedition.tibetankeyboard.ui.compose.components.GoldToggle +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.theme.TibetanColors +import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanTokens + +/** Callbacks for Journey actions, owned by the Activity. */ +class JourneyActions( + val onBack: () -> Unit, + val onUpgrade: () -> Unit, + val onShare: () -> Unit, + val onToggleSync: (Boolean) -> Unit, + val onSignIn: () -> Unit, +) + +@Composable +fun JourneyScreen(state: JourneyUiState, actions: JourneyActions) { + ScreenScaffold { + BackHeader(stringResource(R.string.journey_title), onBack = actions.onBack) + + Column(Modifier.padding(horizontal = 18.dp)) { + StreakHero(state) + + // Milestone day: celebrate at the emotional high point — and for free users, + // that's exactly the moment to pitch PRO (reward-moment conversion). + if (state.milestone != null) { + Spacer(Modifier.height(14.dp)) + MilestoneBanner(state.milestone, state.isPremium, onUpgrade = actions.onUpgrade) + } + + Spacer(Modifier.height(14.dp)) + StatsGrid(state, onUpgrade = actions.onUpgrade) + + Spacer(Modifier.height(22.dp)) + SectionLabel(stringResource(R.string.journey_insights_label), color = TibetanColors.CreamDim) + Spacer(Modifier.height(10.dp)) + if (state.isPremium) { + WeekChart(state.weekWords, state.weekDayLabels) + Spacer(Modifier.height(12.dp)) + CommunityCard(state, actions.onToggleSync, actions.onSignIn) + } else { + TeaserInsightsCard(state, onUpgrade = actions.onUpgrade) + } + + Spacer(Modifier.height(12.dp)) + PrivacyCard() + + if (state.streakDays > 0) { + Spacer(Modifier.height(18.dp)) + GoldButton(stringResource(R.string.journey_share), onClick = actions.onShare) + } + } + } +} + +/** The flame: current streak, best streak and progress toward the next milestone. */ +@Composable +private fun StreakHero(state: JourneyUiState) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(TibetanTokens.RadiusCard) + .background(TibetanColors.Brown600) + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("🔥", fontSize = 44.sp) + Spacer(Modifier.height(6.dp)) + Text( + if (state.streakDays > 0) stringResource(R.string.journey_streak_days, state.streakDays) + else stringResource(R.string.journey_streak_none_title), + color = TibetanColors.Cream, + fontSize = 24.sp, + fontWeight = FontWeight.ExtraBold, + ) + Spacer(Modifier.height(4.dp)) + Text( + when { + state.streakDays == 0 -> stringResource(R.string.journey_streak_none_subtitle) + state.typedToday -> stringResource(R.string.journey_streak_done_today) + else -> stringResource(R.string.journey_streak_at_risk) + }, + color = TibetanColors.CreamDim, + fontSize = 13.sp, + textAlign = TextAlign.Center, + ) + + val next = state.nextMilestone + if (next != null) { + Spacer(Modifier.height(16.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(99.dp)) + .background(TibetanColors.Bg800) + ) { + Box( + modifier = Modifier + .fillMaxWidth((state.streakDays / next.toFloat()).coerceIn(0f, 1f)) + .height(6.dp) + .clip(RoundedCornerShape(99.dp)) + .background(TibetanTokens.GoldVertical) + ) + } + Spacer(Modifier.height(8.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + stringResource(R.string.journey_next_milestone, next), + color = TibetanColors.Gold300, fontSize = 12.sp, fontWeight = FontWeight.Bold, + ) + if (state.bestStreak > 0) { + Text( + stringResource(R.string.journey_best_streak, state.bestStreak), + color = TibetanColors.CreamDim, fontSize = 12.sp, + ) + } + } + } + } +} + +@Composable +private fun StatsGrid(state: JourneyUiState, onUpgrade: () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(11.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(11.dp)) { + StatTile( + label = stringResource(R.string.journey_words_today), + value = state.wordsToday.toString(), + modifier = Modifier.weight(1f), + ) + StatTile( + label = stringResource(R.string.journey_words_week), + value = state.wordsThisWeek.toString(), + modifier = Modifier.weight(1f), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(11.dp)) { + StatTile( + label = stringResource(R.string.journey_words_total), + value = state.totalWords.toString(), + modifier = Modifier.weight(1f), + ) + // Vocabulary size counts unique dictionary words (see WordSegmenter) — a PRO insight. + if (state.isPremium) { + StatTile( + label = stringResource(R.string.journey_vocabulary), + value = state.vocabularySize.toString(), + modifier = Modifier.weight(1f), + ) + } else { + LockedStatTile( + label = stringResource(R.string.journey_vocabulary), + onClick = onUpgrade, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun StatTile(label: String, value: String, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .background(TibetanColors.Brown700) + .border(1.dp, TibetanColors.Line, RoundedCornerShape(16.dp)) + .padding(horizontal = 14.dp, vertical = 13.dp), + ) { + Text(value, color = TibetanColors.Cream, fontSize = 22.sp, fontWeight = FontWeight.ExtraBold) + Spacer(Modifier.height(2.dp)) + Text(label, color = TibetanColors.CreamDim, fontSize = 12.sp) + } +} + +@Composable +private fun LockedStatTile(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .background(TibetanColors.Brown700) + .border(1.dp, TibetanColors.Line, RoundedCornerShape(16.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 13.dp), + ) { + Column { + Icon(AppIcons.Lock, null, tint = TibetanColors.Gold300, modifier = Modifier.size(22.dp)) + Spacer(Modifier.height(2.dp)) + Text(label, color = TibetanColors.CreamDim, fontSize = 12.sp) + } + PillBadge("PRO", modifier = Modifier.align(Alignment.TopEnd)) + } +} + +/** Last-7-days bar chart. Pure Compose boxes — no chart library needed for 7 bars. */ +@Composable +private fun WeekChart(weekWords: List, weekDayLabels: List) { + val max = (weekWords.maxOrNull() ?: 0).coerceAtLeast(1) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(TibetanColors.Brown700) + .border(1.dp, TibetanColors.Line, RoundedCornerShape(16.dp)) + .padding(14.dp), + ) { + Text( + stringResource(R.string.journey_week_chart_title), + color = TibetanColors.Cream, fontSize = 13.5.sp, fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth().height(64.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Bottom, + ) { + weekWords.forEachIndexed { index, words -> + val isToday = index == weekWords.lastIndex + Box( + modifier = Modifier + .weight(1f) + .height((4 + 60 * (words / max.toFloat())).dp) + .clip(RoundedCornerShape(topStart = 5.dp, topEnd = 5.dp)) + .then( + if (isToday) Modifier.background(TibetanTokens.GoldVertical) + else Modifier.background(TibetanColors.Bg800) + ), + ) + } + } + Spacer(Modifier.height(6.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + weekDayLabels.forEachIndexed { index, label -> + val isToday = index == weekDayLabels.lastIndex + Text( + label, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + color = if (isToday) TibetanColors.Gold300 else TibetanColors.CreamDim, + fontSize = 11.sp, + fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, + ) + } + } + } +} + +/** Opt-in, numbers-only community comparison (PRO). */ +@Composable +private fun CommunityCard( + state: JourneyUiState, + onToggleSync: (Boolean) -> Unit, + onSignIn: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(TibetanColors.Brown700) + .border(1.dp, TibetanColors.Line, RoundedCornerShape(16.dp)) + .padding(14.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.journey_community_title), + color = TibetanColors.Cream, fontSize = 13.5.sp, fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(3.dp)) + Text( + stringResource(R.string.journey_community_desc), + color = TibetanColors.CreamDim, fontSize = 12.sp, + ) + } + Spacer(Modifier.size(10.dp)) + GoldToggle(state.syncEnabled, onToggleSync) + } + if (state.syncEnabled) { + Spacer(Modifier.height(10.dp)) + if (state.percentile != null) { + // A percentile from an earlier successful sync stays on screen (stale-while- + // revalidate) even if a later background retry fails — no flicker back to an + // error state once the user has seen a real result. + CommunityResult(state.percentile) + } else { + Text( + when { + // Ground truth is the actual auth session, not just "no percentile yet" — + // a signed-in user whose sync failed must never see "sign in" again. + !state.isSignedIn -> stringResource(R.string.journey_community_signin) + state.comparing -> stringResource(R.string.journey_community_comparing) + else -> stringResource(R.string.journey_community_sync_failed) + }, + color = TibetanColors.Gold300, fontSize = 13.sp, fontWeight = FontWeight.Bold, + modifier = if (!state.isSignedIn) Modifier.clickable(onClick = onSignIn) else Modifier, + ) + } + } + } +} + +/** + * Tiered, aspirational headline only — deliberately no raw "N of M" counts. With a small early + * user base, an exact denominator advertises how few people are in the comparison pool, which + * undercuts the "join a thriving community" pitch this card exists to make. + */ +@Composable +private fun CommunityResult(percentile: Int) { + Text( + stringResource( + when (CommunityInsight.tierFor(percentile)) { + CommunityTier.ELITE -> R.string.journey_community_tier_elite + CommunityTier.TOP -> R.string.journey_community_tier_top + CommunityTier.ABOVE_AVERAGE -> R.string.journey_community_tier_above + CommunityTier.BUILDING -> R.string.journey_community_tier_building + } + ), + color = TibetanColors.Gold300, fontSize = 13.sp, fontWeight = FontWeight.Bold, + ) +} + +/** Milestone celebration. Free users get the upsell exactly at the reward moment. */ +@Composable +private fun MilestoneBanner(milestone: Int, isPremium: Boolean, onUpgrade: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(TibetanTokens.GoldVertical) + .then(if (isPremium) Modifier else Modifier.clickable(onClick = onUpgrade)) + .padding(horizontal = 16.dp, vertical = 14.dp), + ) { + Text( + stringResource(R.string.journey_milestone_title, milestone), + color = TibetanColors.Espresso, fontSize = 15.sp, fontWeight = FontWeight.ExtraBold, + ) + Spacer(Modifier.height(3.dp)) + Text( + stringResource( + if (isPremium) R.string.journey_milestone_sub_pro + else R.string.journey_milestone_sub_free + ), + color = TibetanColors.Espresso.copy(alpha = 0.75f), fontSize = 12.5.sp, + ) + } +} + +/** + * What free users see instead of the chart + community comparison: their REAL numbers with + * the PRO value spelled out against them (Grammarly-style tease), not a generic lock. + */ +@Composable +private fun TeaserInsightsCard(state: JourneyUiState, onUpgrade: () -> Unit) { + // Honest, conservative estimate: Botok autocomplete typically completes a word after + // its first syllable, saving very roughly a third of the keystrokes. + val savableChars = state.charsThisWeek / 3 + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(TibetanColors.Brown700) + .border(1.dp, TibetanColors.Line, RoundedCornerShape(16.dp)) + .padding(16.dp), + ) { + Text( + stringResource(R.string.journey_teaser_title), + color = TibetanColors.Cream, fontSize = 14.5.sp, fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(6.dp)) + Text( + stringResource(R.string.journey_teaser_week, state.wordsThisWeek, state.charsThisWeek), + color = TibetanColors.CreamDim, fontSize = 12.5.sp, + ) + if (savableChars > 0) { + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.journey_teaser_savings, savableChars), + color = TibetanColors.Gold300, fontSize = 12.5.sp, fontWeight = FontWeight.Bold, + ) + } + Spacer(Modifier.height(10.dp)) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) { + Icon(AppIcons.Lock, null, tint = TibetanColors.Gold300, modifier = Modifier.size(15.dp)) + Text( + stringResource(R.string.journey_teaser_locked), + color = TibetanColors.CreamDim, fontSize = 12.sp, + ) + } + Spacer(Modifier.height(12.dp)) + GoldButton(stringResource(R.string.journey_unlock_pro), onClick = onUpgrade, fontSize = 14.sp) + } +} + +/** The trust statement — always visible, never behind PRO. */ +@Composable +private fun PrivacyCard() { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(TibetanColors.Bg800) + .border(1.dp, TibetanColors.Line, RoundedCornerShape(16.dp)) + .padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(AppIcons.Lock, null, tint = TibetanColors.Jade, modifier = Modifier.size(18.dp)) + Column { + Text( + stringResource(R.string.journey_privacy_title), + color = TibetanColors.Cream, fontSize = 13.sp, fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(3.dp)) + Text( + stringResource(R.string.journey_privacy_desc), + color = TibetanColors.CreamDim, fontSize = 12.sp, lineHeight = 17.sp, + ) + } + } +} diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/JourneyViewModel.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/JourneyViewModel.kt new file mode 100644 index 0000000..0824f68 --- /dev/null +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/JourneyViewModel.kt @@ -0,0 +1,126 @@ +package com.kharagedition.tibetankeyboard.ui.journey + +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.auth.AuthManager +import com.kharagedition.tibetankeyboard.data.local.TypingStatsStore +import com.kharagedition.tibetankeyboard.data.local.UserPreferences +import com.kharagedition.tibetankeyboard.data.repository.JourneyRepository +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 +import kotlinx.coroutines.launch + +/** Immutable UI state for the Journey (streak & insights) screen. Numbers only, by design. */ +data class JourneyUiState( + val streakDays: Int = 0, + val bestStreak: Int = 0, + val typedToday: Boolean = false, + val wordsToday: Int = 0, + val wordsThisWeek: Int = 0, + val charsThisWeek: Int = 0, + val totalWords: Long = 0L, + val vocabularySize: Int = 0, + /** Words per day for the last 7 days, oldest first (today last). */ + val weekWords: List = List(7) { 0 }, + /** Single-letter weekday labels matching [weekWords]'s order. */ + val weekDayLabels: List = WeekLabels.lastSevenDays(), + val nextMilestone: Int? = StreakLogic.MILESTONES.first(), + /** Set when the current streak sits exactly on a milestone — drives the celebration banner. */ + val milestone: Int? = null, + val isPremium: Boolean = false, + val isSignedIn: Boolean = false, + val syncEnabled: Boolean = false, + /** Community percentile (0–100) once the opt-in comparison has run; null otherwise. */ + val percentile: Int? = null, + val comparing: Boolean = false, + /** True when a signed-in sync attempt came back empty — distinct from "not signed in". */ + val syncFailed: Boolean = false, +) + +/** + * Owns the Journey UI state: everything is read from the on-device [TypingStatsStore]; + * the only network touch is the opt-in, numbers-only community comparison. + */ +class JourneyViewModel(app: Application) : AndroidViewModel(app) { + + private val stats = TypingStatsStore.getInstance(app) + private val repository = JourneyRepository() + private val authManager = AuthManager(app) + private val premiumLiveData = RevenueCatManager.getInstance().isPremiumUser + + private val _uiState = MutableStateFlow(JourneyUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val premiumObserver = Observer { isPremium -> + _uiState.update { it.copy(isPremium = isPremium) } + } + + init { + premiumLiveData.observeForever(premiumObserver) + RevenueCatManager.getInstance().refreshCustomerInfo() + refresh() + } + + /** Re-read the on-device stats (cheap; called on every resume). */ + fun refresh() { + val today = stats.todayEpochDay() + val streakDays = stats.displayStreak() + _uiState.update { + it.copy( + streakDays = streakDays, + bestStreak = stats.streak().bestDays, + typedToday = stats.streak().lastActiveEpochDay == today, + wordsToday = stats.wordsToday(), + wordsThisWeek = stats.wordsThisWeek(today), + charsThisWeek = stats.charsThisWeek(today), + totalWords = stats.totalWords(), + vocabularySize = stats.vocabularySize(), + weekWords = stats.weekWords(today), + weekDayLabels = WeekLabels.lastSevenDays(), + nextMilestone = StreakLogic.nextMilestone(streakDays), + milestone = streakDays.takeIf { it in StreakLogic.MILESTONES }, + syncEnabled = stats.isSyncEnabled(), + isSignedIn = authManager.isUserAuthenticated(), + ) + } + if (stats.isSyncEnabled()) compare() + } + + /** + * Toggle the community comparison. Enabling immediately runs one numbers-only sync; + * disabling stops all future syncs (nothing else is stored server-side by the client). + */ + fun setSyncEnabled(enabled: Boolean) { + stats.setSyncEnabled(enabled) + AppAnalytics.logJourneySyncToggled(enabled) + _uiState.update { it.copy(syncEnabled = enabled, percentile = null) } + if (enabled) compare() + } + + private fun compare() { + val userId = UserPreferences(getApplication()).getUserId() + if (userId.isBlank() || _uiState.value.comparing) return + _uiState.update { it.copy(comparing = true, syncFailed = false) } + viewModelScope.launch { + val state = _uiState.value + val result = repository.submitWeekly(userId, state.wordsThisWeek, state.streakDays) + _uiState.update { + it.copy( + comparing = false, + percentile = result?.percentile ?: it.percentile, + syncFailed = result == null, + ) + } + } + } + + override fun onCleared() { + premiumLiveData.removeObserver(premiumObserver) + } +} diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/StreakLogic.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/StreakLogic.kt new file mode 100644 index 0000000..5feca50 --- /dev/null +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/StreakLogic.kt @@ -0,0 +1,62 @@ +package com.kharagedition.tibetankeyboard.ui.journey + +/** + * Pure (Android-free) streak arithmetic for the "Tibetan Journey" feature, kept as plain data + * so the streak rules are unit-testable without touching SharedPreferences or the IME + * (same pattern as [com.kharagedition.tibetankeyboard.ui.keyboard.ProStripState]). + * + * Days are counted as **local-timezone epoch days** (see TypingStatsStore.todayEpochDay) so the + * streak rolls over at the user's local midnight, like Duolingo's. + */ +data class StreakState( + /** Consecutive active days ending at [lastActiveEpochDay]. 0 = never typed. */ + val lengthDays: Int = 0, + /** Local epoch day of the most recent active day. -1 = never typed. */ + val lastActiveEpochDay: Long = -1L, + /** All-time longest streak. */ + val bestDays: Int = 0, +) + +object StreakLogic { + + /** Celebration milestones. 108 is the mala-bead count — the signature milestone. */ + val MILESTONES = listOf(3, 7, 14, 30, 60, 108, 365) + + /** + * Fold one day of typing activity into the streak. Idempotent within a day: + * calling it again on the same [todayEpochDay] returns the state unchanged. + */ + fun recordActivity(state: StreakState, todayEpochDay: Long): StreakState { + val grown = when (todayEpochDay - state.lastActiveEpochDay) { + 0L -> return state // already counted today + 1L -> state.lengthDays + 1 // consecutive day — streak grows + else -> 1 // first day ever, or streak was broken + } + return StreakState( + lengthDays = grown, + lastActiveEpochDay = todayEpochDay, + bestDays = maxOf(grown, state.bestDays), + ) + } + + /** + * The streak length to show the user. A streak stays *alive* (still shown) through the + * whole day after the last active day; it reads 0 only once a full day has been missed. + */ + fun displayLength(state: StreakState, todayEpochDay: Long): Int = + when (todayEpochDay - state.lastActiveEpochDay) { + 0L, 1L -> state.lengthDays + else -> 0 + } + + /** True when the user has a live streak but hasn't typed yet today — reminder territory. */ + fun isAtRisk(state: StreakState, todayEpochDay: Long): Boolean = + state.lengthDays > 0 && todayEpochDay - state.lastActiveEpochDay == 1L + + /** The milestone crossed by growing from [previousDays] to [currentDays], if any. */ + fun milestoneCrossed(previousDays: Int, currentDays: Int): Int? = + MILESTONES.firstOrNull { previousDays < it && currentDays >= it } + + /** The next milestone ahead of [currentDays] (null once past the last one). */ + fun nextMilestone(currentDays: Int): Int? = MILESTONES.firstOrNull { it > currentDays } +} diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/StreakReminderWorker.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/StreakReminderWorker.kt new file mode 100644 index 0000000..274c65f --- /dev/null +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/StreakReminderWorker.kt @@ -0,0 +1,142 @@ +package com.kharagedition.tibetankeyboard.ui.journey + +import android.Manifest +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.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.preference.PreferenceManager +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.kharagedition.tibetankeyboard.R +import com.kharagedition.tibetankeyboard.analytics.AppAnalytics +import com.kharagedition.tibetankeyboard.data.local.TypingStatsStore +import com.kharagedition.tibetankeyboard.ui.settings.SettingsPrefs +import java.util.Calendar +import java.util.concurrent.TimeUnit + +/** + * Evening check for the Journey loop. Runs once a day (~19:00 local, WorkManager may drift): + * - streak at risk (typed yesterday, not yet today) → "your streak ends at midnight" nudge; + * - Sundays → the weekly insight ("you typed N words this week"), once per week. + * + * Entirely offline: everything is read from [TypingStatsStore]; nothing is fetched or sent. + * Both notifications respect the "Streak reminders" toggle in Settings. + */ +class StreakReminderWorker(context: Context, params: WorkerParameters) : + Worker(context, params) { + + override fun doWork(): Result { + val context = applicationContext + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + if (!prefs.getBoolean(SettingsPrefs.KEY_STREAK_REMINDER, true)) return Result.success() + if (!canNotify(context)) return Result.success() + + val stats = TypingStatsStore.getInstance(context) + val today = stats.todayEpochDay() + val streak = stats.streak() + + if (StreakLogic.isAtRisk(streak, today)) { + notify( + context, + id = NOTIFICATION_ID_REMINDER, + title = context.getString(R.string.journey_reminder_title, streak.lengthDays), + body = context.getString(R.string.journey_reminder_body), + ) + } + + val isSunday = + Calendar.getInstance().get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY + val wordsThisWeek = stats.wordsThisWeek(today) + if (isSunday && wordsThisWeek > 0 && stats.markWeeklyNotified(today)) { + notify( + context, + id = NOTIFICATION_ID_WEEKLY, + title = context.getString(R.string.journey_weekly_title), + body = context.getString(R.string.journey_weekly_body, wordsThisWeek), + ) + } + return Result.success() + } + + private fun canNotify(context: Context): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + + private fun notify(context: Context, id: Int, title: String, body: String) { + val manager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + context.getString(R.string.journey_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { description = context.getString(R.string.journey_channel_desc) } + ) + } + + val intent = Intent(context, JourneyActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra(JourneyActivity.EXTRA_SOURCE, AppAnalytics.JourneySource.NOTIFICATION) + } + val pendingIntent = PendingIntent.getActivity( + context, id, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_star_filled) + .setContentTitle(title) + .setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() + manager.notify(id, notification) + } + + companion object { + private const val CHANNEL_ID = "journey_channel" + private const val WORK_NAME = "journey_streak_reminder" + private const val NOTIFICATION_ID_REMINDER = 4101 + private const val NOTIFICATION_ID_WEEKLY = 4102 + private const val TARGET_HOUR = 19 // ~7pm local — evening, before the streak dies + + /** + * Schedule the daily evening check. Idempotent (KEEP) — safe to call from + * Application.onCreate on every process start, including the IME's. + */ + fun schedule(context: Context) { + val request = PeriodicWorkRequest.Builder( + StreakReminderWorker::class.java, 1, TimeUnit.DAYS, + ) + .setInitialDelay(millisUntilNextTargetHour(), TimeUnit.MILLISECONDS) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request, + ) + } + + private fun millisUntilNextTargetHour(): Long { + val now = Calendar.getInstance() + val next = (now.clone() as Calendar).apply { + set(Calendar.HOUR_OF_DAY, TARGET_HOUR) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + if (before(now) || equals(now)) add(Calendar.DAY_OF_YEAR, 1) + } + return next.timeInMillis - now.timeInMillis + } + } +} diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/WeekLabels.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/WeekLabels.kt new file mode 100644 index 0000000..9362113 --- /dev/null +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/WeekLabels.kt @@ -0,0 +1,27 @@ +package com.kharagedition.tibetankeyboard.ui.journey + +import java.util.Calendar + +/** Single-letter weekday labels under the Journey week chart — matches [TypingStatsStore.weekWords]'s local-day window. */ +object WeekLabels { + + private val LETTERS = mapOf( + Calendar.SUNDAY to "S", + Calendar.MONDAY to "M", + Calendar.TUESDAY to "T", + Calendar.WEDNESDAY to "W", + Calendar.THURSDAY to "T", + Calendar.FRIDAY to "F", + Calendar.SATURDAY to "S", + ) + + /** Labels for the last 7 local days, oldest first, today last — same order as [TypingStatsStore.weekWords]. */ + fun lastSevenDays(now: Long = System.currentTimeMillis()): List { + val today = Calendar.getInstance().apply { timeInMillis = now } + return (6 downTo 0).map { daysAgo -> + val day = today.clone() as Calendar + day.add(Calendar.DAY_OF_YEAR, -daysAgo) + LETTERS.getValue(day.get(Calendar.DAY_OF_WEEK)) + } + } +} diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/WordSegmenter.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/WordSegmenter.kt new file mode 100644 index 0000000..a3e2ef7 --- /dev/null +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/journey/WordSegmenter.kt @@ -0,0 +1,45 @@ +package com.kharagedition.tibetankeyboard.ui.journey + +import com.kharagedition.botok.autocomplete.SuggestionEngine + +/** + * Splits a space/shad-delimited chunk of typed Tibetan into real dictionary words, instead of + * treating the whole chunk as one "word" (Tibetan has no spaces between words — only tsheg + * between syllables — so an uninterrupted chunk is really a whole clause). + * + * Greedy longest-match over syllables using [dictionary] — the same word list the autocomplete + * suggestion strip already loads from assets, so this needs no extra dictionary loading and no + * heavyweight trie construction on the typing thread. It's a simplified stand-in for full Botok + * tokenization (no POS/affix disambiguation), but grounded in the real dictionary rather than a + * syntactic heuristic. + */ +object WordSegmenter { + + private const val TSHEK = '་' + + /** Longest compound entries in the dictionary run to about this many syllables. */ + private const val MAX_SPAN = 6 + + fun segment(chunk: String, dictionary: SuggestionEngine?): List { + val syllables = chunk.split(TSHEK).filter { it.isNotEmpty() } + if (syllables.isEmpty()) return emptyList() + if (dictionary == null || !dictionary.isReady) return listOf(chunk) + + val words = mutableListOf() + var i = 0 + while (i < syllables.size) { + val maxLen = minOf(MAX_SPAN, syllables.size - i) + var matchLen = 1 + for (len in maxLen downTo 2) { + val candidate = syllables.subList(i, i + len).joinToString(TSHEK.toString()) + if (dictionary.containsExact(candidate)) { + matchLen = len + break + } + } + words.add(syllables.subList(i, i + matchLen).joinToString(TSHEK.toString())) + i += matchLen + } + return words + } +} diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/AIKeyboardInterface.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/AIKeyboardInterface.kt index eb58577..179c1e7 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/AIKeyboardInterface.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/AIKeyboardInterface.kt @@ -13,4 +13,7 @@ interface AIKeyboardInterface { /** Route a free user to the unlock flow (login if signed out, else the premium paywall). */ fun onUnlockPro() + + /** Open the Tibetan Journey (streak & typing insights) screen. */ + fun onOpenJourney() } \ 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 index 0fea50c..fc4102e 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/AIKeyboardView.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/keyboard/AIKeyboardView.kt @@ -26,6 +26,7 @@ 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.local.TypingStatsStore import com.kharagedition.tibetankeyboard.data.repository.RevenueCatManager import com.kharagedition.tibetankeyboard.ui.settings.SettingsPrefs import kotlinx.coroutines.* @@ -38,6 +39,7 @@ class AIKeyboardView @JvmOverloads constructor( ) : LinearLayout(context, attrs, defStyleAttr) { private lateinit var aiToolbar: LinearLayout + private lateinit var streakChip: TextView private lateinit var proPill: View private lateinit var proChatBtn: ImageView private lateinit var proAutoBtn: ImageView @@ -103,6 +105,22 @@ class AIKeyboardView @JvmOverloads constructor( loadSuggestionEngine() applyBottomInsetPadding() applyPremiumState() + refreshStreakChip() + } + + /** + * Show the current typing streak on the toolbar (the Journey feature's always-visible + * trigger). The IME rebuilds this view on every keyboard open, so the number stays fresh + * without any observer plumbing. Hidden until the user has a streak at all. + */ + fun refreshStreakChip() { + val days = TypingStatsStore.getInstance(context).displayStreak() + if (days > 0) { + streakChip.text = context.getString(R.string.journey_streak_chip, days) + streakChip.visibility = View.VISIBLE + } else { + streakChip.visibility = View.GONE + } } /** @@ -163,6 +181,7 @@ class AIKeyboardView @JvmOverloads constructor( private fun initializeViews() { aiToolbar = findViewById(R.id.ai_toolbar) + streakChip = findViewById(R.id.streak_chip) proPill = findViewById(R.id.pro_pill) proChatBtn = findViewById(R.id.pro_chat_btn) proAutoBtn = findViewById(R.id.pro_auto_btn) @@ -192,6 +211,9 @@ class AIKeyboardView @JvmOverloads constructor( } private fun setupClickListeners() { + // Streak flame → the Journey screen (available to everyone; streak is the free hook). + streakChip.setOnClickListener { aiKeyboardInterface?.onOpenJourney() } + // Gold upsell pill — free users only (hidden for PRO). proPill.setOnClickListener { aiKeyboardInterface?.onUnlockPro() } @@ -628,5 +650,12 @@ class AIKeyboardView @JvmOverloads constructor( /** Last real navigation-bar inset, remembered across IME view rebuilds. -1 = unknown. */ @Volatile private var cachedNavInset = -1 + + /** + * Read-only access to the process-wide dictionary for callers outside the suggestion + * strip (Journey stats word segmentation) — null until the background asset load + * finishes, same as [suggestionEngine] here. + */ + fun dictionaryOrNull(): SuggestionEngine? = sharedEngine?.takeIf { it.isReady } } } \ No newline at end of file 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 index ea2c89d..0ab84b0 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsActivity.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsActivity.kt @@ -47,6 +47,7 @@ class SettingsActivity : AppCompatActivity() { onVibrate = viewModel::setVibrate, onSound = viewModel::setSound, onNotification = viewModel::setNotification, + onStreakReminder = viewModel::setStreakReminder, onUpgrade = { openPremiumUpgrade(AppAnalytics.UpgradeSource.SETTINGS) }, onLogout = { showConfirmationDialog( 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 index 2a989b2..5b256a9 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsPrefs.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsPrefs.kt @@ -17,6 +17,7 @@ object SettingsPrefs { const val KEY_VIBRATE = "vibrate" const val KEY_SOUND = "sound" const val KEY_NOTIFICATION = "event_notification" + const val KEY_STREAK_REMINDER = "streak_reminder" const val KEY_AI_MODEL = "ai_model" const val KEY_TRANSLATE_ENGINE = "translate_engine" @@ -95,6 +96,7 @@ object SettingsPrefs { vibrate = p.getBoolean(KEY_VIBRATE, false), sound = p.getBoolean(KEY_SOUND, true), eventNotification = p.getBoolean(KEY_NOTIFICATION, true), + streakReminder = p.getBoolean(KEY_STREAK_REMINDER, true), ) } 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 index a3c3708..dc1209a 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsScreen.kt @@ -57,6 +57,7 @@ data class SettingsState( val vibrate: Boolean = false, val sound: Boolean = true, val eventNotification: Boolean = true, + val streakReminder: Boolean = true, val isPremium: Boolean = false, val isAuthenticated: Boolean = false, ) @@ -68,6 +69,7 @@ class SettingsActions( val onVibrate: (Boolean) -> Unit, val onSound: (Boolean) -> Unit, val onNotification: (Boolean) -> Unit, + val onStreakReminder: (Boolean) -> Unit, val onUpgrade: () -> Unit, val onLogout: () -> Unit, ) @@ -130,6 +132,13 @@ fun SettingsScreen( title = stringResource(R.string.event_notifications), trailing = { GoldToggle(state.eventNotification, actions.onNotification) }, ) + Divider() + SettingsRow( + icon = AppIcons.Star, + title = stringResource(R.string.journey_reminder_setting), + subtitle = stringResource(R.string.journey_reminder_setting_desc), + trailing = { GoldToggle(state.streakReminder, actions.onStreakReminder) }, + ) } Spacer(Modifier.height(18.dp)) 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 index ef71df5..84c3a69 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsViewModel.kt @@ -44,6 +44,8 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { .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()) } + fun setStreakReminder(on: Boolean) = write(SettingsPrefs.KEY_STREAK_REMINDER, on) { it.copy(streakReminder = on) } + .also { AppAnalytics.logSettingChanged(AppAnalytics.Setting.STREAK_REMINDER, on.toString()) } private fun write(key: String, value: String, reduce: (SettingsState) -> SettingsState) { SettingsPrefs.putString(getApplication(), key, value) diff --git a/app/src/main/res/layout/ai_keyboard_layout.xml b/app/src/main/res/layout/ai_keyboard_layout.xml index 13ab1e8..97b7722 100644 --- a/app/src/main/res/layout/ai_keyboard_layout.xml +++ b/app/src/main/res/layout/ai_keyboard_layout.xml @@ -1,6 +1,7 @@ + + + English Chinese + + Your Tibetan Journey + 🔥 %1$d + Typing streak + %1$d-day streak + Light your flame + Type a little Tibetan today to start your streak + You\'ve typed Tibetan today — streak safe 🎉 + Type some Tibetan today to keep your streak alive + Best: %1$d days + Next milestone: %1$d days + Insights + Words today + This week + Total words + Vocabulary + Your week + Unlock PRO + 🎉 %1$d-day milestone! + An incredible commitment to Tibetan. Keep going! + Celebrate it — unlock autocomplete, AI chat & full insights with PRO. + Your insights are ready + You typed %1$d words (%2$d characters) this week. + PRO autocomplete could have typed ~%1$d of those characters for you. + Vocabulary size · Weekly chart · Community rank + Community comparison + Optional. Shares only two numbers — your weekly word count and streak — never anything you type. + Comparing… + Sign in to compare with the community. + Couldn\'t sync just now — we\'ll retry the next time you open this screen. + 🏆 Elite typist — top 5% this week + 🔥 Top 20% this week + 📈 Above average this week + 🌱 Keep building momentum + Private by design + Your streak and stats are counted on this device only. Words are never stored or sent — the app keeps just the totals, and vocabulary is tracked as unreadable fingerprints. Nothing leaves your phone unless you turn on the community comparison (numbers only). + Share my streak + 🔥 I\'m on a %1$d-day streak writing in Tibetan with %2$s! Keep the language alive: %3$s + %1$d-day streak 🔥 + Start your Tibetan streak + %1$d words today — keep it going + Type in Tibetan today to light your flame + Streak reminders + A nudge in the evening when your streak is at risk + Tibetan Journey + Streak reminders and weekly typing insights + 🔥 Your %1$d-day streak is at risk + Type a little Tibetan before midnight to keep it alive. + Your week in Tibetan + You typed %1$d Tibetan words this week. See your journey → + \ No newline at end of file diff --git a/app/src/test/java/com/kharagedition/tibetankeyboard/ui/journey/CommunityInsightTest.kt b/app/src/test/java/com/kharagedition/tibetankeyboard/ui/journey/CommunityInsightTest.kt new file mode 100644 index 0000000..4993c75 --- /dev/null +++ b/app/src/test/java/com/kharagedition/tibetankeyboard/ui/journey/CommunityInsightTest.kt @@ -0,0 +1,19 @@ +package com.kharagedition.tibetankeyboard.ui.journey + +import org.junit.Assert.assertEquals +import org.junit.Test + +class CommunityInsightTest { + + @Test + fun `tier boundaries match the aspirational copy`() { + assertEquals(CommunityTier.ELITE, CommunityInsight.tierFor(100)) + assertEquals(CommunityTier.ELITE, CommunityInsight.tierFor(95)) + assertEquals(CommunityTier.TOP, CommunityInsight.tierFor(94)) + assertEquals(CommunityTier.TOP, CommunityInsight.tierFor(80)) + assertEquals(CommunityTier.ABOVE_AVERAGE, CommunityInsight.tierFor(79)) + assertEquals(CommunityTier.ABOVE_AVERAGE, CommunityInsight.tierFor(50)) + assertEquals(CommunityTier.BUILDING, CommunityInsight.tierFor(49)) + assertEquals(CommunityTier.BUILDING, CommunityInsight.tierFor(0)) + } +} diff --git a/app/src/test/java/com/kharagedition/tibetankeyboard/ui/journey/StreakLogicTest.kt b/app/src/test/java/com/kharagedition/tibetankeyboard/ui/journey/StreakLogicTest.kt new file mode 100644 index 0000000..50a1012 --- /dev/null +++ b/app/src/test/java/com/kharagedition/tibetankeyboard/ui/journey/StreakLogicTest.kt @@ -0,0 +1,107 @@ +package com.kharagedition.tibetankeyboard.ui.journey + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Guards the streak rules that drive the whole Journey habit loop: growth, breakage, + * idempotency within a day, the grace day for display, at-risk detection, and milestones. + */ +class StreakLogicTest { + + private val day = 20_000L // arbitrary local epoch day + + // ── recordActivity ─────────────────────────────────────────────────────── + + @Test + fun `first ever activity starts a 1-day streak`() { + val s = StreakLogic.recordActivity(StreakState(), day) + assertEquals(1, s.lengthDays) + assertEquals(day, s.lastActiveEpochDay) + assertEquals(1, s.bestDays) + } + + @Test + fun `same-day activity is idempotent`() { + val once = StreakLogic.recordActivity(StreakState(), day) + val twice = StreakLogic.recordActivity(once, day) + assertEquals(once, twice) + } + + @Test + fun `consecutive day grows the streak`() { + var s = StreakLogic.recordActivity(StreakState(), day) + s = StreakLogic.recordActivity(s, day + 1) + s = StreakLogic.recordActivity(s, day + 2) + assertEquals(3, s.lengthDays) + assertEquals(3, s.bestDays) + } + + @Test + fun `missing a day resets the streak to 1 but keeps the best`() { + var s = StreakState(lengthDays = 10, lastActiveEpochDay = day, bestDays = 10) + s = StreakLogic.recordActivity(s, day + 2) // skipped day+1 + assertEquals(1, s.lengthDays) + assertEquals(10, s.bestDays) + } + + @Test + fun `best only moves up`() { + var s = StreakState(lengthDays = 2, lastActiveEpochDay = day, bestDays = 9) + s = StreakLogic.recordActivity(s, day + 1) + assertEquals(3, s.lengthDays) + assertEquals(9, s.bestDays) + s = s.copy(lengthDays = 9) + s = StreakLogic.recordActivity(s, day + 2) + assertEquals(10, s.bestDays) + } + + // ── displayLength ──────────────────────────────────────────────────────── + + @Test + fun `streak shows through the day after the last active day`() { + val s = StreakState(lengthDays = 5, lastActiveEpochDay = day, bestDays = 5) + assertEquals(5, StreakLogic.displayLength(s, day)) // typed today + assertEquals(5, StreakLogic.displayLength(s, day + 1)) // grace day — still alive + assertEquals(0, StreakLogic.displayLength(s, day + 2)) // missed a full day — broken + } + + @Test + fun `no activity ever shows 0`() { + assertEquals(0, StreakLogic.displayLength(StreakState(), day)) + } + + // ── isAtRisk ───────────────────────────────────────────────────────────── + + @Test + fun `at risk only on the grace day`() { + val s = StreakState(lengthDays = 5, lastActiveEpochDay = day, bestDays = 5) + assertFalse(StreakLogic.isAtRisk(s, day)) // already typed today + assertTrue(StreakLogic.isAtRisk(s, day + 1)) // not yet typed today — remind + assertFalse(StreakLogic.isAtRisk(s, day + 2)) // already broken — nothing to save + assertFalse(StreakLogic.isAtRisk(StreakState(), day)) // no streak to protect + } + + // ── milestones ─────────────────────────────────────────────────────────── + + @Test + fun `milestone fires exactly when crossed`() { + assertEquals(3, StreakLogic.milestoneCrossed(2, 3)) + assertEquals(7, StreakLogic.milestoneCrossed(6, 7)) + assertEquals(108, StreakLogic.milestoneCrossed(107, 108)) + assertNull(StreakLogic.milestoneCrossed(3, 4)) // between milestones + assertNull(StreakLogic.milestoneCrossed(7, 7)) // no growth + } + + @Test + fun `next milestone ahead of the current streak`() { + assertEquals(3, StreakLogic.nextMilestone(0)) + assertEquals(7, StreakLogic.nextMilestone(3)) + assertEquals(108, StreakLogic.nextMilestone(60)) + assertEquals(365, StreakLogic.nextMilestone(108)) + assertNull(StreakLogic.nextMilestone(365)) + } +} diff --git a/app/src/test/java/com/kharagedition/tibetankeyboard/ui/journey/WeekLabelsTest.kt b/app/src/test/java/com/kharagedition/tibetankeyboard/ui/journey/WeekLabelsTest.kt new file mode 100644 index 0000000..07396d2 --- /dev/null +++ b/app/src/test/java/com/kharagedition/tibetankeyboard/ui/journey/WeekLabelsTest.kt @@ -0,0 +1,25 @@ +package com.kharagedition.tibetankeyboard.ui.journey + +import org.junit.Assert.assertEquals +import java.util.Calendar +import org.junit.Test + +class WeekLabelsTest { + + @Test + fun `seven labels ending on today, oldest first`() { + // 2024-01-13 was a Saturday. + val cal = Calendar.getInstance().apply { set(2024, Calendar.JANUARY, 13, 12, 0, 0) } + assertEquals( + listOf("S", "M", "T", "W", "T", "F", "S"), // Sun 7th .. Sat 13th + WeekLabels.lastSevenDays(cal.timeInMillis), + ) + } + + @Test + fun `today is always the last label`() { + // 2024-03-04 was a Monday. + val cal = Calendar.getInstance().apply { set(2024, Calendar.MARCH, 4, 9, 30, 0) } + assertEquals("M", WeekLabels.lastSevenDays(cal.timeInMillis).last()) + } +} diff --git a/app/src/test/java/com/kharagedition/tibetankeyboard/ui/journey/WordSegmenterTest.kt b/app/src/test/java/com/kharagedition/tibetankeyboard/ui/journey/WordSegmenterTest.kt new file mode 100644 index 0000000..f251726 --- /dev/null +++ b/app/src/test/java/com/kharagedition/tibetankeyboard/ui/journey/WordSegmenterTest.kt @@ -0,0 +1,47 @@ +package com.kharagedition.tibetankeyboard.ui.journey + +import com.kharagedition.botok.autocomplete.SuggestionEngine +import org.junit.Assert.assertEquals +import org.junit.Test + +class WordSegmenterTest { + + private fun engineOf(vararg forms: String): SuggestionEngine { + val engine = SuggestionEngine() + engine.addLines(forms.asSequence().map { "$it\t\t\t\t1" }) + engine.ready() + return engine + } + + @Test + fun `splits a multi-word chunk into its dictionary words`() { + // "ཀ་དག" (2 syllables) + "ང" (1 syllable) typed back-to-back with no space — + // exactly the case the old space-shad-only chunking miscounted as a single "word". + val engine = engineOf("ཀ་དག", "ང") + assertEquals(listOf("ཀ་དག", "ང"), WordSegmenter.segment("ཀ་དག་ང", engine)) + } + + @Test + fun `prefers the longest dictionary match over single syllables`() { + val engine = engineOf("ཀ", "དག", "ཀ་དག") + assertEquals(listOf("ཀ་དག"), WordSegmenter.segment("ཀ་དག", engine)) + } + + @Test + fun `falls back to single syllables when nothing matches`() { + val engine = engineOf("སོ་སོ") + assertEquals(listOf("ཀ", "ཁ", "ག"), WordSegmenter.segment("ཀ་ཁ་ག", engine)) + } + + @Test + fun `dictionary not ready yet falls back to the whole chunk as one word`() { + val notReady = SuggestionEngine() + assertEquals(listOf("ཀ་དག་ང"), WordSegmenter.segment("ཀ་དག་ང", notReady)) + assertEquals(listOf("ཀ་དག་ང"), WordSegmenter.segment("ཀ་དག་ང", null)) + } + + @Test + fun `empty chunk segments to no words`() { + assertEquals(emptyList(), WordSegmenter.segment("", engineOf("ཀ"))) + } +} diff --git a/botok/src/main/java/com/kharagedition/botok/autocomplete/SuggestionEngine.kt b/botok/src/main/java/com/kharagedition/botok/autocomplete/SuggestionEngine.kt index 8d36ec9..fccea69 100644 --- a/botok/src/main/java/com/kharagedition/botok/autocomplete/SuggestionEngine.kt +++ b/botok/src/main/java/com/kharagedition/botok/autocomplete/SuggestionEngine.kt @@ -54,6 +54,18 @@ class SuggestionEngine { return emptyList() } + /** True if [word] is an exact dictionary entry (used for greedy word-boundary segmentation). */ + fun containsExact(word: String): Boolean { + if (!isReady || word.isEmpty()) return false + var lo = 0 + var hi = words.size + while (lo < hi) { + val mid = (lo + hi).ushr(1) + if (words[mid].form < word) lo = mid + 1 else hi = mid + } + return lo < words.size && words[lo].form == word + } + private fun lookup(prefix: String, max: Int): List { var lo = 0 var hi = words.size