diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index fedbdfb..178187e 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -14,7 +14,7 @@ concurrency: jobs: build: - name: Assemble Release APK + name: Build APK runs-on: ubuntu-latest steps: diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index ef887f1..ba09367 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -2,28 +2,27 @@ - - - - - + - - - + diff --git a/app/src/main/java/lc/fungee/IngrediCheck/IngrediCheckApp.kt b/app/src/main/java/lc/fungee/IngrediCheck/IngrediCheckApp.kt index 7e76934..232aa44 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/IngrediCheckApp.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/IngrediCheckApp.kt @@ -4,6 +4,8 @@ import android.app.Application import com.posthog.android.PostHogAndroid import com.posthog.android.PostHogAndroidConfig import lc.fungee.IngrediCheck.di.AppContainer +import com.posthog.PostHog +import lc.fungee.IngrediCheck.model.utils.AppConstants class IngrediCheckApp : Application() { lateinit var container: AppContainer @@ -31,6 +33,8 @@ class IngrediCheckApp : Application() { } PostHogAndroid.setup(this, config) + val internal = AppConstants.isInternalEnabled(this) + PostHog.register("is_internal", internal) } companion object { diff --git a/app/src/main/java/lc/fungee/IngrediCheck/analytics/Analytics.kt b/app/src/main/java/lc/fungee/IngrediCheck/analytics/Analytics.kt index 088ab8d..eaa0f6b 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/analytics/Analytics.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/analytics/Analytics.kt @@ -151,4 +151,25 @@ object Analytics { // Property key must match iOS exactly PostHog.capture(event = "Image Captured", properties = mapOf("time" to epochSeconds)) } + + fun identifyAndRegister(distinctId: String?, isInternal: Boolean, email: String? = null) { + if (!distinctId.isNullOrBlank()) { + val props = mutableMapOf("is_internal" to isInternal) + if (!email.isNullOrBlank()) props["email"] = email + PostHog.identify( + distinctId = distinctId, + userProperties = props + ) + } + PostHog.register("is_internal", isInternal) + } + + fun registerInternal(isInternal: Boolean) { + PostHog.register("is_internal", isInternal) + } + + fun resetAndRegister(isInternal: Boolean) { + PostHog.reset() + PostHog.register("is_internal", isInternal) + } } diff --git a/app/src/main/java/lc/fungee/IngrediCheck/model/repository/LoginAuthRepository.kt b/app/src/main/java/lc/fungee/IngrediCheck/model/repository/LoginAuthRepository.kt index b7e262a..5a6d183 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/model/repository/LoginAuthRepository.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/model/repository/LoginAuthRepository.kt @@ -1,4 +1,4 @@ -package lc.fungee.IngrediCheck.model.repository +package lc.fungee.IngrediCheck.model.repository import lc.fungee.IngrediCheck.model.utils.AppConstants import lc.fungee.IngrediCheck.model.entities.AppleAuthConfig @@ -25,6 +25,7 @@ import lc.fungee.IngrediCheck.ui.view.screens.onboarding.AppleLoginWebViewActivi import lc.fungee.IngrediCheck.model.source.SharedPreferencesSessionManager import kotlin.time.ExperimentalTime import lc.fungee.IngrediCheck.model.repository.auth.AuthProvider + @OptIn(ExperimentalTime::class) class LoginAuthRepository( @@ -44,6 +45,8 @@ class LoginAuthRepository( install(Storage) } + + fun hasStoredSession(): Boolean { return try { context.getSharedPreferences(AppConstants.Prefs.SUPABASE_SESSION, Context.MODE_PRIVATE) diff --git a/app/src/main/java/lc/fungee/IngrediCheck/model/utils/Constants.kt b/app/src/main/java/lc/fungee/IngrediCheck/model/utils/Constants.kt index e2a47c7..cd92d95 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/model/utils/Constants.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/model/utils/Constants.kt @@ -23,10 +23,12 @@ object AppConstants { // SharedPreferences file names const val USER_SESSION = "user_session" const val SUPABASE_SESSION = "supabase_session" + const val INTERNAL_FLAGS = "internal_flags" // Common keys inside SharedPreferences const val KEY_LOGIN_PROVIDER = "login_provider" const val KEY_DISCLAIMER_ACCEPTED = "disclaimer_accepted" + const val KEY_INTERNAL_MODE = "is_internal_user" } object Providers { @@ -55,5 +57,21 @@ object AppConstants { val HOST: String? get() = Uri.parse(URL).host } + + fun isInternalEnabled(context: android.content.Context): Boolean { + return try { + context.getSharedPreferences(Prefs.INTERNAL_FLAGS, android.content.Context.MODE_PRIVATE) + .getBoolean(Prefs.KEY_INTERNAL_MODE, false) + } catch (_: Exception) { false } + } + + fun setInternalEnabled(context: android.content.Context, enabled: Boolean) { + try { + context.getSharedPreferences(Prefs.INTERNAL_FLAGS, android.content.Context.MODE_PRIVATE) + .edit() + .putBoolean(Prefs.KEY_INTERNAL_MODE, enabled) + .apply() + } catch (_: Exception) { } + } } diff --git a/app/src/main/java/lc/fungee/IngrediCheck/ui/view/screens/setting/Settingscreen.kt b/app/src/main/java/lc/fungee/IngrediCheck/ui/view/screens/setting/Settingscreen.kt index a85fef3..2259a33 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/ui/view/screens/setting/Settingscreen.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/ui/view/screens/setting/Settingscreen.kt @@ -19,6 +19,7 @@ import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Star import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.TextButton @@ -51,7 +52,9 @@ import lc.fungee.IngrediCheck.viewmodel.PreferenceViewModel import lc.fungee.IngrediCheck.viewmodel.AppleAuthViewModel import com.google.android.gms.auth.api.signin.GoogleSignInClient import kotlinx.coroutines.delay +import kotlinx.coroutines.Job import lc.fungee.IngrediCheck.model.utils.AppConstants +import android.widget.Toast enum class ConfirmAction { NONE, DELETE_ACCOUNT, RESET_GUEST @@ -75,7 +78,15 @@ fun SettingScreen( val isGuest = loginProvider.isNullOrBlank() || loginProvider == AppConstants.Providers.ANONYMOUS val coroutineScope = rememberCoroutineScope() var showSignOutDialog by remember { mutableStateOf(false) } + var internalEnabled by remember { mutableStateOf(AppConstants.isInternalEnabled(context)) } + var versionTapCount by remember { mutableStateOf(0) } + var tapResetJob by remember { mutableStateOf(null) } + var internalTapCount by remember { mutableStateOf(0) } + var internalTapResetJob by remember { mutableStateOf(null) } + var isSignOutLoading by remember { mutableStateOf(false) } + var isResetLoading by remember { mutableStateOf(false) } var showDeleteAccountDialog by remember { mutableStateOf(false) } + var isDeleteAccountLoading by remember { mutableStateOf(false) } var showDeleteGuestDialog by remember { mutableStateOf(false) } var confirmAction by remember { mutableStateOf(ConfirmAction.NONE) } fun clearWebCookies() { @@ -138,21 +149,32 @@ fun SettingScreen( "Reset App State", R.drawable.fluent_warning_20_regular, tint = AppColors.ErrorStrong, - tint2 = AppColors.ErrorStrong, showDivider = false + tint2 = AppColors.ErrorStrong, + showDivider = false, + trailingLoading = isResetLoading ) { confirmAction = ConfirmAction.RESET_GUEST } } else { // Authenticated user: show Sign Out and Delete Data & Account IconRow( "Sign Out", R.drawable.stash_signout_light__1_, - tint = AppColors.Brand, showArrow = false - ) { clearAllSession()} + tint = AppColors.Brand, + showArrow = false, + trailingLoading = isSignOutLoading + ) { + if (!isSignOutLoading) { + isSignOutLoading = true + clearAllSession() + } + } IconRow( "Delete Data & Account", R.drawable.fluent_warning_20_regular, tint = AppColors.ErrorStrong, - tint2 = AppColors.ErrorStrong,showDivider = false - ) { confirmAction = ConfirmAction.DELETE_ACCOUNT } + tint2 = AppColors.ErrorStrong, + showDivider = false, + trailingLoading = isDeleteAccountLoading + ) { if (!isDeleteAccountLoading) confirmAction = ConfirmAction.DELETE_ACCOUNT } } } @@ -190,11 +212,56 @@ fun SettingScreen( // R.drawable.rightbackbutton ) { selectedUrl = AppConstants.Website.PRIVACY } + if (internalEnabled) { + IconRow( + "Internal Mode Enabled", + R.drawable.fluent_warning_20_regular, + tint = AppColors.Brand, + tint2 = AppColors.Brand, + showDivider = true, + showArrow = false, + onClick = { + internalTapCount += 1 + if (internalTapCount == 1) { + internalTapResetJob?.cancel() + internalTapResetJob = coroutineScope.launch { + delay(1500) + internalTapCount = 0 + } + } + if (internalTapCount >= 7) { + internalTapCount = 0 + internalTapResetJob?.cancel() + viewModel.disableInternalMode(context) + internalEnabled = false + Toast.makeText(context, "Internal Mode Disabled", Toast.LENGTH_SHORT).show() + } + } + ) + } + IconRow( "IngrediCheck for Android 1.0.0.(4)", R.drawable.rectangle_34624324__1_, // null, - showDivider = false + showDivider = false, + onClick = { + versionTapCount += 1 + if (versionTapCount == 1) { + tapResetJob?.cancel() + tapResetJob = coroutineScope.launch { + delay(1500) + versionTapCount = 0 + } + } + if (versionTapCount >= 7) { + versionTapCount = 0 + tapResetJob?.cancel() + viewModel.enableInternalMode(context) + internalEnabled = true + Toast.makeText(context, "Internal Mode Enabled", Toast.LENGTH_SHORT).show() + } + } ) } @@ -212,6 +279,7 @@ fun SettingScreen( confirmText = "I Understand" onConfirm = { coroutineScope.launch { + isDeleteAccountLoading = true // First: try remote account deletion (Edge Function must be deployed server-side) runCatching { preferenceViewModel.deleteAccountRemote() } clearAllSession() @@ -223,6 +291,7 @@ fun SettingScreen( confirmText = "I Understand" onConfirm = { coroutineScope.launch { + isResetLoading = true runCatching { viewModel.signOut(context) } runCatching { viewModel.clearSupabaseLocalSession() } preferenceViewModel.clearAllLocalData() @@ -515,6 +584,7 @@ private fun IconRow( tint2: Color = AppColors.Brand, showDivider: Boolean = true, // showArrow: Boolean = true, // ✅ new flag + trailingLoading: Boolean = false, onClick: (() -> Unit)? = null ) { Column { @@ -523,6 +593,7 @@ private fun IconRow( .fillMaxWidth() .height(45.dp) .clickable( + enabled = !trailingLoading, indication = null, interactionSource = remember { MutableInteractionSource() } ) { onClick?.invoke() } @@ -547,18 +618,19 @@ private fun IconRow( ) ) Spacer(modifier = Modifier.weight(1f)) -// - if(showDivider ) { - if(showArrow) { - - Icon( - imageVector = Icons.Default.KeyboardArrowRight, - modifier = Modifier.size(20.dp), - contentDescription = null, - tint = Grey75 - ) - } - + if (trailingLoading) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = AppColors.Brand + ) + } else if (showDivider && showArrow) { + Icon( + imageVector = Icons.Default.KeyboardArrowRight, + modifier = Modifier.size(20.dp), + contentDescription = null, + tint = Grey75 + ) } } diff --git a/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt b/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt index ac18c8d..121f850 100644 --- a/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt +++ b/app/src/main/java/lc/fungee/IngrediCheck/viewmodel/LoginAuthViewModel.kt @@ -15,7 +15,10 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import kotlinx.serialization.json.put import lc.fungee.IngrediCheck.model.repository.LoginAuthRepository +import lc.fungee.IngrediCheck.analytics.Analytics +import lc.fungee.IngrediCheck.IngrediCheckApp sealed class AppleLoginState { object Idle : AppleLoginState() @@ -61,6 +64,7 @@ class AppleAuthViewModel( _loginState.value = AppleLoginState.Success(s) userEmail = s.user?.email userId = s.user?.id + updateAnalyticsAndSupabase(s) restoring = false _isAuthChecked.value = true return@launch @@ -87,6 +91,7 @@ class AppleAuthViewModel( _loginState.value = AppleLoginState.Success(current) userEmail = current.user?.email userId = current.user?.id + updateAnalyticsAndSupabase(current) isAppleLoading = false } else { _loginState.value = AppleLoginState.Idle @@ -132,6 +137,7 @@ class AppleAuthViewModel( .apply() userEmail = session.user?.email userId = session.user?.id + updateAnalyticsAndSupabase(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -171,6 +177,7 @@ class AppleAuthViewModel( userEmail = session.user?.email userId = session.user?.id Log.d("AppleAuthViewModel", "User data extracted - Email: $userEmail, ID: $userId") + updateAnalyticsAndSupabase(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -217,6 +224,7 @@ class AppleAuthViewModel( userEmail = session.user?.email userId = session.user?.id Log.d("AppleAuthViewModel", "User data extracted - Email: $userEmail, ID: $userId") + updateAnalyticsAndSupabase(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -254,6 +262,7 @@ class AppleAuthViewModel( .apply() userEmail = session.user?.email userId = session.user?.id + updateAnalyticsAndSupabase(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -287,6 +296,7 @@ class AppleAuthViewModel( userEmail = session.user?.email ?: "anonymous@example.com" userId = session.user?.id ?: "anonymous_${System.currentTimeMillis()}" Log.d("AppleAuthViewModel", "Anonymous user data - Email: $userEmail, ID: $userId") + updateAnalyticsAndSupabase(session) AppleLoginState.Success(session) }, onFailure = { exception -> @@ -355,6 +365,7 @@ class AppleAuthViewModel( .edit() .clear() .apply() + Analytics.resetAndRegister(AppConstants.isInternalEnabled(context)) }, onFailure = { exception -> Log.e("AppleAuthViewModel", "Sign out failed", exception) @@ -378,6 +389,7 @@ class AppleAuthViewModel( .edit() .clear() .apply() + Analytics.resetAndRegister(AppConstants.isInternalEnabled(context)) } } } @@ -387,6 +399,36 @@ class AppleAuthViewModel( return repository.getCurrentSession() } + fun enableInternalMode(context: Context) { + AppConstants.setInternalEnabled(context, true) + Analytics.registerInternal(true) + val s = repository.getCurrentSession() + updateAnalyticsAndSupabase(s) + } + + fun disableInternalMode(context: Context) { + AppConstants.setInternalEnabled(context, false) + Analytics.registerInternal(false) + val s = repository.getCurrentSession() + updateAnalyticsAndSupabase(s) + } + + private fun updateAnalyticsAndSupabase(session: UserSession?) { + val ctx = IngrediCheckApp.appInstance + val isInternal = AppConstants.isInternalEnabled(ctx) + val distinctId = session?.user?.id + val email = session?.user?.email + Analytics.identifyAndRegister(distinctId, isInternal, email) + if (session != null) { + viewModelScope.launch { + try { + repository.supabaseClient.auth.updateUser { + data { put("is_internal", isInternal) } + } + } catch (_: Exception) { } + } + } + } // Google Web OAuth removed. Use native GoogleSignInClient to obtain an ID token, // then call signInWithGoogleIdToken(idToken, context) }