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