Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,5 +252,9 @@ Claude, so they carry the same middleware chain.
resource merge).
- **Always device-verify Compose changes** (`installDebug` + screenshot), not just `assembleDebug` —
a green build can still crash at runtime (e.g. the empty-font bug).
- **Never start the Android emulator** unless the user asked for it, or said yes when asked. It is
slow to boot and device checks belong on the connected phone. If the phone can't take the build
(a debug build can't replace a Play-installed one: the signatures differ, and uninstalling wipes
the app's data), stop and ask how to verify instead of reaching for the emulator.
- A few inline strings remain (Premium feature descriptions, quick-action labels, Tibetan
placeholders) — externalize if expanding i18n.
4 changes: 2 additions & 2 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ android {
// installed devices on API 23 (0.03%).
minSdkVersion 24
targetSdkVersion 36
versionCode project.hasProperty('versionCode') ? project.property('versionCode').toInteger() : 57
versionCode project.hasProperty('versionCode') ? project.property('versionCode').toInteger() : 58

// RevenueCat targeting rules match on this (App version >= 2.3.0).
versionName "2.3.0"
versionName "2.3.1"

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
multiDexEnabled true
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
</service>
<activity
android:name=".ui.login.LoginActivity"
android:exported="false" />
android:exported="false"
android:theme="@style/Theme.TibetanKeyboard.Sheet" />
<activity
android:name=".ui.chat.ChatActivity"
android:exported="false"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,8 @@ object AppAnalytics {
const val ABOUT = "about"
const val UPGRADE = "upgrade"
const val JOURNEY = "journey"
const val SIGN_IN = "sign_in"
const val ACCOUNT = "account"
}

/** Stable source labels for [logJourneyOpened] (where the Journey screen was opened from). */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import com.google.firebase.auth.ktx.auth
import com.google.firebase.ktx.Firebase
import com.kharagedition.tibetankeyboard.R
import com.kharagedition.tibetankeyboard.analytics.AppAnalytics
import com.kharagedition.tibetankeyboard.ui.home.HomeActivity
import com.kharagedition.tibetankeyboard.ui.login.LoginActivity
import com.kharagedition.tibetankeyboard.ui.subscription.KeyboardPremiumActivity
import com.kharagedition.tibetankeyboard.ui.subscription.PremiumActivity
Expand Down Expand Up @@ -42,6 +43,8 @@ class AuthManager(private val context: Context) {
return userPreferences.getUserName().takeIf { it.isNotEmpty() } ?: "User"
}

fun getCurrentUserEmail(): String = userPreferences.getUserEmail()

/**
* Get current user photo URL
*/
Expand Down Expand Up @@ -120,6 +123,22 @@ class AuthManager(private val context: Context) {
if (finishCaller) (context as? Activity)?.finish()
}

/**
* Back to Home, closing the screens above it. Where a sign-out lands: Home needs no account
* and its header offers "Sign in" again, while a login screen would read as a demand.
*/
fun redirectToHome() {
val intent = Intent(context, HomeActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)

if (context !is Activity) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}

context.startActivity(intent)
(context as? Activity)?.finish()
}

/**
* Open the paywall. Safe from a non-Activity context such as the IME service. [source] is the
* [AppAnalytics.UpgradeSource] that triggered it, carried through every purchase event.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ class ChatActivity : AppCompatActivity() {

private fun signOut() {
authManager.signOut {
authManager.redirectToLogin()
showToast(getString(R.string.signed_out))
authManager.redirectToHome()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.MoreHoriz
import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.Palette
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.SwapHoriz
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Share
Expand Down Expand Up @@ -56,6 +57,7 @@ object AppIcons {
val Sound: ImageVector = Icons.Rounded.VolumeUp
val Bell: ImageVector = Icons.Rounded.Notifications
val Logout: ImageVector = Icons.AutoMirrored.Rounded.Logout
val Person: ImageVector = Icons.Rounded.Person
val Copy: ImageVector = Icons.Rounded.ContentCopy
val Send: ImageVector = Icons.AutoMirrored.Rounded.Send
val Globe: ImageVector = Icons.Rounded.Language
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,14 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.kharagedition.tibetankeyboard.ui.compose.theme.LocalTibetanFont
import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanColors
import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanTokens
Expand Down Expand Up @@ -152,6 +154,42 @@ fun CharCounter(
)
}

/**
* The signed-in user's picture in a gold ring. Their initial sits underneath, so it shows while
* the picture loads and stays if there is none.
*/
@Composable
fun AccountAvatar(
name: String,
photoUrl: String?,
modifier: Modifier = Modifier,
size: Dp = 40.dp,
) {
Box(
modifier = modifier
.size(size)
.clip(CircleShape)
.background(TibetanColors.Brown600)
.border(1.5.dp, TibetanColors.Gold400, CircleShape),
contentAlignment = Alignment.Center,
) {
Text(
name.trim().take(1).uppercase().ifEmpty { "?" },
color = TibetanColors.Gold300,
fontSize = (size.value * 0.42f).sp,
fontWeight = FontWeight.ExtraBold,
)
if (!photoUrl.isNullOrBlank()) {
AsyncImage(
model = photoUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().padding(1.5.dp).clip(CircleShape),
)
}
}
}

/** Small rounded badge, e.g. "PRO". */
@Composable
fun PillBadge(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ class HomeActivity : InputMethodActivity() {
super.onResume()
refreshSetupState()
viewModel.refreshJourney()
viewModel.refreshAccount()
// CRITICAL: sync purchases on resume to acknowledge pending subscriptions
// (prevents Google Play auto-cancelling after 3 days).
if (viewModel.isUserAuthenticated()) {
Expand Down Expand Up @@ -205,6 +206,16 @@ class HomeActivity : InputMethodActivity() {
AppAnalytics.logJourneyOpened(AppAnalytics.JourneySource.HOME)
startActivity(Intent(this, JourneyActivity::class.java))
},
onSignIn = {
AppAnalytics.logHomeAction(AppAnalytics.HomeAction.SIGN_IN)
// Home stays underneath, so signing in (or backing out) lands back here.
AuthManager(this).redirectToLogin(finishCaller = false, returnAfterLogin = true)
},
onAccount = {
AppAnalytics.logHomeAction(AppAnalytics.HomeAction.ACCOUNT)
// Plan, manage subscription and sign out live in Settings → Account.
startActivity(Intent(this, SettingsActivity::class.java))
},
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ 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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Icon
Expand All @@ -34,15 +35,18 @@ import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import com.bumptech.glide.Glide
import com.kharagedition.tibetankeyboard.R
import com.kharagedition.tibetankeyboard.analytics.AppAnalytics
import com.kharagedition.tibetankeyboard.ui.compose.components.AccountAvatar
import com.kharagedition.tibetankeyboard.ui.compose.components.AppIcons
import com.kharagedition.tibetankeyboard.ui.compose.components.BoText
import com.kharagedition.tibetankeyboard.ui.compose.components.IconTile
Expand All @@ -61,8 +65,13 @@ data class HomeUiState(
val isPremium: Boolean = false,
val streakDays: Int = 0,
val wordsToday: Int = 0,
/** Who is signed in; null when signed out. */
val account: HomeAccount? = null,
)

/** The signed-in user as the Home header shows them. */
data class HomeAccount(val name: String, val photoUrl: String?)

/** Callbacks for Home actions, owned by the Activity. */
class HomeActions(
val onEnableKeyboard: () -> Unit,
Expand All @@ -75,6 +84,8 @@ class HomeActions(
val onAbout: () -> Unit,
val onUpgrade: () -> Unit,
val onJourney: () -> Unit,
val onSignIn: () -> Unit,
val onAccount: () -> Unit,
)

private data class QuickAction(
Expand Down Expand Up @@ -124,7 +135,7 @@ fun HomeScreen(
Box(modifier = Modifier.fillMaxSize()) {
ScreenScaffold(horizontalPadding = 18.dp) {
Spacer(Modifier.height(8.dp))
BrandHeader()
BrandHeader(state.account, onSignIn = actions.onSignIn, onAccount = actions.onAccount)
Spacer(Modifier.height(18.dp))
SetupCard(
state = state,
Expand Down Expand Up @@ -229,7 +240,7 @@ private fun UpdateReadyBanner(onInstall: () -> Unit, modifier: Modifier = Modifi
}

@Composable
private fun BrandHeader() {
private fun BrandHeader(account: HomeAccount?, onSignIn: () -> Unit, onAccount: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
Expand All @@ -248,10 +259,50 @@ private fun BrandHeader() {
modifier = Modifier.size(72.dp,).clip(RoundedCornerShape(10.dp))
)
}
Column {
Text(stringResource(R.string.app_name), color = TibetanColors.Cream, fontSize = 18.sp, fontWeight = FontWeight.ExtraBold)
Column(Modifier.weight(1f)) {
Text(
stringResource(R.string.app_name),
color = TibetanColors.Cream, fontSize = 18.sp, fontWeight = FontWeight.ExtraBold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
BoText(stringResource(R.string.bo_app_name), color = TibetanColors.CreamDim, fontSize = 13.sp)
}
AccountControl(account, onSignIn = onSignIn, onAccount = onAccount)
}
}

/**
* Top right of Home: "Sign in" for a signed-out user, their picture once signed in. Without it
* the only way to sign in was to reach a PRO screen first, so a subscriber on a new phone had no
* obvious way back to their PRO.
*/
@Composable
private fun AccountControl(account: HomeAccount?, onSignIn: () -> Unit, onAccount: () -> Unit) {
if (account == null) {
Row(
modifier = Modifier
.clip(TibetanTokens.Pill)
.border(1.dp, TibetanColors.Gold400.copy(alpha = 0.7f), TibetanTokens.Pill)
.clickable(role = Role.Button, onClick = onSignIn)
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(AppIcons.Person, null, tint = TibetanColors.Gold300, modifier = Modifier.size(17.dp))
Text(stringResource(R.string.sign_in), color = TibetanColors.Gold300, fontSize = 13.sp, fontWeight = FontWeight.Bold)
}
} else {
AccountAvatar(
name = account.name,
photoUrl = account.photoUrl,
modifier = Modifier
.clip(CircleShape)
.clickable(
onClickLabel = stringResource(R.string.title_account),
role = Role.Button,
onClick = onAccount,
),
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ class HomeViewModel(app: Application) : AndroidViewModel(app) {
init {
premiumLiveData.observeForever(premiumObserver)
RevenueCatManager.getInstance().refreshCustomerInfo()
refreshAccount()
}

/** Re-read who is signed in (every resume: signing in and out happen on other screens). */
fun refreshAccount() {
val account = if (authManager.isUserAuthenticated()) {
HomeAccount(
name = authManager.getCurrentUserName(),
photoUrl = authManager.getCurrentUserPhotoUrl().takeIf { it.isNotBlank() },
)
} else null
_uiState.update { it.copy(account = account) }
}

/** Called by the Activity after it reads keyboard-enabled / default-IME status. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ class LoginActivity : AppCompatActivity() {
onGoogleSignIn = { signInWithGoogle() },
onTerms = { openTermsOfService() },
onPrivacy = { openPrivacyPolicy() },
// Swiped away, tapped outside or Back: whatever is underneath is still there.
onDismiss = { finish() },
)
}
}
Expand Down
Loading
Loading