diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml new file mode 100644 index 0000000..63960bf --- /dev/null +++ b/.github/workflows/mobile-ci.yml @@ -0,0 +1,30 @@ +name: Mobile CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + android-build: + name: Android Build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build debug APK + run: ./gradlew :composeApp:assembleDebug + + - name: Run unit tests + run: ./gradlew :composeApp:testDebugUnitTest diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 747aaf8..0b88b0d 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -68,6 +68,9 @@ kotlin { // DataStore for widget data persistence implementation("androidx.datastore:datastore-preferences:1.1.1") + + // Encrypted SharedPreferences for secure token storage + implementation(libs.androidx.security.crypto) } iosMain.dependencies { implementation(libs.ktor.client.darwin) @@ -130,6 +133,9 @@ android { isMinifyEnabled = false } } + buildFeatures { + buildConfig = true + } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/MyFirebaseMessagingService.kt b/composeApp/src/androidMain/kotlin/com/example/casera/MyFirebaseMessagingService.kt index 50a8660..b8ccd09 100644 --- a/composeApp/src/androidMain/kotlin/com/example/casera/MyFirebaseMessagingService.kt +++ b/composeApp/src/androidMain/kotlin/com/example/casera/MyFirebaseMessagingService.kt @@ -8,7 +8,7 @@ import android.content.Intent import android.os.Build import android.util.Log import androidx.core.app.NotificationCompat -import com.example.casera.cache.SubscriptionCache +import com.example.casera.data.DataManager import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import kotlinx.coroutines.CoroutineScope @@ -155,7 +155,7 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { } private fun isPremiumUser(): Boolean { - val subscription = SubscriptionCache.currentSubscription.value + val subscription = DataManager.subscription.value // User is premium if limitations are disabled return subscription?.limitationsEnabled == false } diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/NotificationActionReceiver.kt b/composeApp/src/androidMain/kotlin/com/example/casera/NotificationActionReceiver.kt index 0e2aef3..bf20abf 100644 --- a/composeApp/src/androidMain/kotlin/com/example/casera/NotificationActionReceiver.kt +++ b/composeApp/src/androidMain/kotlin/com/example/casera/NotificationActionReceiver.kt @@ -5,7 +5,7 @@ import android.content.Context import android.content.Intent import android.util.Log import androidx.core.app.NotificationManagerCompat -import com.example.casera.cache.SubscriptionCache +import com.example.casera.data.DataManager import com.example.casera.models.TaskCompletionCreateRequest import com.example.casera.network.APILayer import com.example.casera.network.ApiResult @@ -67,7 +67,7 @@ class NotificationActionReceiver : BroadcastReceiver() { } private fun isPremiumUser(): Boolean { - val subscription = SubscriptionCache.currentSubscription.value + val subscription = DataManager.subscription.value // User is premium if limitations are disabled return subscription?.limitationsEnabled == false } diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/network/ApiClient.android.kt b/composeApp/src/androidMain/kotlin/com/example/casera/network/ApiClient.android.kt index afecc27..0a09254 100644 --- a/composeApp/src/androidMain/kotlin/com/example/casera/network/ApiClient.android.kt +++ b/composeApp/src/androidMain/kotlin/com/example/casera/network/ApiClient.android.kt @@ -32,7 +32,9 @@ actual fun createHttpClient(): HttpClient { install(Logging) { logger = Logger.DEFAULT - level = LogLevel.ALL + // Only log full request/response bodies in debug builds to avoid + // leaking auth tokens and PII in production logcat. + level = if (com.example.casera.BuildConfig.DEBUG) LogLevel.ALL else LogLevel.INFO } install(DefaultRequest) { diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/platform/BillingManager.kt b/composeApp/src/androidMain/kotlin/com/example/casera/platform/BillingManager.kt index 5b84a1b..b7db13a 100644 --- a/composeApp/src/androidMain/kotlin/com/example/casera/platform/BillingManager.kt +++ b/composeApp/src/androidMain/kotlin/com/example/casera/platform/BillingManager.kt @@ -4,10 +4,9 @@ import android.app.Activity import android.content.Context import android.util.Log import com.android.billingclient.api.* -import com.example.casera.cache.SubscriptionCache import com.example.casera.network.APILayer import com.example.casera.network.ApiResult -import com.example.casera.utils.SubscriptionHelper +import com.example.casera.utils.SubscriptionProducts import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -35,10 +34,7 @@ class BillingManager private constructor(private val context: Context) { } // Product IDs (must match Google Play Console) - private val productIDs = listOf( - "com.example.casera.pro.monthly", - "com.example.casera.pro.annual" - ) + private val productIDs = SubscriptionProducts.all private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) @@ -237,10 +233,7 @@ class BillingManager private constructor(private val context: Context) { // Update local state _purchasedProductIDs.value = _purchasedProductIDs.value + purchase.products.toSet() - // Update subscription tier - SubscriptionHelper.currentTier = "pro" - - // Refresh subscription status from backend + // Refresh subscription status from backend (updates DataManager which derives tier) APILayer.getSubscriptionStatus(forceRefresh = true) Log.d(TAG, "Purchase verified and acknowledged") @@ -341,10 +334,7 @@ class BillingManager private constructor(private val context: Context) { ) } - // Update subscription tier - SubscriptionHelper.currentTier = "pro" - - // Refresh subscription status from backend + // Refresh subscription status from backend (updates DataManager which derives tier) APILayer.getSubscriptionStatus(forceRefresh = true) true @@ -423,14 +413,14 @@ class BillingManager private constructor(private val context: Context) { * Get monthly product */ fun getMonthlyProduct(): ProductDetails? { - return _products.value.find { it.productId == "com.example.casera.pro.monthly" } + return _products.value.find { it.productId == SubscriptionProducts.MONTHLY } } /** * Get annual product */ fun getAnnualProduct(): ProductDetails? { - return _products.value.find { it.productId == "com.example.casera.pro.annual" } + return _products.value.find { it.productId == SubscriptionProducts.ANNUAL } } /** diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/storage/TokenManager.android.kt b/composeApp/src/androidMain/kotlin/com/example/casera/storage/TokenManager.android.kt index 06a003d..2f1b42a 100644 --- a/composeApp/src/androidMain/kotlin/com/example/casera/storage/TokenManager.android.kt +++ b/composeApp/src/androidMain/kotlin/com/example/casera/storage/TokenManager.android.kt @@ -2,15 +2,20 @@ package com.example.casera.storage import android.content.Context import android.content.SharedPreferences +import android.util.Log +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey /** - * Android implementation of TokenManager using SharedPreferences. + * Android implementation of TokenManager using EncryptedSharedPreferences. + * + * Uses AndroidX Security Crypto library with AES256-GCM master key + * to encrypt the auth token at rest. Falls back to regular SharedPreferences + * if EncryptedSharedPreferences initialization fails (e.g., on very old devices + * or when the Keystore is in a broken state). */ actual class TokenManager(private val context: Context) { - private val prefs: SharedPreferences = context.getSharedPreferences( - PREFS_NAME, - Context.MODE_PRIVATE - ) + private val prefs: SharedPreferences = createEncryptedPrefs(context) actual fun saveToken(token: String) { prefs.edit().putString(KEY_TOKEN, token).apply() @@ -25,7 +30,9 @@ actual class TokenManager(private val context: Context) { } companion object { - private const val PREFS_NAME = "mycrib_prefs" + private const val TAG = "TokenManager" + private const val ENCRYPTED_PREFS_NAME = "mycrib_secure_prefs" + private const val FALLBACK_PREFS_NAME = "mycrib_prefs" private const val KEY_TOKEN = "auth_token" @Volatile @@ -36,5 +43,34 @@ actual class TokenManager(private val context: Context) { instance ?: TokenManager(context.applicationContext).also { instance = it } } } + + /** + * Creates EncryptedSharedPreferences backed by an AES256-GCM master key. + * If initialization fails (broken Keystore, unsupported device, etc.), + * falls back to plain SharedPreferences with a warning log. + */ + private fun createEncryptedPrefs(context: Context): SharedPreferences { + return try { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + EncryptedSharedPreferences.create( + context, + ENCRYPTED_PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } catch (e: Exception) { + Log.w( + TAG, + "Failed to create EncryptedSharedPreferences, falling back to plain SharedPreferences. " + + "Auth tokens will NOT be encrypted at rest on this device.", + e + ) + context.getSharedPreferences(FALLBACK_PREFS_NAME, Context.MODE_PRIVATE) + } + } } } diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/ui/subscription/UpgradeFeatureScreenAndroid.kt b/composeApp/src/androidMain/kotlin/com/example/casera/ui/subscription/UpgradeFeatureScreenAndroid.kt index 0a78985..2a9df08 100644 --- a/composeApp/src/androidMain/kotlin/com/example/casera/ui/subscription/UpgradeFeatureScreenAndroid.kt +++ b/composeApp/src/androidMain/kotlin/com/example/casera/ui/subscription/UpgradeFeatureScreenAndroid.kt @@ -17,7 +17,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.android.billingclient.api.ProductDetails -import com.example.casera.cache.SubscriptionCache +import com.example.casera.data.DataManager import com.example.casera.platform.BillingManager import com.example.casera.ui.theme.AppSpacing import kotlinx.coroutines.launch @@ -50,7 +50,7 @@ fun UpgradeFeatureScreenAndroid( // Look up trigger data from cache val triggerData by remember { derivedStateOf { - SubscriptionCache.upgradeTriggers.value[triggerKey] + DataManager.upgradeTriggers.value[triggerKey] } } // Fallback values if trigger not found diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/cache/SubscriptionCache.kt b/composeApp/src/commonMain/kotlin/com/example/casera/cache/SubscriptionCache.kt index 2f4cf8e..9cdec9c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/cache/SubscriptionCache.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/cache/SubscriptionCache.kt @@ -1,37 +1,66 @@ package com.example.casera.cache -import androidx.compose.runtime.mutableStateOf +import com.example.casera.data.DataManager import com.example.casera.models.FeatureBenefit import com.example.casera.models.Promotion import com.example.casera.models.SubscriptionStatus import com.example.casera.models.UpgradeTriggerData +/** + * Thin facade over DataManager for subscription data. + * + * All state is delegated to DataManager (single source of truth). + * This object exists for backwards compatibility with callers that + * read subscription state (e.g. iOS SubscriptionCacheWrapper polling via Kotlin interop). + * + * For Compose UI code, prefer using DataManager StateFlows directly with collectAsState(). + */ object SubscriptionCache { - val currentSubscription = mutableStateOf(null) - val upgradeTriggers = mutableStateOf>(emptyMap()) - val featureBenefits = mutableStateOf>(emptyList()) - val promotions = mutableStateOf>(emptyList()) + /** + * Current subscription status, delegated to DataManager. + * For Compose callers, prefer: `val subscription by DataManager.subscription.collectAsState()` + */ + val currentSubscription: SubscriptionCacheAccessor + get() = SubscriptionCacheAccessor { DataManager.subscription.value } + + val upgradeTriggers: SubscriptionCacheAccessor> + get() = SubscriptionCacheAccessor { DataManager.upgradeTriggers.value } + + val featureBenefits: SubscriptionCacheAccessor> + get() = SubscriptionCacheAccessor { DataManager.featureBenefits.value } + + val promotions: SubscriptionCacheAccessor> + get() = SubscriptionCacheAccessor { DataManager.promotions.value } fun updateSubscriptionStatus(subscription: SubscriptionStatus) { - currentSubscription.value = subscription + DataManager.setSubscription(subscription) } fun updateUpgradeTriggers(triggers: Map) { - upgradeTriggers.value = triggers + DataManager.setUpgradeTriggers(triggers) } fun updateFeatureBenefits(benefits: List) { - featureBenefits.value = benefits + DataManager.setFeatureBenefits(benefits) } fun updatePromotions(promos: List) { - promotions.value = promos + DataManager.setPromotions(promos) } fun clear() { - currentSubscription.value = null - upgradeTriggers.value = emptyMap() - featureBenefits.value = emptyList() - promotions.value = emptyList() + DataManager.setSubscription(null) + DataManager.setUpgradeTriggers(emptyMap()) + DataManager.setFeatureBenefits(emptyList()) + DataManager.setPromotions(emptyList()) } } + +/** + * Simple accessor that provides .value to read from DataManager. + * This preserves the `SubscriptionCache.currentSubscription.value` call pattern + * used by existing callers (Kotlin code and iOS interop polling). + */ +class SubscriptionCacheAccessor(private val getter: () -> T) { + val value: T get() = getter() +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt b/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt index 755cb5b..454926c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt @@ -18,8 +18,16 @@ import kotlin.time.ExperimentalTime * 1. All data is cached here - no other caches exist * 2. Every API response updates DataManager immediately * 3. All screens observe DataManager StateFlows directly - * 4. All data is persisted to disk for offline access - * 5. Includes auth token and theme preferences + * 4. Auth token and theme preferences are persisted via platform-specific managers + * + * Disk Persistence (survives app restart): + * - Current user, auth token (via TokenManager), theme (via ThemeStorageManager) + * - Lookup/reference data: categories, priorities, frequencies, specialties, residence types, task templates + * - ETag values for conditional fetching, onboarding completion flag + * + * In-memory only (re-fetched on app launch via prefetchAllData): + * - Residences, tasks, documents, contractors + * - Subscription status, summaries, upgrade triggers, feature benefits, promotions * * Data Flow: * User Action → API Call → Server Response → DataManager Updated → All Screens React @@ -29,12 +37,24 @@ object DataManager { // ==================== CACHE CONFIGURATION ==================== /** - * Cache timeout in milliseconds. + * Default cache timeout in milliseconds. * Data older than this will be refreshed from the API. * Default: 1 hour (3600000ms) */ const val CACHE_TIMEOUT_MS: Long = 60 * 60 * 1000L // 1 hour + /** + * Per-entity cache TTLs for data with different freshness requirements. + */ + object CacheTTL { + /** Lookups (categories, priorities, frequencies) — rarely change */ + const val LOOKUPS_MS: Long = 24 * 60 * 60 * 1000L // 24 hours + /** Entity data (residences, tasks, documents, contractors) */ + const val ENTITIES_MS: Long = 60 * 60 * 1000L // 1 hour + /** Subscription status — needs frequent refresh */ + const val SUBSCRIPTION_MS: Long = 30 * 60 * 1000L // 30 minutes + } + // Cache timestamps for each data type (epoch milliseconds) var residencesCacheTime: Long = 0L private set @@ -52,13 +72,16 @@ object DataManager { private set /** - * Check if cache for a given timestamp is still valid (not expired) + * Check if cache for a given timestamp is still valid (not expired). + * @param cacheTime Epoch milliseconds when the cache was last set. + * @param ttlMs Optional TTL override. Defaults to CACHE_TIMEOUT_MS (1 hour). + * Use CacheTTL.LOOKUPS_MS for lookups, CacheTTL.SUBSCRIPTION_MS for subscription. */ @OptIn(ExperimentalTime::class) - fun isCacheValid(cacheTime: Long): Boolean { + fun isCacheValid(cacheTime: Long, ttlMs: Long = CACHE_TIMEOUT_MS): Boolean { if (cacheTime == 0L) return false val now = Clock.System.now().toEpochMilliseconds() - return (now - cacheTime) < CACHE_TIMEOUT_MS + return (now - cacheTime) < ttlMs } /** @@ -360,8 +383,17 @@ object DataManager { persistToDisk() } + /** + * Add a new residence to the cache. + * Caches affected: _residences, _myResidences + * Invalidation trigger: createResidence API success + */ fun addResidence(residence: Residence) { _residences.value = _residences.value + residence + // Also append to myResidences if it has been loaded + _myResidences.value?.let { myRes -> + _myResidences.value = myRes.copy(residences = myRes.residences + residence) + } updateLastSyncTime() persistToDisk() } @@ -518,15 +550,34 @@ object DataManager { persistToDisk() } + /** + * Add a new document to the cache. + * Caches affected: _documents, _documentsByResidence[residenceId] + * Invalidation trigger: createDocument API success + */ fun addDocument(document: Document) { _documents.value = _documents.value + document + // Also add to residence-specific cache if it exists + val residenceId = document.residenceId ?: document.residence + _documentsByResidence.value[residenceId]?.let { existing -> + _documentsByResidence.value = _documentsByResidence.value + (residenceId to (existing + document)) + } persistToDisk() } + /** + * Update an existing document in the cache. + * Caches affected: _documents, _documentsByResidence (all maps containing the document) + * Invalidation trigger: updateDocument / uploadDocumentImage / deleteDocumentImage API success + */ fun updateDocument(document: Document) { _documents.value = _documents.value.map { if (it.id == document.id) document else it } + // Also update in residence-specific caches + _documentsByResidence.value = _documentsByResidence.value.mapValues { (_, docs) -> + docs.map { if (it.id == document.id) document else it } + } persistToDisk() } @@ -582,7 +633,7 @@ object DataManager { // ==================== SUBSCRIPTION UPDATE METHODS ==================== - fun setSubscription(subscription: SubscriptionStatus) { + fun setSubscription(subscription: SubscriptionStatus?) { _subscription.value = subscription persistToDisk() } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/models/Document.kt b/composeApp/src/commonMain/kotlin/com/example/casera/models/Document.kt index 161f21b..b3c8931 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/models/Document.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/models/Document.kt @@ -10,6 +10,14 @@ data class WarrantyStatus( @SerialName("is_expiring_soon") val isExpiringSoon: Boolean ) +@Serializable +data class DocumentUser( + val id: Int, + val username: String = "", + @SerialName("first_name") val firstName: String = "", + @SerialName("last_name") val lastName: String = "" +) + @Serializable data class DocumentImage( val id: Int? = null, @@ -19,105 +27,95 @@ data class DocumentImage( @SerialName("uploaded_at") val uploadedAt: String? = null ) +@Serializable +data class DocumentActionResponse( + val message: String, + val document: Document +) + @Serializable data class Document( val id: Int? = null, val title: String, @SerialName("document_type") val documentType: String, - val category: String? = null, val description: String? = null, @SerialName("file_url") val fileUrl: String? = null, // URL to the file @SerialName("media_url") val mediaUrl: String? = null, // Authenticated endpoint: /api/media/document/{id} + @SerialName("file_name") val fileName: String? = null, @SerialName("file_size") val fileSize: Int? = null, - @SerialName("file_type") val fileType: String? = null, - // Warranty-specific fields (only used when documentType == "warranty") - @SerialName("item_name") val itemName: String? = null, + @SerialName("mime_type") val mimeType: String? = null, + // Warranty-specific fields @SerialName("model_number") val modelNumber: String? = null, @SerialName("serial_number") val serialNumber: String? = null, + val vendor: String? = null, + @SerialName("purchase_date") val purchaseDate: String? = null, + @SerialName("purchase_price") val purchasePrice: String? = null, + @SerialName("expiry_date") val expiryDate: String? = null, + // Relationships + @SerialName("residence_id") val residenceId: Int? = null, + val residence: Int, + @SerialName("created_by_id") val createdById: Int? = null, + @SerialName("created_by") val createdBy: DocumentUser? = null, + @SerialName("task_id") val taskId: Int? = null, + // Images + val images: List = emptyList(), + // Status + @SerialName("is_active") val isActive: Boolean = true, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("updated_at") val updatedAt: String? = null, + // Client-side convenience fields (not from backend, kept for UI compatibility) + // These fields are populated client-side or kept optional so deserialization doesn't fail + val category: String? = null, + val tags: String? = null, + val notes: String? = null, + @SerialName("item_name") val itemName: String? = null, val provider: String? = null, @SerialName("provider_contact") val providerContact: String? = null, @SerialName("claim_phone") val claimPhone: String? = null, @SerialName("claim_email") val claimEmail: String? = null, @SerialName("claim_website") val claimWebsite: String? = null, - @SerialName("purchase_date") val purchaseDate: String? = null, @SerialName("start_date") val startDate: String? = null, - @SerialName("end_date") val endDate: String? = null, - // Relationships - val residence: Int, - @SerialName("residence_address") val residenceAddress: String? = null, - val contractor: Int? = null, - @SerialName("contractor_name") val contractorName: String? = null, - @SerialName("contractor_phone") val contractorPhone: String? = null, - @SerialName("uploaded_by") val uploadedBy: Int? = null, - @SerialName("uploaded_by_username") val uploadedByUsername: String? = null, - // Images - val images: List = emptyList(), - // Metadata - val tags: String? = null, - val notes: String? = null, - @SerialName("is_active") val isActive: Boolean = true, @SerialName("days_until_expiration") val daysUntilExpiration: Int? = null, - @SerialName("warranty_status") val warrantyStatus: WarrantyStatus? = null, - @SerialName("created_at") val createdAt: String? = null, - @SerialName("updated_at") val updatedAt: String? = null -) + @SerialName("warranty_status") val warrantyStatus: WarrantyStatus? = null +) { + // Backward-compatible alias: endDate maps to expiryDate + val endDate: String? get() = expiryDate +} @Serializable data class DocumentCreateRequest( val title: String, @SerialName("document_type") val documentType: String, - val category: String? = null, val description: String? = null, // Note: file will be handled separately as multipart/form-data // Warranty-specific fields - @SerialName("item_name") val itemName: String? = null, @SerialName("model_number") val modelNumber: String? = null, @SerialName("serial_number") val serialNumber: String? = null, - val provider: String? = null, - @SerialName("provider_contact") val providerContact: String? = null, - @SerialName("claim_phone") val claimPhone: String? = null, - @SerialName("claim_email") val claimEmail: String? = null, - @SerialName("claim_website") val claimWebsite: String? = null, + val vendor: String? = null, @SerialName("purchase_date") val purchaseDate: String? = null, - @SerialName("start_date") val startDate: String? = null, - @SerialName("end_date") val endDate: String? = null, + @SerialName("purchase_price") val purchasePrice: String? = null, + @SerialName("expiry_date") val expiryDate: String? = null, // Relationships @SerialName("residence_id") val residenceId: Int, - @SerialName("contractor_id") val contractorId: Int? = null, @SerialName("task_id") val taskId: Int? = null, // Images - @SerialName("image_urls") val imageUrls: List? = null, - // Metadata - val tags: String? = null, - val notes: String? = null, - @SerialName("is_active") val isActive: Boolean = true + @SerialName("image_urls") val imageUrls: List? = null ) @Serializable data class DocumentUpdateRequest( val title: String? = null, @SerialName("document_type") val documentType: String? = null, - val category: String? = null, val description: String? = null, - // Note: file will be handled separately as multipart/form-data // Warranty-specific fields - @SerialName("item_name") val itemName: String? = null, @SerialName("model_number") val modelNumber: String? = null, @SerialName("serial_number") val serialNumber: String? = null, - val provider: String? = null, - @SerialName("provider_contact") val providerContact: String? = null, - @SerialName("claim_phone") val claimPhone: String? = null, - @SerialName("claim_email") val claimEmail: String? = null, - @SerialName("claim_website") val claimWebsite: String? = null, + val vendor: String? = null, @SerialName("purchase_date") val purchaseDate: String? = null, - @SerialName("start_date") val startDate: String? = null, - @SerialName("end_date") val endDate: String? = null, + @SerialName("purchase_price") val purchasePrice: String? = null, + @SerialName("expiry_date") val expiryDate: String? = null, // Relationships - @SerialName("contractor_id") val contractorId: Int? = null, - // Metadata - val tags: String? = null, - val notes: String? = null, - @SerialName("is_active") val isActive: Boolean? = null + @SerialName("task_id") val taskId: Int? = null ) // Removed: DocumentListResponse - no longer using paginated responses diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/models/Notification.kt b/composeApp/src/commonMain/kotlin/com/example/casera/models/Notification.kt index ef4e0f5..7cf4373 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/models/Notification.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/models/Notification.kt @@ -107,6 +107,12 @@ data class Notification( val taskId: Int? = null ) +@Serializable +data class NotificationListResponse( + val count: Int, + val results: List +) + @Serializable data class UnreadCountResponse( @SerialName("unread_count") diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/models/Subscription.kt b/composeApp/src/commonMain/kotlin/com/example/casera/models/Subscription.kt index 3827b7d..e3b44a0 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/models/Subscription.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/models/Subscription.kt @@ -40,8 +40,8 @@ data class UpgradeTriggerData( @Serializable data class FeatureBenefit( @SerialName("feature_name") val featureName: String, - @SerialName("free_tier") val freeTier: String, - @SerialName("pro_tier") val proTier: String + @SerialName("free_tier_text") val freeTierText: String, + @SerialName("pro_tier_text") val proTierText: String ) @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt index 420c57c..114b8dd 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt @@ -3,6 +3,7 @@ package com.example.casera.network import com.example.casera.data.DataManager import com.example.casera.models.* import com.example.casera.network.* +import kotlinx.coroutines.sync.Mutex /** * Unified API Layer that manages all network calls and DataManager updates. @@ -30,16 +31,16 @@ object APILayer { // ==================== Initialization Guards ==================== /** - * Guard to prevent concurrent initialization calls. - * This prevents multiple initializeLookups() calls from running simultaneously. + * Thread-safe guard to prevent concurrent initialization calls. + * Uses Mutex instead of boolean flag for coroutine safety. */ - private var isInitializingLookups = false + private val lookupsInitMutex = Mutex() /** - * Guard to prevent concurrent prefetch calls. - * This prevents multiple prefetchAllData() calls from running simultaneously. + * Thread-safe guard to prevent concurrent prefetch calls. + * Uses Mutex instead of boolean flag for coroutine safety. */ - private var isPrefetchingData = false + private val prefetchMutex = Mutex() // ==================== Authentication Helper ==================== @@ -83,8 +84,8 @@ object APILayer { * - /subscription/status/ requires auth and is only called if user is authenticated */ suspend fun initializeLookups(): ApiResult { - // Guard: prevent concurrent initialization - if (isInitializingLookups) { + // Guard: prevent concurrent initialization (thread-safe via Mutex) + if (!lookupsInitMutex.tryLock()) { println("📋 [APILayer] Lookups initialization already in progress, skipping...") return ApiResult.Success(Unit) } @@ -94,11 +95,11 @@ object APILayer { // If lookups are already initialized and we have an ETag, do conditional fetch if (DataManager.lookupsInitialized.value && currentETag != null) { + lookupsInitMutex.unlock() println("📋 [APILayer] Lookups initialized, checking for updates with ETag...") return refreshLookupsIfChanged() } - isInitializingLookups = true try { // Use seeded data endpoint with ETag support (PUBLIC - no auth required) // Only send ETag if lookups are already in memory - otherwise we need full data @@ -174,7 +175,7 @@ object APILayer { } catch (e: Exception) { return ApiResult.Error("Failed to initialize lookups: ${e.message}") } finally { - isInitializingLookups = false + lookupsInitMutex.unlock() } } @@ -356,8 +357,10 @@ object APILayer { // Check DataManager first - return cached if valid and not forcing refresh // Cache is valid even if empty (user has no residences) if (!forceRefresh && DataManager.isCacheValid(DataManager.residencesCacheTime)) { + println("[APILayer] CACHE HIT: residences") return ApiResult.Success(DataManager.residences.value) } + println("[APILayer] CACHE MISS: residences (forceRefresh=$forceRefresh)") // Fetch from API val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) @@ -376,9 +379,11 @@ object APILayer { if (!forceRefresh && DataManager.isCacheValid(DataManager.myResidencesCacheTime)) { val cached = DataManager.myResidences.value if (cached != null) { + println("[APILayer] CACHE HIT: myResidences") return ApiResult.Success(cached) } } + println("[APILayer] CACHE MISS: myResidences (forceRefresh=$forceRefresh)") // Fetch from API val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) @@ -547,9 +552,11 @@ object APILayer { if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksCacheTime)) { val cached = DataManager.allTasks.value if (cached != null) { + println("[APILayer] CACHE HIT: tasks") return ApiResult.Success(cached) } } + println("[APILayer] CACHE MISS: tasks (forceRefresh=$forceRefresh)") // Fetch from API val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) @@ -799,8 +806,10 @@ object APILayer { // Check DataManager first if no filters - return cached if valid and not forcing refresh // Cache is valid even if empty (user has no documents) if (!forceRefresh && !hasFilters && DataManager.isCacheValid(DataManager.documentsCacheTime)) { + println("[APILayer] CACHE HIT: documents") return ApiResult.Success(DataManager.documents.value) } + println("[APILayer] CACHE MISS: documents (forceRefresh=$forceRefresh, hasFilters=$hasFilters)") // Fetch from API val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) @@ -937,15 +946,24 @@ object APILayer { documentId: Int, imageBytes: ByteArray, fileName: String, - mimeType: String - ): ApiResult { + mimeType: String, + caption: String? = null + ): ApiResult { val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) - return documentApi.uploadDocumentImage(token, documentId, imageBytes, fileName, mimeType) + val result = documentApi.uploadDocumentImage(token, documentId, imageBytes, fileName, mimeType, caption) + if (result is ApiResult.Success) { + DataManager.updateDocument(result.data) + } + return result } - suspend fun deleteDocumentImage(imageId: Int): ApiResult { + suspend fun deleteDocumentImage(documentId: Int, imageId: Int): ApiResult { val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) - return documentApi.deleteDocumentImage(token, imageId) + val result = documentApi.deleteDocumentImage(token, documentId, imageId) + if (result is ApiResult.Success) { + DataManager.updateDocument(result.data) + } + return result } suspend fun downloadDocument(url: String): ApiResult { @@ -1295,9 +1313,9 @@ object APILayer { return notificationApi.registerDevice(token, request) } - suspend fun unregisterDevice(registrationId: String): ApiResult { + suspend fun unregisterDevice(deviceId: Int): ApiResult { val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) - return notificationApi.unregisterDevice(token, registrationId) + return notificationApi.unregisterDevice(token, deviceId) } suspend fun getNotificationPreferences(): ApiResult { @@ -1315,12 +1333,12 @@ object APILayer { return notificationApi.getNotificationHistory(token) } - suspend fun markNotificationAsRead(notificationId: Int): ApiResult { + suspend fun markNotificationAsRead(notificationId: Int): ApiResult { val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return notificationApi.markNotificationAsRead(token, notificationId) } - suspend fun markAllNotificationsAsRead(): ApiResult> { + suspend fun markAllNotificationsAsRead(): ApiResult { val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return notificationApi.markAllNotificationsAsRead(token) } @@ -1333,10 +1351,20 @@ object APILayer { // ==================== Subscription Operations ==================== /** - * Get subscription status from backend + * Get subscription status from backend. + * Returns cached data from DataManager if available and forceRefresh is false. */ suspend fun getSubscriptionStatus(forceRefresh: Boolean = false): ApiResult { val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) + + // Return cached subscription if available and not forcing refresh + if (!forceRefresh) { + val cached = DataManager.subscription.value + if (cached != null) { + return ApiResult.Success(cached) + } + } + val result = subscriptionApi.getSubscriptionStatus(token) // Update DataManager on success @@ -1370,25 +1398,24 @@ object APILayer { * Uses guards to prevent concurrent calls and skips if data is already cached. */ private suspend fun prefetchAllData() { - // Guard: prevent concurrent prefetch calls - if (isPrefetchingData) { + // Guard: prevent concurrent prefetch calls (thread-safe via Mutex) + if (!prefetchMutex.tryLock()) { println("📋 [APILayer] Data prefetch already in progress, skipping...") return } - // Skip if data is already cached (within cache validity period) - val residencesCached = DataManager.isCacheValid(DataManager.myResidencesCacheTime) && - DataManager.myResidences.value != null - val tasksCached = DataManager.isCacheValid(DataManager.tasksCacheTime) && - DataManager.allTasks.value != null - - if (residencesCached && tasksCached) { - println("📋 [APILayer] Data already cached, skipping prefetch...") - return - } - - isPrefetchingData = true try { + // Skip if data is already cached (within cache validity period) + val residencesCached = DataManager.isCacheValid(DataManager.myResidencesCacheTime) && + DataManager.myResidences.value != null + val tasksCached = DataManager.isCacheValid(DataManager.tasksCacheTime) && + DataManager.allTasks.value != null + + if (residencesCached && tasksCached) { + println("📋 [APILayer] Data already cached, skipping prefetch...") + return + } + // Fetch key data - these all update DataManager if (!residencesCached) { getMyResidences(forceRefresh = true) @@ -1399,7 +1426,7 @@ object APILayer { } catch (e: Exception) { println("Error prefetching data: ${e.message}") } finally { - isPrefetchingData = false + prefetchMutex.unlock() } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt index cd5e298..f4bc03e 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt @@ -51,10 +51,23 @@ object ApiConfig { * This is the Web application client ID from Google Cloud Console. * It should match the GOOGLE_CLIENT_ID configured in the backend. * + * Set via environment: actual client ID must be configured per environment. * To get this value: * 1. Go to Google Cloud Console -> APIs & Services -> Credentials * 2. Create or use an existing OAuth 2.0 Client ID of type "Web application" * 3. Copy the Client ID (format: xxx.apps.googleusercontent.com) + * 4. Replace the empty string below with your client ID + * + * WARNING: An empty string means Google Sign-In is not configured. + * The app should check [isGoogleSignInConfigured] before offering Google Sign-In. */ - const val GOOGLE_WEB_CLIENT_ID = "YOUR_WEB_CLIENT_ID.apps.googleusercontent.com" + const val GOOGLE_WEB_CLIENT_ID = "" + + /** + * Whether Google Sign-In has been configured with a real client ID. + * UI should check this before showing Google Sign-In buttons. + */ + val isGoogleSignInConfigured: Boolean + get() = GOOGLE_WEB_CLIENT_ID.isNotEmpty() + && !GOOGLE_WEB_CLIENT_ID.contains("YOUR_") } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/AuthApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/AuthApi.kt index 6a17e5d..f8af231 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/AuthApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/AuthApi.kt @@ -81,7 +81,7 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult { return try { - val response = client.post("$baseUrl/auth/verify/") { + val response = client.post("$baseUrl/auth/verify-email/") { header("Authorization", "Token $token") contentType(ContentType.Application.Json) setBody(request) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/DocumentApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/DocumentApi.kt index ad32f6b..75496d5 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/DocumentApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/DocumentApi.kt @@ -104,34 +104,23 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { append("document_type", documentType) append("residence_id", residenceId.toString()) description?.let { append("description", it) } - category?.let { append("category", it) } - tags?.let { append("tags", it) } - notes?.let { append("notes", it) } - contractorId?.let { append("contractor_id", it.toString()) } - append("is_active", isActive.toString()) - // Warranty fields - itemName?.let { append("item_name", it) } + // Backend-supported fields modelNumber?.let { append("model_number", it) } serialNumber?.let { append("serial_number", it) } - provider?.let { append("provider", it) } - providerContact?.let { append("provider_contact", it) } - claimPhone?.let { append("claim_phone", it) } - claimEmail?.let { append("claim_email", it) } - claimWebsite?.let { append("claim_website", it) } + // Map provider to vendor for backend compatibility + provider?.let { append("vendor", it) } purchaseDate?.let { append("purchase_date", it) } - startDate?.let { append("start_date", it) } - endDate?.let { append("end_date", it) } + // Map endDate to expiry_date for backend compatibility + endDate?.let { append("expiry_date", it) } - // Handle multiple files if provided + // Backend accepts "file" field for single file upload if (fileBytesList != null && fileBytesList.isNotEmpty() && fileNamesList != null && mimeTypesList != null) { - fileBytesList.forEachIndexed { index, bytes -> - append("files", bytes, Headers.build { - append(HttpHeaders.ContentType, mimeTypesList.getOrElse(index) { "application/octet-stream" }) - append(HttpHeaders.ContentDisposition, "filename=\"${fileNamesList.getOrElse(index) { "file_$index" }}\"") - }) - } + // Send first file as "file" (backend only accepts single file) + append("file", fileBytesList[0], Headers.build { + append(HttpHeaders.ContentType, mimeTypesList.getOrElse(0) { "application/octet-stream" }) + append(HttpHeaders.ContentDisposition, "filename=\"${fileNamesList.getOrElse(0) { "file_0" }}\"") + }) } else if (fileBytes != null && fileName != null && mimeType != null) { - // Single file (backwards compatibility) append("file", fileBytes, Headers.build { append(HttpHeaders.ContentType, mimeType) append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"") @@ -146,24 +135,13 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { val request = DocumentCreateRequest( title = title, documentType = documentType, - category = category, description = description, - itemName = itemName, modelNumber = modelNumber, serialNumber = serialNumber, - provider = provider, - providerContact = providerContact, - claimPhone = claimPhone, - claimEmail = claimEmail, - claimWebsite = claimWebsite, + vendor = provider, // Map provider to vendor purchaseDate = purchaseDate, - startDate = startDate, - endDate = endDate, - residenceId = residenceId, - contractorId = contractorId, - tags = tags, - notes = notes, - isActive = isActive + expiryDate = endDate, // Map endDate to expiryDate + residenceId = residenceId ) client.post("$baseUrl/documents/") { header("Authorization", "Token $token") @@ -209,75 +187,24 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { claimWebsite: String? = null, purchaseDate: String? = null, startDate: String? = null, - endDate: String? = null, - // File - fileBytes: ByteArray? = null, - fileName: String? = null, - mimeType: String? = null + endDate: String? = null ): ApiResult { return try { - // If file is being updated, use multipart/form-data - val response = if (fileBytes != null && fileName != null && mimeType != null) { - client.submitFormWithBinaryData( - url = "$baseUrl/documents/$id/", - formData = formData { - title?.let { append("title", it) } - documentType?.let { append("document_type", it) } - description?.let { append("description", it) } - category?.let { append("category", it) } - tags?.let { append("tags", it) } - notes?.let { append("notes", it) } - contractorId?.let { append("contractor_id", it.toString()) } - isActive?.let { append("is_active", it.toString()) } - // Warranty fields - itemName?.let { append("item_name", it) } - modelNumber?.let { append("model_number", it) } - serialNumber?.let { append("serial_number", it) } - provider?.let { append("provider", it) } - providerContact?.let { append("provider_contact", it) } - claimPhone?.let { append("claim_phone", it) } - claimEmail?.let { append("claim_email", it) } - claimWebsite?.let { append("claim_website", it) } - purchaseDate?.let { append("purchase_date", it) } - startDate?.let { append("start_date", it) } - endDate?.let { append("end_date", it) } - append("file", fileBytes, Headers.build { - append(HttpHeaders.ContentType, mimeType) - append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"") - }) - } - ) { - header("Authorization", "Token $token") - method = HttpMethod.Put - } - } else { - // Otherwise use JSON for metadata-only updates - val request = DocumentUpdateRequest( - title = title, - documentType = documentType, - category = category, - description = description, - itemName = itemName, - modelNumber = modelNumber, - serialNumber = serialNumber, - provider = provider, - providerContact = providerContact, - claimPhone = claimPhone, - claimEmail = claimEmail, - claimWebsite = claimWebsite, - purchaseDate = purchaseDate, - startDate = startDate, - endDate = endDate, - contractorId = contractorId, - tags = tags, - notes = notes, - isActive = isActive - ) - client.patch("$baseUrl/documents/$id/") { - header("Authorization", "Token $token") - contentType(ContentType.Application.Json) - setBody(request) - } + // Backend update handler uses JSON via c.Bind (not multipart) + val request = DocumentUpdateRequest( + title = title, + documentType = documentType, + description = description, + modelNumber = modelNumber, + serialNumber = serialNumber, + vendor = provider, // Map provider to vendor + purchaseDate = purchaseDate, + expiryDate = endDate // Map endDate to expiryDate + ) + val response = client.patch("$baseUrl/documents/$id/") { + header("Authorization", "Token $token") + contentType(ContentType.Application.Json) + setBody(request) } if (response.status.isSuccess()) { @@ -334,7 +261,9 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { } if (response.status.isSuccess()) { - ApiResult.Success(response.body()) + // Backend returns wrapped response: {message: string, document: DocumentResponse} + val wrapper: DocumentActionResponse = response.body() + ApiResult.Success(wrapper.document) } else { ApiResult.Error("Failed to activate document", response.status.value) } @@ -350,7 +279,9 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { } if (response.status.isSuccess()) { - ApiResult.Success(response.body()) + // Backend returns wrapped response: {message: string, document: DocumentResponse} + val wrapper: DocumentActionResponse = response.body() + ApiResult.Success(wrapper.document) } else { ApiResult.Error("Failed to deactivate document", response.status.value) } @@ -359,22 +290,6 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { } } - suspend fun deleteDocumentImage(token: String, imageId: Int): ApiResult { - return try { - val response = client.delete("$baseUrl/document-images/$imageId/") { - header("Authorization", "Token $token") - } - - if (response.status.isSuccess()) { - ApiResult.Success(Unit) - } else { - ApiResult.Error("Failed to delete image", response.status.value) - } - } catch (e: Exception) { - ApiResult.Error(e.message ?: "Unknown error occurred") - } - } - suspend fun uploadDocumentImage( token: String, documentId: Int, @@ -382,17 +297,16 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { fileName: String = "image.jpg", mimeType: String = "image/jpeg", caption: String? = null - ): ApiResult { + ): ApiResult { return try { val response = client.submitFormWithBinaryData( - url = "$baseUrl/document-images/", + url = "$baseUrl/documents/$documentId/images/", formData = formData { - append("document", documentId.toString()) - caption?.let { append("caption", it) } append("image", imageBytes, Headers.build { append(HttpHeaders.ContentType, mimeType) append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"") }) + caption?.let { append("caption", it) } } ) { header("Authorization", "Token $token") @@ -404,7 +318,7 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { val errorBody = try { response.body() } catch (e: Exception) { - "Failed to upload image" + "Failed to upload document image" } ApiResult.Error(errorBody, response.status.value) } @@ -412,4 +326,20 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { ApiResult.Error(e.message ?: "Unknown error occurred") } } + + suspend fun deleteDocumentImage(token: String, documentId: Int, imageId: Int): ApiResult { + return try { + val response = client.delete("$baseUrl/documents/$documentId/images/$imageId/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + ApiResult.Error("Failed to delete document image", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/LookupsApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/LookupsApi.kt index 868bd16..5039583 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/LookupsApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/LookupsApi.kt @@ -35,7 +35,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getResidenceTypes(token: String): ApiResult> { return try { - val response = client.get("$baseUrl/residence-types/") { + val response = client.get("$baseUrl/residences/types/") { header("Authorization", "Token $token") } @@ -51,7 +51,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getTaskFrequencies(token: String): ApiResult> { return try { - val response = client.get("$baseUrl/task-frequencies/") { + val response = client.get("$baseUrl/tasks/frequencies/") { header("Authorization", "Token $token") } @@ -67,7 +67,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getTaskPriorities(token: String): ApiResult> { return try { - val response = client.get("$baseUrl/task-priorities/") { + val response = client.get("$baseUrl/tasks/priorities/") { header("Authorization", "Token $token") } @@ -83,7 +83,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getTaskCategories(token: String): ApiResult> { return try { - val response = client.get("$baseUrl/task-categories/") { + val response = client.get("$baseUrl/tasks/categories/") { header("Authorization", "Token $token") } @@ -99,7 +99,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getContractorSpecialties(token: String): ApiResult> { return try { - val response = client.get("$baseUrl/contractor-specialties/") { + val response = client.get("$baseUrl/contractors/specialties/") { header("Authorization", "Token $token") } @@ -113,22 +113,6 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { } } - suspend fun getAllTasks(token: String): ApiResult> { - return try { - val response = client.get("$baseUrl/tasks/") { - header("Authorization", "Token $token") - } - - if (response.status.isSuccess()) { - ApiResult.Success(response.body()) - } else { - ApiResult.Error("Failed to fetch tasks", response.status.value) - } - } catch (e: Exception) { - ApiResult.Error(e.message ?: "Unknown error occurred") - } - } - suspend fun getStaticData(token: String? = null): ApiResult { return try { val response = client.get("$baseUrl/static_data/") { diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/NotificationApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/NotificationApi.kt index 0ec1ded..f2dc862 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/NotificationApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/NotificationApi.kt @@ -38,18 +38,13 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) { } } - /** - * Unregister a device - */ suspend fun unregisterDevice( token: String, - registrationId: String + deviceId: Int ): ApiResult { return try { - val response = client.post("$baseUrl/notifications/devices/unregister/") { + val response = client.delete("$baseUrl/notifications/devices/$deviceId/") { header("Authorization", "Token $token") - contentType(ContentType.Application.Json) - setBody(mapOf("registration_id" to registrationId)) } if (response.status.isSuccess()) { @@ -105,17 +100,15 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) { } } - /** - * Get notification history - */ suspend fun getNotificationHistory(token: String): ApiResult> { return try { - val response = client.get("$baseUrl/notifications/history/") { + val response = client.get("$baseUrl/notifications/") { header("Authorization", "Token $token") } if (response.status.isSuccess()) { - ApiResult.Success(response.body()) + val listResponse: NotificationListResponse = response.body() + ApiResult.Success(listResponse.results) } else { ApiResult.Error("Failed to get notification history", response.status.value) } @@ -124,15 +117,12 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) { } } - /** - * Mark a notification as read - */ suspend fun markNotificationAsRead( token: String, notificationId: Int - ): ApiResult { + ): ApiResult { return try { - val response = client.post("$baseUrl/notifications/history/$notificationId/mark_as_read/") { + val response = client.post("$baseUrl/notifications/$notificationId/read/") { header("Authorization", "Token $token") } @@ -146,12 +136,9 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) { } } - /** - * Mark all notifications as read - */ - suspend fun markAllNotificationsAsRead(token: String): ApiResult> { + suspend fun markAllNotificationsAsRead(token: String): ApiResult { return try { - val response = client.post("$baseUrl/notifications/history/mark_all_as_read/") { + val response = client.post("$baseUrl/notifications/mark-all-read/") { header("Authorization", "Token $token") } @@ -165,12 +152,9 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) { } } - /** - * Get unread notification count - */ suspend fun getUnreadCount(token: String): ApiResult { return try { - val response = client.get("$baseUrl/notifications/history/unread_count/") { + val response = client.get("$baseUrl/notifications/unread-count/") { header("Authorization", "Token $token") } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/SubscriptionApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/SubscriptionApi.kt index 19cdf27..d57ebea 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/SubscriptionApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/SubscriptionApi.kt @@ -44,7 +44,7 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) { suspend fun getFeatureBenefits(): ApiResult> { return try { - val response = client.get("$baseUrl/subscription/feature-benefits/") + val response = client.get("$baseUrl/subscription/features/") if (response.status.isSuccess()) { ApiResult.Success(response.body()) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/JoinResidenceDialog.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/JoinResidenceDialog.kt index 58fd8af..a6895ea 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/JoinResidenceDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/JoinResidenceDialog.kt @@ -10,8 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.example.casera.network.ApiResult -import com.example.casera.network.ResidenceApi -import com.example.casera.storage.TokenStorage +import com.example.casera.network.APILayer import kotlinx.coroutines.launch @Composable @@ -23,7 +22,6 @@ fun JoinResidenceDialog( var isJoining by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } - val residenceApi = remember { ResidenceApi() } val scope = rememberCoroutineScope() AlertDialog( @@ -92,25 +90,19 @@ fun JoinResidenceDialog( scope.launch { isJoining = true error = null - val token = TokenStorage.getToken() - if (token != null) { - when (val result = residenceApi.joinWithCode(token, shareCode.text)) { - is ApiResult.Success -> { - isJoining = false - onJoined() - onDismiss() - } - is ApiResult.Error -> { - error = result.message - isJoining = false - } - else -> { - isJoining = false - } + when (val result = APILayer.joinWithCode(shareCode.text)) { + is ApiResult.Success -> { + isJoining = false + onJoined() + onDismiss() + } + is ApiResult.Error -> { + error = result.message + isJoining = false + } + else -> { + isJoining = false } - } else { - error = "Not authenticated" - isJoining = false } } } else { diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ManageUsersDialog.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ManageUsersDialog.kt index 267aea9..7720745 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ManageUsersDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ManageUsersDialog.kt @@ -23,8 +23,7 @@ import androidx.compose.ui.unit.sp import com.example.casera.models.ResidenceUser import com.example.casera.models.ResidenceShareCode import com.example.casera.network.ApiResult -import com.example.casera.network.ResidenceApi -import com.example.casera.storage.TokenStorage +import com.example.casera.network.APILayer import kotlinx.coroutines.launch @Composable @@ -44,7 +43,6 @@ fun ManageUsersDialog( var error by remember { mutableStateOf(null) } var isGeneratingCode by remember { mutableStateOf(false) } - val residenceApi = remember { ResidenceApi() } val scope = rememberCoroutineScope() val clipboardManager = LocalClipboardManager.current @@ -53,22 +51,19 @@ fun ManageUsersDialog( // Clear share code on open so it's always blank shareCode = null - val token = TokenStorage.getToken() - if (token != null) { - when (val result = residenceApi.getResidenceUsers(token, residenceId)) { - is ApiResult.Success -> { - users = result.data - isLoading = false - } - is ApiResult.Error -> { - error = result.message - isLoading = false - } - else -> {} + when (val result = APILayer.getResidenceUsers(residenceId)) { + is ApiResult.Success -> { + users = result.data + isLoading = false } - - // Don't auto-load share code - user must generate it explicitly + is ApiResult.Error -> { + error = result.message + isLoading = false + } + else -> {} } + + // Don't auto-load share code - user must generate it explicitly } AlertDialog( @@ -217,17 +212,14 @@ fun ManageUsersDialog( onClick = { scope.launch { isGeneratingCode = true - val token = TokenStorage.getToken() - if (token != null) { - when (val result = residenceApi.generateShareCode(token, residenceId)) { - is ApiResult.Success -> { - shareCode = result.data.shareCode - } - is ApiResult.Error -> { - error = result.message - } - else -> {} + when (val result = APILayer.generateShareCode(residenceId)) { + is ApiResult.Success -> { + shareCode = result.data.shareCode } + is ApiResult.Error -> { + error = result.message + } + else -> {} } isGeneratingCode = false } @@ -277,18 +269,15 @@ fun ManageUsersDialog( isPrimaryOwner = isPrimaryOwner, onRemove = { scope.launch { - val token = TokenStorage.getToken() - if (token != null) { - when (residenceApi.removeUser(token, residenceId, user.id)) { - is ApiResult.Success -> { - users = users.filter { it.id != user.id } - onUserRemoved() - } - is ApiResult.Error -> { - // Show error - } - else -> {} + when (APILayer.removeUser(residenceId, user.id)) { + is ApiResult.Success -> { + users = users.filter { it.id != user.id } + onUserRemoved() } + is ApiResult.Error -> { + // Show error + } + else -> {} } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/documents/DocumentsTabContent.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/documents/DocumentsTabContent.kt index b3f4e13..98cab07 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/documents/DocumentsTabContent.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/documents/DocumentsTabContent.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.example.casera.models.Document import com.example.casera.network.ApiResult -import com.example.casera.cache.SubscriptionCache import com.example.casera.ui.subscription.UpgradeFeatureScreen import com.example.casera.utils.SubscriptionHelper diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentDetailScreen.kt index 406e04e..872523c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentDetailScreen.kt @@ -281,9 +281,8 @@ fun DocumentDetailScreen( ) OrganicDivider() - document.residenceAddress?.let { DetailRow(stringResource(Res.string.documents_residence), it) } - document.contractorName?.let { DetailRow(stringResource(Res.string.documents_contractor), it) } - document.contractorPhone?.let { DetailRow(stringResource(Res.string.documents_contractor_phone), it) } + document.residenceId?.let { DetailRow(stringResource(Res.string.documents_residence), "Residence #$it") } + document.taskId?.let { DetailRow(stringResource(Res.string.documents_contractor), "Task #$it") } } } @@ -378,7 +377,7 @@ fun DocumentDetailScreen( ) OrganicDivider() - document.fileType?.let { DetailRow(stringResource(Res.string.documents_file_type), it) } + document.mimeType?.let { DetailRow(stringResource(Res.string.documents_file_type), it) } document.fileSize?.let { DetailRow(stringResource(Res.string.documents_file_size), formatFileSize(it)) } @@ -408,7 +407,10 @@ fun DocumentDetailScreen( ) OrganicDivider() - document.uploadedByUsername?.let { DetailRow(stringResource(Res.string.documents_uploaded_by), it) } + document.createdBy?.let { user -> + val name = listOfNotNull(user.firstName, user.lastName).joinToString(" ").ifEmpty { user.username } + DetailRow(stringResource(Res.string.documents_uploaded_by), name) + } document.createdAt?.let { DetailRow(stringResource(Res.string.documents_created), DateUtils.formatDateMedium(it)) } document.updatedAt?.let { DetailRow(stringResource(Res.string.documents_updated), DateUtils.formatDateMedium(it)) } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ManageUsersScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ManageUsersScreen.kt index 78216f1..6ef8ff4 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ManageUsersScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ManageUsersScreen.kt @@ -19,8 +19,7 @@ import casera.composeapp.generated.resources.* import com.example.casera.models.ResidenceUser import com.example.casera.models.ResidenceShareCode import com.example.casera.network.ApiResult -import com.example.casera.network.ResidenceApi -import com.example.casera.storage.TokenStorage +import com.example.casera.network.APILayer import com.example.casera.ui.theme.* import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -43,26 +42,22 @@ fun ManageUsersScreen( var isGeneratingCode by remember { mutableStateOf(false) } var showRemoveConfirmation by remember { mutableStateOf(null) } - val residenceApi = remember { ResidenceApi() } val scope = rememberCoroutineScope() val clipboardManager = LocalClipboardManager.current val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(residenceId) { shareCode = null - val token = TokenStorage.getToken() - if (token != null) { - when (val result = residenceApi.getResidenceUsers(token, residenceId)) { - is ApiResult.Success -> { - users = result.data - isLoading = false - } - is ApiResult.Error -> { - error = result.message - isLoading = false - } - else -> {} + when (val result = APILayer.getResidenceUsers(residenceId)) { + is ApiResult.Success -> { + users = result.data + isLoading = false } + is ApiResult.Error -> { + error = result.message + isLoading = false + } + else -> {} } } @@ -280,17 +275,14 @@ fun ManageUsersScreen( onClick = { scope.launch { isGeneratingCode = true - val token = TokenStorage.getToken() - if (token != null) { - when (val result = residenceApi.generateShareCode(token, residenceId)) { - is ApiResult.Success -> { - shareCode = result.data.shareCode - } - is ApiResult.Error -> { - error = result.message - } - else -> {} + when (val result = APILayer.generateShareCode(residenceId)) { + is ApiResult.Success -> { + shareCode = result.data.shareCode } + is ApiResult.Error -> { + error = result.message + } + else -> {} } isGeneratingCode = false } @@ -356,18 +348,15 @@ fun ManageUsersScreen( TextButton( onClick = { scope.launch { - val token = TokenStorage.getToken() - if (token != null) { - when (residenceApi.removeUser(token, residenceId, user.id)) { - is ApiResult.Success -> { - users = users.filter { it.id != user.id } - onUserRemoved() - } - is ApiResult.Error -> { - // Show error - } - else -> {} + when (APILayer.removeUser(residenceId, user.id)) { + is ApiResult.Success -> { + users = users.filter { it.id != user.id } + onUserRemoved() } + is ApiResult.Error -> { + // Show error + } + else -> {} } showRemoveConfirmation = null } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt index 965f3df..1f8f353 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt @@ -26,8 +26,8 @@ import com.example.casera.ui.theme.ThemeManager import com.example.casera.ui.theme.* import com.example.casera.viewmodel.AuthViewModel import com.example.casera.network.ApiResult -import com.example.casera.storage.TokenStorage -import com.example.casera.cache.SubscriptionCache +import com.example.casera.network.APILayer +import com.example.casera.data.DataManager import com.example.casera.ui.subscription.UpgradePromptDialog import androidx.compose.runtime.getValue import com.example.casera.analytics.PostHogAnalytics @@ -56,7 +56,7 @@ fun ProfileScreen( val updateState by viewModel.updateProfileState.collectAsState() val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } } - val currentSubscription by SubscriptionCache.currentSubscription + val currentSubscription by DataManager.subscription.collectAsState() // Handle errors for profile update updateState.HandleErrors( @@ -73,24 +73,21 @@ fun ProfileScreen( // Track screen view and load current user data LaunchedEffect(Unit) { PostHogAnalytics.screen(AnalyticsEvents.SETTINGS_SCREEN_SHOWN) - val token = TokenStorage.getToken() - if (token != null) { - val authApi = com.example.casera.network.AuthApi() - when (val result = authApi.getCurrentUser(token)) { - is ApiResult.Success -> { - firstName = result.data.firstName ?: "" - lastName = result.data.lastName ?: "" - email = result.data.email - isLoadingUser = false - } - else -> { - errorMessage = "profile_load_failed" - isLoadingUser = false - } + when (val result = APILayer.getCurrentUser()) { + is ApiResult.Success -> { + firstName = result.data.firstName ?: "" + lastName = result.data.lastName ?: "" + email = result.data.email + isLoadingUser = false + } + is ApiResult.Error -> { + errorMessage = if (result.code == 401) "profile_not_authenticated" else "profile_load_failed" + isLoadingUser = false + } + else -> { + errorMessage = "profile_load_failed" + isLoadingUser = false } - } else { - errorMessage = "profile_not_authenticated" - isLoadingUser = false } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt index 22bef23..ae3671d 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt @@ -33,7 +33,6 @@ import com.example.casera.models.ContractorSummary import com.example.casera.network.ApiResult import com.example.casera.utils.SubscriptionHelper import com.example.casera.ui.subscription.UpgradePromptDialog -import com.example.casera.cache.SubscriptionCache import com.example.casera.data.DataManager import com.example.casera.util.DateUtils import com.example.casera.platform.rememberShareResidence diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidencesScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidencesScreen.kt index 46dad55..5271e74 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidencesScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidencesScreen.kt @@ -33,7 +33,6 @@ import com.example.casera.viewmodel.TaskViewModel import com.example.casera.network.ApiResult import com.example.casera.utils.SubscriptionHelper import com.example.casera.ui.subscription.UpgradePromptDialog -import com.example.casera.cache.SubscriptionCache import com.example.casera.analytics.PostHogAnalytics import com.example.casera.analytics.AnalyticsEvents import com.example.casera.data.DataManager diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/FeatureComparisonDialog.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/FeatureComparisonDialog.kt index 529437a..bfbf9b5 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/FeatureComparisonDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/FeatureComparisonDialog.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import com.example.casera.cache.SubscriptionCache +import com.example.casera.data.DataManager import com.example.casera.ui.theme.AppRadius import com.example.casera.ui.theme.AppSpacing @@ -22,8 +22,7 @@ fun FeatureComparisonDialog( onDismiss: () -> Unit, onUpgrade: () -> Unit ) { - val subscriptionCache = SubscriptionCache - val featureBenefits = subscriptionCache.featureBenefits.value + val featureBenefits = DataManager.featureBenefits.value Dialog(onDismissRequest = onDismiss) { Card( @@ -115,8 +114,8 @@ fun FeatureComparisonDialog( featureBenefits.forEach { benefit -> ComparisonRow( featureName = benefit.featureName, - freeText = benefit.freeTier, - proText = benefit.proTier + freeText = benefit.freeTierText, + proText = benefit.proTierText ) HorizontalDivider() } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/UpgradeFeatureScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/UpgradeFeatureScreen.kt index bafdbe8..ded2e2d 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/UpgradeFeatureScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/UpgradeFeatureScreen.kt @@ -13,9 +13,10 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.example.casera.cache.SubscriptionCache +import com.example.casera.data.DataManager import com.example.casera.ui.theme.AppRadius import com.example.casera.ui.theme.AppSpacing +import com.example.casera.utils.SubscriptionProducts /** * Full inline paywall screen for upgrade prompts. @@ -35,7 +36,7 @@ fun UpgradeFeatureScreen( // Look up trigger data from cache val triggerData by remember { derivedStateOf { - SubscriptionCache.upgradeTriggers.value[triggerKey] + DataManager.upgradeTriggers.value[triggerKey] } } // Fallback values if trigger not found @@ -252,26 +253,26 @@ private fun SubscriptionProductsSection( ) { // Monthly Option SubscriptionProductCard( - productId = "com.example.casera.pro.monthly", + productId = SubscriptionProducts.MONTHLY, name = "Casera Pro Monthly", price = "$2.99/month", description = "Billed monthly", savingsBadge = null, isSelected = false, isProcessing = isProcessing, - onSelect = { onProductSelected("com.example.casera.pro.monthly") } + onSelect = { onProductSelected(SubscriptionProducts.MONTHLY) } ) // Annual Option SubscriptionProductCard( - productId = "com.example.casera.pro.annual", + productId = SubscriptionProducts.ANNUAL, name = "Casera Pro Annual", price = "$27.99/year", description = "Billed annually", savingsBadge = "Save 22%", isSelected = false, isProcessing = isProcessing, - onSelect = { onProductSelected("com.example.casera.pro.annual") } + onSelect = { onProductSelected(SubscriptionProducts.ANNUAL) } ) } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/UpgradePromptDialog.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/UpgradePromptDialog.kt index 34f3306..aebe446 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/UpgradePromptDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/UpgradePromptDialog.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import com.example.casera.cache.SubscriptionCache +import com.example.casera.data.DataManager import com.example.casera.ui.theme.AppRadius import com.example.casera.ui.theme.AppSpacing @@ -21,8 +21,7 @@ fun UpgradePromptDialog( onDismiss: () -> Unit, onUpgrade: () -> Unit ) { - val subscriptionCache = SubscriptionCache - val triggerData = subscriptionCache.upgradeTriggers.value[triggerKey] + val triggerData = DataManager.upgradeTriggers.value[triggerKey] var showFeatureComparison by remember { mutableStateOf(false) } var isProcessing by remember { mutableStateOf(false) } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/UpgradeScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/UpgradeScreen.kt index a3ea9a7..b45bbc7 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/UpgradeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/UpgradeScreen.kt @@ -23,9 +23,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import casera.composeapp.generated.resources.* -import com.example.casera.cache.SubscriptionCache +import com.example.casera.data.DataManager import com.example.casera.ui.theme.AppRadius import com.example.casera.ui.theme.AppSpacing +import com.example.casera.utils.SubscriptionProducts import org.jetbrains.compose.resources.stringResource /** @@ -50,7 +51,7 @@ fun UpgradeScreen( var isPurchasing by remember { mutableStateOf(false) } var isRestoring by remember { mutableStateOf(false) } - val featureBenefits = SubscriptionCache.featureBenefits.value + val featureBenefits = DataManager.featureBenefits.value Scaffold( topBar = { @@ -213,7 +214,7 @@ fun UpgradeScreen( Button( onClick = { isPurchasing = true - val planId = if (selectedPlan == PlanType.YEARLY) "casera_pro_yearly" else "casera_pro_monthly" + val planId = if (selectedPlan == PlanType.YEARLY) SubscriptionProducts.ANNUAL else SubscriptionProducts.MONTHLY onPurchase(planId) }, modifier = Modifier diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/utils/SubscriptionHelper.kt b/composeApp/src/commonMain/kotlin/com/example/casera/utils/SubscriptionHelper.kt index a33d0e6..9f6b75e 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/utils/SubscriptionHelper.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/utils/SubscriptionHelper.kt @@ -1,10 +1,33 @@ package com.example.casera.utils -import com.example.casera.cache.SubscriptionCache +import com.example.casera.data.DataManager + +/** + * Canonical product IDs for in-app subscriptions. + * + * These must match the product IDs configured in: + * - Apple App Store Connect (StoreKit configuration) + * - Google Play Console (subscription products) + * + * All code referencing subscription product IDs should use these constants + * instead of hardcoded strings to ensure consistency. + */ +object SubscriptionProducts { + const val MONTHLY = "com.example.casera.pro.monthly" + const val ANNUAL = "com.example.casera.pro.annual" + + /** All product IDs as a list, useful for querying store product details. */ + val all: List = listOf(MONTHLY, ANNUAL) +} /** * Helper for checking subscription limits and determining when to show upgrade prompts. * + * Reads ALL subscription state from DataManager (single source of truth). + * The current tier is derived from the backend subscription status: + * - If expiresAt is present and non-empty, user is "pro" + * - Otherwise, user is "free" + * * RULES: * 1. Backend limitations OFF: Never show upgrade view, allow everything * 2. Backend limitations ON + limit=0: Show upgrade view, block access entirely (no add button) @@ -20,9 +43,30 @@ object SubscriptionHelper { */ data class UsageCheck(val allowed: Boolean, val triggerKey: String?) - // NOTE: For Android, currentTier should be set from Google Play Billing - // For iOS, tier is managed by SubscriptionCacheWrapper from StoreKit - var currentTier: String = "free" + /** + * Derive the current subscription tier from DataManager. + * "pro" if the backend subscription has a non-empty expiresAt (active paid plan), + * "free" otherwise. + */ + val currentTier: String + get() { + val subscription = DataManager.subscription.value ?: return "free" + val expiresAt = subscription.expiresAt + return if (!expiresAt.isNullOrEmpty()) "pro" else "free" + } + + /** + * Whether the user has a premium (pro) subscription. + * True when limitations are disabled (everyone gets full access) + * OR when the user is on the pro tier. + */ + val isPremium: Boolean + get() { + val subscription = DataManager.subscription.value ?: return false + // If limitations are disabled, everyone effectively has premium access + if (!subscription.limitationsEnabled) return true + return currentTier == "pro" + } // ===== PROPERTY (RESIDENCE) ===== @@ -31,18 +75,19 @@ object SubscriptionHelper { * Returns true (blocked) only when limitations are ON and limit=0. */ fun isResidencesBlocked(): UsageCheck { - val subscription = SubscriptionCache.currentSubscription.value + val subscription = DataManager.subscription.value ?: return UsageCheck(false, null) // Allow access while loading if (!subscription.limitationsEnabled) { return UsageCheck(false, null) // Limitations disabled, never block } - if (currentTier == "pro") { + val tier = currentTier + if (tier == "pro") { return UsageCheck(false, null) // Pro users never blocked } - val limit = subscription.limits[currentTier]?.properties + val limit = subscription.limits[tier]?.properties // If limit is 0, block access entirely if (limit == 0) { @@ -57,19 +102,20 @@ object SubscriptionHelper { * Used when limit > 0 and user has reached the limit. */ fun canAddProperty(currentCount: Int = 0): UsageCheck { - val subscription = SubscriptionCache.currentSubscription.value + val subscription = DataManager.subscription.value ?: return UsageCheck(true, null) // Allow if no subscription data if (!subscription.limitationsEnabled) { return UsageCheck(true, null) // Limitations disabled, allow everything } - if (currentTier == "pro") { + val tier = currentTier + if (tier == "pro") { return UsageCheck(true, null) // Pro tier gets unlimited access } // Get limit for current tier (null = unlimited) - val limit = subscription.limits[currentTier]?.properties + val limit = subscription.limits[tier]?.properties // null means unlimited if (limit == null) { @@ -97,18 +143,19 @@ object SubscriptionHelper { * Returns true (blocked) only when limitations are ON and limit=0. */ fun isTasksBlocked(): UsageCheck { - val subscription = SubscriptionCache.currentSubscription.value + val subscription = DataManager.subscription.value ?: return UsageCheck(false, null) // Allow access while loading if (!subscription.limitationsEnabled) { return UsageCheck(false, null) } - if (currentTier == "pro") { + val tier = currentTier + if (tier == "pro") { return UsageCheck(false, null) } - val limit = subscription.limits[currentTier]?.tasks + val limit = subscription.limits[tier]?.tasks if (limit == 0) { return UsageCheck(true, "add_11th_task") @@ -121,18 +168,19 @@ object SubscriptionHelper { * Check if user can add a task (when trying to add). */ fun canAddTask(currentCount: Int = 0): UsageCheck { - val subscription = SubscriptionCache.currentSubscription.value + val subscription = DataManager.subscription.value ?: return UsageCheck(true, null) if (!subscription.limitationsEnabled) { return UsageCheck(true, null) } - if (currentTier == "pro") { + val tier = currentTier + if (tier == "pro") { return UsageCheck(true, null) } - val limit = subscription.limits[currentTier]?.tasks + val limit = subscription.limits[tier]?.tasks if (limit == null) { return UsageCheck(true, null) // Unlimited @@ -156,18 +204,19 @@ object SubscriptionHelper { * Returns true (blocked) only when limitations are ON and limit=0. */ fun isContractorsBlocked(): UsageCheck { - val subscription = SubscriptionCache.currentSubscription.value + val subscription = DataManager.subscription.value ?: return UsageCheck(false, null) // Allow access while loading if (!subscription.limitationsEnabled) { return UsageCheck(false, null) } - if (currentTier == "pro") { + val tier = currentTier + if (tier == "pro") { return UsageCheck(false, null) } - val limit = subscription.limits[currentTier]?.contractors + val limit = subscription.limits[tier]?.contractors if (limit == 0) { return UsageCheck(true, "view_contractors") @@ -180,18 +229,19 @@ object SubscriptionHelper { * Check if user can add a contractor (when trying to add). */ fun canAddContractor(currentCount: Int = 0): UsageCheck { - val subscription = SubscriptionCache.currentSubscription.value + val subscription = DataManager.subscription.value ?: return UsageCheck(true, null) if (!subscription.limitationsEnabled) { return UsageCheck(true, null) } - if (currentTier == "pro") { + val tier = currentTier + if (tier == "pro") { return UsageCheck(true, null) } - val limit = subscription.limits[currentTier]?.contractors + val limit = subscription.limits[tier]?.contractors if (limit == null) { return UsageCheck(true, null) @@ -215,18 +265,19 @@ object SubscriptionHelper { * Returns true (blocked) only when limitations are ON and limit=0. */ fun isDocumentsBlocked(): UsageCheck { - val subscription = SubscriptionCache.currentSubscription.value + val subscription = DataManager.subscription.value ?: return UsageCheck(false, null) // Allow access while loading if (!subscription.limitationsEnabled) { return UsageCheck(false, null) } - if (currentTier == "pro") { + val tier = currentTier + if (tier == "pro") { return UsageCheck(false, null) } - val limit = subscription.limits[currentTier]?.documents + val limit = subscription.limits[tier]?.documents if (limit == 0) { return UsageCheck(true, "view_documents") @@ -239,18 +290,19 @@ object SubscriptionHelper { * Check if user can add a document (when trying to add). */ fun canAddDocument(currentCount: Int = 0): UsageCheck { - val subscription = SubscriptionCache.currentSubscription.value + val subscription = DataManager.subscription.value ?: return UsageCheck(true, null) if (!subscription.limitationsEnabled) { return UsageCheck(true, null) } - if (currentTier == "pro") { + val tier = currentTier + if (tier == "pro") { return UsageCheck(true, null) } - val limit = subscription.limits[currentTier]?.documents + val limit = subscription.limits[tier]?.documents if (limit == null) { return UsageCheck(true, null) @@ -274,7 +326,7 @@ object SubscriptionHelper { * Returns true (blocked) when limitations are ON and user is not pro. */ fun canShareResidence(): UsageCheck { - val subscription = SubscriptionCache.currentSubscription.value + val subscription = DataManager.subscription.value ?: return UsageCheck(true, null) // Allow if no subscription data (fallback) if (!subscription.limitationsEnabled) { @@ -296,7 +348,7 @@ object SubscriptionHelper { * Returns true (blocked) when limitations are ON and user is not pro. */ fun canShareContractor(): UsageCheck { - val subscription = SubscriptionCache.currentSubscription.value + val subscription = DataManager.subscription.value ?: return UsageCheck(true, null) // Allow if no subscription data (fallback) if (!subscription.limitationsEnabled) { diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/DocumentViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/DocumentViewModel.kt index ead7943..4d74c31 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/DocumentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/DocumentViewModel.kt @@ -30,8 +30,11 @@ class DocumentViewModel : ViewModel() { private val _downloadState = MutableStateFlow>(ApiResult.Idle) val downloadState: StateFlow> = _downloadState - private val _deleteImageState = MutableStateFlow>(ApiResult.Idle) - val deleteImageState: StateFlow> = _deleteImageState + private val _deleteImageState = MutableStateFlow>(ApiResult.Idle) + val deleteImageState: StateFlow> = _deleteImageState + + private val _uploadImageState = MutableStateFlow>(ApiResult.Idle) + val uploadImageState: StateFlow> = _uploadImageState fun loadDocuments( residenceId: Int? = null, @@ -253,7 +256,7 @@ class DocumentViewModel : ViewModel() { } } - // If all uploads succeeded, set success state + // If all uploads succeeded, use the last uploaded document (has all images) if (!uploadFailed) { _updateState.value = updateResult } @@ -293,14 +296,47 @@ class DocumentViewModel : ViewModel() { _downloadState.value = ApiResult.Idle } - fun deleteDocumentImage(imageId: Int) { + fun deleteDocumentImage(documentId: Int, imageId: Int) { viewModelScope.launch { _deleteImageState.value = ApiResult.Loading - _deleteImageState.value = APILayer.deleteDocumentImage(imageId) + _deleteImageState.value = APILayer.deleteDocumentImage(documentId, imageId) + } + } + + fun uploadDocumentImage( + documentId: Int, + imageData: com.example.casera.platform.ImageData, + caption: String? = null + ) { + viewModelScope.launch { + _uploadImageState.value = ApiResult.Loading + val compressedBytes = ImageCompressor.compressImage(imageData) + val fileName = if (imageData.fileName.isNotBlank()) { + val baseName = imageData.fileName + if (baseName.endsWith(".jpg", ignoreCase = true) || + baseName.endsWith(".jpeg", ignoreCase = true)) { + baseName + } else { + baseName.substringBeforeLast('.', baseName) + ".jpg" + } + } else { + "image.jpg" + } + _uploadImageState.value = APILayer.uploadDocumentImage( + documentId = documentId, + imageBytes = compressedBytes, + fileName = fileName, + mimeType = "image/jpeg", + caption = caption + ) } } fun resetDeleteImageState() { _deleteImageState.value = ApiResult.Idle } + + fun resetUploadImageState() { + _uploadImageState.value = ApiResult.Idle + } } diff --git a/composeApp/src/iosMain/kotlin/com/example/casera/network/ApiClient.ios.kt b/composeApp/src/iosMain/kotlin/com/example/casera/network/ApiClient.ios.kt index 5447c33..3b18564 100644 --- a/composeApp/src/iosMain/kotlin/com/example/casera/network/ApiClient.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/example/casera/network/ApiClient.ios.kt @@ -37,7 +37,9 @@ actual fun createHttpClient(): HttpClient { install(Logging) { logger = Logger.DEFAULT - level = LogLevel.ALL + // Only log full request/response bodies in local dev to avoid + // leaking auth tokens and PII in production logs. + level = if (ApiConfig.CURRENT_ENV == ApiConfig.Environment.LOCAL) LogLevel.ALL else LogLevel.INFO } install(DefaultRequest) { diff --git a/composeApp/src/iosMain/kotlin/com/example/casera/platform/ContractorSharing.ios.kt b/composeApp/src/iosMain/kotlin/com/example/casera/platform/ContractorSharing.ios.kt index 31d2e4e..157ff3b 100644 --- a/composeApp/src/iosMain/kotlin/com/example/casera/platform/ContractorSharing.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/example/casera/platform/ContractorSharing.ios.kt @@ -3,6 +3,11 @@ package com.example.casera.platform import androidx.compose.runtime.Composable import com.example.casera.models.Contractor +// Architecture Decision: iOS sharing is implemented natively in Swift +// (ContractorSharingManager.swift) because UIActivityViewController and +// other iOS-native sharing APIs cannot be driven from Kotlin Multiplatform. +// This is an intentional no-op stub. The Android implementation is in androidMain. + /** * iOS implementation is a no-op - sharing is handled in Swift layer via ContractorSharingManager.swift. * The iOS ContractorDetailView uses the Swift sharing manager directly. diff --git a/composeApp/src/iosMain/kotlin/com/example/casera/platform/ResidenceSharing.ios.kt b/composeApp/src/iosMain/kotlin/com/example/casera/platform/ResidenceSharing.ios.kt index 30e9239..9aca2d5 100644 --- a/composeApp/src/iosMain/kotlin/com/example/casera/platform/ResidenceSharing.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/example/casera/platform/ResidenceSharing.ios.kt @@ -4,6 +4,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.example.casera.models.Residence +// Architecture Decision: iOS sharing is implemented natively in Swift +// (ResidenceSharingManager.swift) because UIActivityViewController and +// other iOS-native sharing APIs cannot be driven from Kotlin Multiplatform. +// This is an intentional no-op stub. The Android implementation is in androidMain. + /** * iOS implementation is a no-op - sharing is handled in Swift layer via ResidenceSharingManager.swift. */ diff --git a/composeApp/src/iosMain/kotlin/com/example/casera/storage/TokenManager.ios.kt b/composeApp/src/iosMain/kotlin/com/example/casera/storage/TokenManager.ios.kt index feedc05..2ca511e 100644 --- a/composeApp/src/iosMain/kotlin/com/example/casera/storage/TokenManager.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/example/casera/storage/TokenManager.ios.kt @@ -4,27 +4,38 @@ import platform.Foundation.NSUserDefaults import kotlin.concurrent.Volatile /** - * iOS implementation of TokenManager using NSUserDefaults. + * iOS implementation of TokenManager. + * + * SECURITY NOTE: Currently uses NSUserDefaults for token storage. + * For production hardening, migrate to iOS Keychain via a Swift helper + * exposed to KMP through an expect/actual boundary or SKIE bridge. + * NSUserDefaults is not encrypted and should not store long-lived auth tokens + * in apps handling sensitive data. + * + * Migration plan: + * 1. Create a Swift KeychainHelper class with save/get/delete methods + * 2. Expose it to Kotlin via SKIE or a protocol-based expect/actual + * 3. Use service "com.tt.casera", account "auth_token" */ actual class TokenManager { - private val userDefaults = NSUserDefaults.standardUserDefaults + private val prefs = NSUserDefaults.standardUserDefaults actual fun saveToken(token: String) { - userDefaults.setObject(token, KEY_TOKEN) - userDefaults.synchronize() + prefs.setObject(token, forKey = TOKEN_KEY) + prefs.synchronize() } actual fun getToken(): String? { - return userDefaults.stringForKey(KEY_TOKEN) + return prefs.stringForKey(TOKEN_KEY) } actual fun clearToken() { - userDefaults.removeObjectForKey(KEY_TOKEN) - userDefaults.synchronize() + prefs.removeObjectForKey(TOKEN_KEY) + prefs.synchronize() } companion object { - private const val KEY_TOKEN = "auth_token" + private const val TOKEN_KEY = "auth_token" @Volatile private var instance: TokenManager? = null diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4c4757c..c1b45b5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,7 @@ androidx-activity = "1.11.0" androidx-appcompat = "1.7.1" androidx-core = "1.17.0" androidx-espresso = "3.7.0" +androidx-security-crypto = "1.1.0-alpha06" androidx-lifecycle = "2.9.5" androidx-navigation = "2.9.1" androidx-testExt = "1.3.0" @@ -29,6 +30,7 @@ junit = { module = "junit:junit", version.ref = "junit" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" } androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" } +androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "androidx-security-crypto" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } diff --git a/iosApp/CaseraUITests/CriticalPath/SmokeTests.swift b/iosApp/CaseraUITests/CriticalPath/SmokeTests.swift new file mode 100644 index 0000000..a951819 --- /dev/null +++ b/iosApp/CaseraUITests/CriticalPath/SmokeTests.swift @@ -0,0 +1,120 @@ +import XCTest + +/// Smoke tests - run on every PR. Must complete in <2 minutes. +/// +/// Tests that the app launches successfully, the auth screen renders correctly, +/// and core navigation is functional. These are the minimum-viability tests +/// that must pass before any PR can merge. +final class SmokeTests: XCTestCase { + var app: XCUIApplication! + + override func setUp() { + super.setUp() + continueAfterFailure = false + app = TestLaunchConfig.launchApp() + } + + override func tearDown() { + app = nil + super.tearDown() + } + + // MARK: - App Launch + + func testAppLaunches() { + // App should show either login screen or main tab view + let loginScreen = LoginScreen(app: app) + let mainScreen = MainTabScreen(app: app) + + let loginAppeared = loginScreen.emailField.waitForExistence(timeout: 15) + let mainAppeared = mainScreen.residencesTab.waitForExistence(timeout: 5) + + XCTAssertTrue(loginAppeared || mainAppeared, "App should show login or main screen on launch") + } + + // MARK: - Login Screen Elements + + func testLoginScreenElements() { + let login = LoginScreen(app: app) + guard login.emailField.waitForExistence(timeout: 15) else { + // Already logged in, skip this test + return + } + + XCTAssertTrue(login.emailField.exists, "Email field should exist") + XCTAssertTrue(login.passwordField.exists, "Password field should exist") + XCTAssertTrue(login.loginButton.exists, "Login button should exist") + } + + // MARK: - Login Flow + + func testLoginWithExistingCredentials() { + let login = LoginScreen(app: app) + guard login.emailField.waitForExistence(timeout: 15) else { + // Already on main screen - verify tabs + let main = MainTabScreen(app: app) + XCTAssertTrue(main.isDisplayed, "Main tabs should be visible") + return + } + + // Login with the known test user + let user = TestFixtures.TestUser.existing + login.login(email: user.email, password: user.password) + + let main = MainTabScreen(app: app) + XCTAssertTrue(main.residencesTab.waitForExistence(timeout: 15), "Should navigate to main screen after login") + } + + // MARK: - Tab Navigation + + func testMainTabsExistAfterLogin() { + let login = LoginScreen(app: app) + if login.emailField.waitForExistence(timeout: 15) { + // Need to login first + let user = TestFixtures.TestUser.existing + login.login(email: user.email, password: user.password) + } + + let main = MainTabScreen(app: app) + guard main.residencesTab.waitForExistence(timeout: 15) else { + XCTFail("Main screen did not appear") + return + } + + XCTAssertTrue(main.residencesTab.exists, "Residences tab should exist") + XCTAssertTrue(main.tasksTab.exists, "Tasks tab should exist") + XCTAssertTrue(main.contractorsTab.exists, "Contractors tab should exist") + XCTAssertTrue(main.documentsTab.exists, "Documents tab should exist") + XCTAssertTrue(main.profileTab.exists, "Profile tab should exist") + } + + func testTabNavigation() { + let login = LoginScreen(app: app) + if login.emailField.waitForExistence(timeout: 15) { + let user = TestFixtures.TestUser.existing + login.login(email: user.email, password: user.password) + } + + let main = MainTabScreen(app: app) + guard main.residencesTab.waitForExistence(timeout: 15) else { + XCTFail("Main screen did not appear") + return + } + + // Navigate through each tab and verify selection + main.goToTasks() + XCTAssertTrue(main.tasksTab.isSelected, "Tasks tab should be selected") + + main.goToContractors() + XCTAssertTrue(main.contractorsTab.isSelected, "Contractors tab should be selected") + + main.goToDocuments() + XCTAssertTrue(main.documentsTab.isSelected, "Documents tab should be selected") + + main.goToProfile() + XCTAssertTrue(main.profileTab.isSelected, "Profile tab should be selected") + + main.goToResidences() + XCTAssertTrue(main.residencesTab.isSelected, "Residences tab should be selected") + } +} diff --git a/iosApp/CaseraUITests/Fixtures/TestFixtures.swift b/iosApp/CaseraUITests/Fixtures/TestFixtures.swift new file mode 100644 index 0000000..6869169 --- /dev/null +++ b/iosApp/CaseraUITests/Fixtures/TestFixtures.swift @@ -0,0 +1,120 @@ +import Foundation + +/// Reusable test data builders for UI tests. +/// +/// Each fixture generates unique names using random numbers or UUIDs +/// to ensure test isolation and prevent cross-test interference. +enum TestFixtures { + + // MARK: - Users + + struct TestUser { + let firstName: String + let lastName: String + let email: String + let password: String + + /// Standard test user with unique email. + static let standard = TestUser( + firstName: "Test", + lastName: "User", + email: "uitest_\(UUID().uuidString.prefix(8))@test.com", + password: "TestPassword123!" + ) + + /// Secondary test user for multi-user scenarios. + static let secondary = TestUser( + firstName: "Second", + lastName: "Tester", + email: "uitest2_\(UUID().uuidString.prefix(8))@test.com", + password: "TestPassword456!" + ) + + /// Pre-existing test user with known credentials (must exist on backend). + static let existing = TestUser( + firstName: "Test", + lastName: "User", + email: "testuser", + password: "TestPass123!" + ) + } + + // MARK: - Residences + + struct TestResidence { + let name: String + let address: String + let type: String + + static let house = TestResidence( + name: "Test House \(Int.random(in: 1000...9999))", + address: "123 Test St", + type: "House" + ) + + static let apartment = TestResidence( + name: "Test Apt \(Int.random(in: 1000...9999))", + address: "456 Mock Ave", + type: "Apartment" + ) + } + + // MARK: - Tasks + + struct TestTask { + let title: String + let description: String + let priority: String + let category: String + + static let basic = TestTask( + title: "Test Task \(Int.random(in: 1000...9999))", + description: "A test task", + priority: "Medium", + category: "Cleaning" + ) + + static let urgent = TestTask( + title: "Urgent Task \(Int.random(in: 1000...9999))", + description: "An urgent task", + priority: "High", + category: "Repair" + ) + } + + // MARK: - Documents + + struct TestDocument { + let title: String + let description: String + let type: String + + static let basic = TestDocument( + title: "Test Doc \(Int.random(in: 1000...9999))", + description: "A test document", + type: "Manual" + ) + + static let warranty = TestDocument( + title: "Test Warranty \(Int.random(in: 1000...9999))", + description: "A test warranty", + type: "Warranty" + ) + } + + // MARK: - Contractors + + struct TestContractor { + let name: String + let phone: String + let email: String + let specialty: String + + static let basic = TestContractor( + name: "Test Contractor \(Int.random(in: 1000...9999))", + phone: "555-0100", + email: "contractor@test.com", + specialty: "Plumber" + ) + } +} diff --git a/iosApp/CaseraUITests/PageObjects/BaseScreen.swift b/iosApp/CaseraUITests/PageObjects/BaseScreen.swift new file mode 100644 index 0000000..271cd35 --- /dev/null +++ b/iosApp/CaseraUITests/PageObjects/BaseScreen.swift @@ -0,0 +1,73 @@ +import XCTest + +/// Base class for all page objects providing common waiting and assertion utilities. +/// +/// Replaces ad-hoc `sleep()` calls with condition-based waits for reliable, +/// non-flaky UI tests. All screen page objects should inherit from this class. +class BaseScreen { + let app: XCUIApplication + let timeout: TimeInterval + + init(app: XCUIApplication, timeout: TimeInterval = 10) { + self.app = app + self.timeout = timeout + } + + // MARK: - Wait Helpers (replaces fixed sleeps) + + /// Waits for an element to exist within the timeout period. + /// Fails the test with a descriptive message if the element does not appear. + @discardableResult + func waitForElement(_ element: XCUIElement, timeout: TimeInterval? = nil) -> XCUIElement { + let t = timeout ?? self.timeout + XCTAssertTrue(element.waitForExistence(timeout: t), "Element \(element) did not appear within \(t)s") + return element + } + + /// Waits for an element to disappear within the timeout period. + /// Fails the test if the element is still present after the timeout. + func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval? = nil) { + let t = timeout ?? self.timeout + let predicate = NSPredicate(format: "exists == false") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) + let result = XCTWaiter().wait(for: [expectation], timeout: t) + XCTAssertEqual(result, .completed, "Element \(element) did not disappear within \(t)s") + } + + /// Waits for an element to become hittable (visible and interactable). + /// Returns the element for chaining. + @discardableResult + func waitForHittable(_ element: XCUIElement, timeout: TimeInterval? = nil) -> XCUIElement { + let t = timeout ?? self.timeout + let predicate = NSPredicate(format: "isHittable == true") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) + _ = XCTWaiter().wait(for: [expectation], timeout: t) + return element + } + + // MARK: - State Assertions + + /// Asserts that an element with the given accessibility identifier exists. + func assertExists(_ identifier: String, file: StaticString = #file, line: UInt = #line) { + let element = app.descendants(matching: .any)[identifier] + XCTAssertTrue(element.waitForExistence(timeout: timeout), "Element '\(identifier)' not found", file: file, line: line) + } + + /// Asserts that an element with the given accessibility identifier does not exist. + func assertNotExists(_ identifier: String, file: StaticString = #file, line: UInt = #line) { + let element = app.descendants(matching: .any)[identifier] + XCTAssertFalse(element.exists, "Element '\(identifier)' should not exist", file: file, line: line) + } + + // MARK: - Navigation + + /// Taps the first button in the navigation bar (typically the back button). + func tapBackButton() { + app.navigationBars.buttons.element(boundBy: 0).tap() + } + + /// Subclasses must override this property to indicate whether the screen is currently displayed. + var isDisplayed: Bool { + fatalError("Subclasses must override isDisplayed") + } +} diff --git a/iosApp/CaseraUITests/PageObjects/LoginScreen.swift b/iosApp/CaseraUITests/PageObjects/LoginScreen.swift new file mode 100644 index 0000000..a17d3a7 --- /dev/null +++ b/iosApp/CaseraUITests/PageObjects/LoginScreen.swift @@ -0,0 +1,86 @@ +import XCTest + +/// Page object for the login screen. +/// +/// Uses accessibility identifiers from `AccessibilityIdentifiers.Authentication` +/// to locate elements. Provides typed actions for login flow interactions. +class LoginScreen: BaseScreen { + + // MARK: - Elements + + var emailField: XCUIElement { + app.textFields[AccessibilityIdentifiers.Authentication.usernameField] + } + + var passwordField: XCUIElement { + // Password field may be a SecureTextField or regular TextField depending on visibility toggle + let secure = app.secureTextFields[AccessibilityIdentifiers.Authentication.passwordField] + if secure.exists { return secure } + return app.textFields[AccessibilityIdentifiers.Authentication.passwordField] + } + + var loginButton: XCUIElement { + app.buttons[AccessibilityIdentifiers.Authentication.loginButton] + } + + var appleSignInButton: XCUIElement { + app.buttons[AccessibilityIdentifiers.Authentication.appleSignInButton] + } + + var signUpButton: XCUIElement { + app.buttons[AccessibilityIdentifiers.Authentication.signUpButton] + } + + var forgotPasswordButton: XCUIElement { + app.buttons[AccessibilityIdentifiers.Authentication.forgotPasswordButton] + } + + var passwordVisibilityToggle: XCUIElement { + app.buttons[AccessibilityIdentifiers.Authentication.passwordVisibilityToggle] + } + + var welcomeText: XCUIElement { + app.staticTexts["Welcome Back"] + } + + override var isDisplayed: Bool { + emailField.waitForExistence(timeout: timeout) + } + + // MARK: - Actions + + /// Logs in with the provided credentials and returns a MainTabScreen. + /// Waits for the email field to appear before typing. + @discardableResult + func login(email: String, password: String) -> MainTabScreen { + waitForElement(emailField).tap() + emailField.typeText(email) + + let pwField = passwordField + pwField.tap() + pwField.typeText(password) + + loginButton.tap() + return MainTabScreen(app: app) + } + + /// Taps the sign up / register link and returns a RegisterScreen. + @discardableResult + func tapSignUp() -> RegisterScreen { + waitForElement(signUpButton).tap() + return RegisterScreen(app: app) + } + + /// Taps the forgot password link. + func tapForgotPassword() { + waitForElement(forgotPasswordButton).tap() + } + + /// Toggles password visibility and returns whether the password is now visible. + @discardableResult + func togglePasswordVisibility() -> Bool { + waitForElement(passwordVisibilityToggle).tap() + // If a regular text field with the password identifier exists, password is visible + return app.textFields[AccessibilityIdentifiers.Authentication.passwordField].exists + } +} diff --git a/iosApp/CaseraUITests/PageObjects/MainTabScreen.swift b/iosApp/CaseraUITests/PageObjects/MainTabScreen.swift new file mode 100644 index 0000000..1dd80a8 --- /dev/null +++ b/iosApp/CaseraUITests/PageObjects/MainTabScreen.swift @@ -0,0 +1,88 @@ +import XCTest + +/// Page object for the main tab view that appears after login. +/// +/// Provides navigation to each tab (Residences, Tasks, Contractors, Documents, Profile) +/// and a logout flow. Uses predicate-based element lookup to match the existing test patterns. +class MainTabScreen: BaseScreen { + + // MARK: - Tab Elements + + var residencesTab: XCUIElement { + app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch + } + + var tasksTab: XCUIElement { + app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch + } + + var contractorsTab: XCUIElement { + app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch + } + + var documentsTab: XCUIElement { + app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Documents'")).firstMatch + } + + var profileTab: XCUIElement { + app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Profile'")).firstMatch + } + + override var isDisplayed: Bool { + residencesTab.waitForExistence(timeout: timeout) + } + + // MARK: - Navigation + + @discardableResult + func goToResidences() -> Self { + waitForElement(residencesTab).tap() + return self + } + + @discardableResult + func goToTasks() -> Self { + waitForElement(tasksTab).tap() + return self + } + + @discardableResult + func goToContractors() -> Self { + waitForElement(contractorsTab).tap() + return self + } + + @discardableResult + func goToDocuments() -> Self { + waitForElement(documentsTab).tap() + return self + } + + @discardableResult + func goToProfile() -> Self { + waitForElement(profileTab).tap() + return self + } + + // MARK: - Logout + + /// Logs out by navigating to the Profile tab and tapping the logout button. + /// Handles the confirmation alert automatically. + func logout() { + goToProfile() + + let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton] + if logoutButton.waitForExistence(timeout: 5) { + waitForHittable(logoutButton).tap() + + // Handle confirmation alert + let alert = app.alerts.firstMatch + if alert.waitForExistence(timeout: 3) { + let confirmLogout = alert.buttons["Log Out"] + if confirmLogout.exists { + confirmLogout.tap() + } + } + } + } +} diff --git a/iosApp/CaseraUITests/PageObjects/RegisterScreen.swift b/iosApp/CaseraUITests/PageObjects/RegisterScreen.swift new file mode 100644 index 0000000..9c02f7c --- /dev/null +++ b/iosApp/CaseraUITests/PageObjects/RegisterScreen.swift @@ -0,0 +1,86 @@ +import XCTest + +/// Page object for the registration screen. +/// +/// Uses accessibility identifiers from `AccessibilityIdentifiers.Authentication` +/// to locate registration form elements and perform sign-up actions. +class RegisterScreen: BaseScreen { + + // MARK: - Elements + + var usernameField: XCUIElement { + app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField] + } + + var emailField: XCUIElement { + app.textFields[AccessibilityIdentifiers.Authentication.registerEmailField] + } + + var passwordField: XCUIElement { + app.secureTextFields[AccessibilityIdentifiers.Authentication.registerPasswordField] + } + + var confirmPasswordField: XCUIElement { + app.secureTextFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField] + } + + var registerButton: XCUIElement { + app.buttons[AccessibilityIdentifiers.Authentication.registerButton] + } + + var cancelButton: XCUIElement { + app.buttons[AccessibilityIdentifiers.Authentication.registerCancelButton] + } + + /// Fallback element lookup for the register/create account button using predicate + var registerButtonByLabel: XCUIElement { + app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Register' OR label CONTAINS[c] 'Create Account'")).firstMatch + } + + override var isDisplayed: Bool { + // Registration screen is visible if any of the register-specific fields exist + let usernameExists = usernameField.waitForExistence(timeout: timeout) + let emailExists = emailField.exists + return usernameExists || emailExists + } + + // MARK: - Actions + + /// Fills in the registration form and submits it. + /// Returns a MainTabScreen assuming successful registration leads to the main app. + @discardableResult + func register(username: String, email: String, password: String) -> MainTabScreen { + waitForElement(usernameField).tap() + usernameField.typeText(username) + + emailField.tap() + emailField.typeText(email) + + passwordField.tap() + passwordField.typeText(password) + + confirmPasswordField.tap() + confirmPasswordField.typeText(password) + + // Try accessibility identifier first, fall back to label search + if registerButton.exists { + registerButton.tap() + } else { + registerButtonByLabel.tap() + } + + return MainTabScreen(app: app) + } + + /// Taps cancel to return to the login screen. + @discardableResult + func tapCancel() -> LoginScreen { + if cancelButton.exists { + cancelButton.tap() + } else { + // Fall back to navigation back button + tapBackButton() + } + return LoginScreen(app: app) + } +} diff --git a/iosApp/CaseraUITests/README.md b/iosApp/CaseraUITests/README.md new file mode 100644 index 0000000..68cac38 --- /dev/null +++ b/iosApp/CaseraUITests/README.md @@ -0,0 +1,80 @@ +# Casera iOS UI Testing Architecture + +## Directory Structure + +``` +CaseraUITests/ +├── PageObjects/ # Screen abstractions (Page Object pattern) +│ ├── BaseScreen.swift # Common wait/assert utilities +│ ├── LoginScreen.swift # Login screen elements and actions +│ ├── RegisterScreen.swift +│ └── MainTabScreen.swift +├── TestConfiguration/ # Launch config, environment setup +│ └── TestLaunchConfig.swift +├── Fixtures/ # Test data builders +│ └── TestFixtures.swift +├── CriticalPath/ # Must-pass tests for CI gating +│ └── SmokeTests.swift # Fast smoke suite (<2 min) +├── Suite0-10_*.swift # Existing comprehensive test suites +├── UITestHelpers.swift # Legacy shared helpers +├── AccessibilityIdentifiers.swift # UI element IDs +└── README.md # This file +``` + +## Test Suites + +| Suite | Purpose | CI Gate | Target Time | +|-------|---------|---------|-------------| +| SmokeTests | App launches, auth, navigation | Every PR | <2 min | +| Suite0-2 | Onboarding, registration, auth | Nightly | <5 min | +| Suite3-8 | Feature CRUD (residence, task, etc) | Nightly | <15 min | +| Suite9-10 | E2E integration | Weekly | <30 min | + +## Patterns + +### Page Object Pattern +Every screen has a corresponding PageObject in `PageObjects/`. Use these instead of raw XCUIElement queries in tests. Page objects encapsulate element lookups and common actions, making tests more readable and easier to maintain when the UI changes. + +### Wait Helpers +NEVER use `sleep()` or `Thread.sleep()`. Use `waitForElement()`, `waitForElementToDisappear()`, or `waitForHittable()` from BaseScreen. These are condition-based waits that return as soon as the condition is met, making tests both faster and more reliable. + +### Test Data +Use `TestFixtures` builders for consistent, unique test data. Random numbers and UUIDs ensure test isolation so tests can run in any order without interfering with each other. + +### Launch Configuration +Use `TestLaunchConfig.launchApp()` for standard launches. Use `launchAuthenticated()` to skip login when the app supports test authentication bypass. The standard configuration disables animations and forces English locale. + +### Accessibility Identifiers +All interactive elements must have identifiers defined in `AccessibilityIdentifiers.swift`. Use `.accessibilityIdentifier()` in SwiftUI views. Page objects reference these identifiers for element lookup. + +## CI Configuration + +### Smoke Suite (every PR) +```bash +xcodebuild test -project iosApp.xcodeproj -scheme iosApp \ + -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' \ + -only-testing:CaseraUITests/SmokeTests +``` + +### Full Regression (nightly) +```bash +xcodebuild test -project iosApp.xcodeproj -scheme iosApp \ + -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' \ + -only-testing:CaseraUITests +``` + +## Flake Reduction + +- Target: <2% flake rate on critical-path suite +- All waits use condition-based predicates (no fixed sleeps) +- Test data uses unique identifiers to prevent cross-test interference +- UI animations disabled via launch arguments +- Element lookups use accessibility identifiers where possible, with predicate-based fallbacks + +## Adding New Tests + +1. If the screen does not have a page object yet, create one in `PageObjects/` that extends `BaseScreen`. +2. Define accessibility identifiers in `AccessibilityIdentifiers.swift` for any new UI elements. +3. Add test data builders to `TestFixtures.swift` if needed. +4. Write the test in the appropriate suite file, or create a new suite if the feature is new. +5. For critical-path tests (must pass on every PR), add to `CriticalPath/SmokeTests.swift`. diff --git a/iosApp/CaseraUITests/TestConfiguration/TestLaunchConfig.swift b/iosApp/CaseraUITests/TestConfiguration/TestLaunchConfig.swift new file mode 100644 index 0000000..a1b8396 --- /dev/null +++ b/iosApp/CaseraUITests/TestConfiguration/TestLaunchConfig.swift @@ -0,0 +1,64 @@ +import XCTest + +/// Centralized app launch configuration for UI tests. +/// +/// Provides consistent launch arguments and environment variables across +/// all test suites. Disables animations and sets locale to English for +/// deterministic test behavior. +enum TestLaunchConfig { + + /// Standard launch arguments for UI test mode. + /// Disables animations and forces English locale. + static let standardArguments: [String] = [ + "-UITEST_MODE", "1", + "-AppleLanguages", "(en)", + "-AppleLocale", "en_US", + "-UIAnimationsEnabled", "NO" + ] + + /// Launch environment variables for UI tests. + static let standardEnvironment: [String: String] = [ + "UITEST_MODE": "1", + "ANIMATIONS_DISABLED": "1" + ] + + /// Configure and launch app with standard test settings. + /// + /// - Parameters: + /// - additionalArguments: Extra launch arguments to append. + /// - additionalEnvironment: Extra environment variables to merge. + /// - Returns: The launched `XCUIApplication` instance. + @discardableResult + static func launchApp( + additionalArguments: [String] = [], + additionalEnvironment: [String: String] = [:] + ) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = standardArguments + additionalArguments + var env = standardEnvironment + additionalEnvironment.forEach { env[$0.key] = $0.value } + app.launchEnvironment = env + app.launch() + return app + } + + /// Launch app pre-authenticated (skips login flow). + /// + /// Passes test credentials via launch arguments and environment so the + /// app can bypass the normal authentication flow during UI tests. + /// + /// - Parameters: + /// - email: Test user email address. + /// - token: Test authentication token. + /// - Returns: The launched `XCUIApplication` instance. + @discardableResult + static func launchAuthenticated( + email: String = "test@example.com", + token: String = "test-token-12345" + ) -> XCUIApplication { + return launchApp( + additionalArguments: ["-TEST_AUTH_EMAIL", email, "-TEST_AUTH_TOKEN", token], + additionalEnvironment: ["TEST_AUTH_EMAIL": email, "TEST_AUTH_TOKEN": token] + ) + } +} diff --git a/iosApp/iosApp/Documents/DocumentDetailView.swift b/iosApp/iosApp/Documents/DocumentDetailView.swift index a46f1b4..26aed50 100644 --- a/iosApp/iosApp/Documents/DocumentDetailView.swift +++ b/iosApp/iosApp/Documents/DocumentDetailView.swift @@ -160,7 +160,17 @@ struct DocumentDetailView: View { } // Determine filename - let filename = document.title.replacingOccurrences(of: " ", with: "_") + "." + (document.fileType ?? "file") + // Extract extension from fileName (e.g., "doc.pdf" -> "pdf") or mimeType (e.g., "application/pdf" -> "pdf") + let ext: String = { + if let fn = document.fileName, let dotIndex = fn.lastIndex(of: ".") { + return String(fn[fn.index(after: dotIndex)...]) + } + if let mime = document.mimeType, let slashIndex = mime.lastIndex(of: "/") { + return String(mime[mime.index(after: slashIndex)...]) + } + return "file" + }() + let filename = document.title.replacingOccurrences(of: " ", with: "_") + "." + ext // Move to a permanent location let documentsPath = FileManager.default.temporaryDirectory @@ -329,14 +339,11 @@ struct DocumentDetailView: View { VStack(alignment: .leading, spacing: 12) { sectionHeader(L10n.Documents.associations) - if let residenceAddress = document.residenceAddress { - detailRow(label: L10n.Documents.residence, value: residenceAddress) + if let residenceId = document.residenceId { + detailRow(label: L10n.Documents.residence, value: "Residence #\(residenceId)") } - if let contractorName = document.contractorName { - detailRow(label: L10n.Documents.contractor, value: contractorName) - } - if let contractorPhone = document.contractorPhone { - detailRow(label: L10n.Documents.contractorPhone, value: contractorPhone) + if let taskId = document.taskId { + detailRow(label: L10n.Documents.contractor, value: "Task #\(taskId)") } } .padding() @@ -367,8 +374,8 @@ struct DocumentDetailView: View { VStack(alignment: .leading, spacing: 12) { sectionHeader(L10n.Documents.attachedFile) - if let fileType = document.fileType { - detailRow(label: L10n.Documents.fileType, value: fileType) + if let mimeType = document.mimeType { + detailRow(label: L10n.Documents.fileType, value: mimeType) } if let fileSize = document.fileSize { detailRow(label: L10n.Documents.fileSize, value: formatFileSize(bytes: Int(fileSize))) @@ -412,8 +419,9 @@ struct DocumentDetailView: View { VStack(alignment: .leading, spacing: 12) { sectionHeader(L10n.Documents.metadata) - if let uploadedBy = document.uploadedByUsername { - detailRow(label: L10n.Documents.uploadedBy, value: uploadedBy) + if let createdBy = document.createdBy { + let name = [createdBy.firstName, createdBy.lastName].filter { !$0.isEmpty }.joined(separator: " ") + detailRow(label: L10n.Documents.uploadedBy, value: name.isEmpty ? createdBy.username : name) } if let createdAt = document.createdAt { detailRow(label: L10n.Documents.created, value: DateUtils.formatDateTime(createdAt)) diff --git a/iosApp/iosApp/Documents/DocumentViewModel.swift b/iosApp/iosApp/Documents/DocumentViewModel.swift index af3361f..1acca5f 100644 --- a/iosApp/iosApp/Documents/DocumentViewModel.swift +++ b/iosApp/iosApp/Documents/DocumentViewModel.swift @@ -308,7 +308,8 @@ class DocumentViewModel: ObservableObject { documentId: documentId, imageBytes: self.kotlinByteArray(from: compressedData), fileName: "document_image_\(index + 1).jpg", - mimeType: "image/jpeg" + mimeType: "image/jpeg", + caption: nil ) } catch { return ErrorMessageParser.parse(error.localizedDescription) @@ -318,7 +319,7 @@ class DocumentViewModel: ObservableObject { return ErrorMessageParser.parse(error.message) } - if !(uploadResult is ApiResultSuccess) { + if !(uploadResult is ApiResultSuccess) { return "Failed to upload image \(index + 1)" } } diff --git a/iosApp/iosApp/Documents/DocumentViewModelWrapper.swift b/iosApp/iosApp/Documents/DocumentViewModelWrapper.swift index f56ed58..b0647ff 100644 --- a/iosApp/iosApp/Documents/DocumentViewModelWrapper.swift +++ b/iosApp/iosApp/Documents/DocumentViewModelWrapper.swift @@ -2,6 +2,27 @@ import Foundation import ComposeApp import SwiftUI +// MARK: - Architecture Note +// +// Two document ViewModels coexist with distinct responsibilities: +// +// DocumentViewModel (DocumentViewModel.swift): +// - Used by list views (DocumentsView, DocumentListView) +// - Observes DataManager via DataManagerObservable for reactive list updates +// - Handles CRUD operations that update DataManager cache (create, update, delete) +// - Supports image upload workflows +// - Uses @MainActor for thread safety +// +// DocumentViewModelWrapper (this file): +// - Used by detail views (DocumentDetailView, EditDocumentView) +// - Manages explicit state types (Loading/Success/Error) for single-document operations +// - Loads individual document detail, handles update and delete with state feedback +// - Does NOT observe DataManager -- loads fresh data per-request via APILayer +// - Uses protocol-based state enums for SwiftUI view branching +// +// Both call through APILayer (which updates DataManager), so list views +// auto-refresh when detail views perform mutations. + // State wrappers for SwiftUI protocol DocumentState {} struct DocumentStateIdle: DocumentState {} @@ -235,18 +256,20 @@ class DocumentViewModelWrapper: ObservableObject { } } - func deleteDocumentImage(imageId: Int32) { + func deleteDocumentImage(documentId: Int32, imageId: Int32) { DispatchQueue.main.async { self.deleteImageState = DeleteImageStateLoading() } Task { do { - let result = try await APILayer.shared.deleteDocumentImage(imageId: imageId) + let result = try await APILayer.shared.deleteDocumentImage(documentId: documentId, imageId: imageId) await MainActor.run { - if result is ApiResultSuccess { + if let success = result as? ApiResultSuccess, let document = success.data { self.deleteImageState = DeleteImageStateSuccess() + // Refresh detail state with updated document (image removed) + self.documentDetailState = DocumentDetailStateSuccess(document: document) } else if let error = ApiResultBridge.error(from: result) { self.deleteImageState = DeleteImageStateError(message: error.message) } else { diff --git a/iosApp/iosApp/Login/LoginView.swift b/iosApp/iosApp/Login/LoginView.swift index ca3a50e..0f56119 100644 --- a/iosApp/iosApp/Login/LoginView.swift +++ b/iosApp/iosApp/Login/LoginView.swift @@ -10,6 +10,7 @@ struct LoginView: View { @State private var showPasswordReset = false @State private var isPasswordVisible = false @State private var activeResetToken: String? + @State private var showGoogleSignInAlert = false @Binding var resetToken: String? var onLoginSuccess: (() -> Void)? @@ -192,6 +193,29 @@ struct LoginView: View { .padding(.top, 8) } + // Google Sign-In Button + // TODO: Replace with full Google Sign-In SDK integration (requires GoogleSignIn pod/SPM package) + Button(action: { + showGoogleSignInAlert = true + }) { + HStack(spacing: 10) { + Image(systemName: "globe") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(Color.appTextPrimary) + Text("Sign in with Google") + .font(.system(size: 17, weight: .medium)) + .foregroundColor(Color.appTextPrimary) + } + .frame(maxWidth: .infinity) + .frame(height: 54) + .background(Color.appBackgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.appTextSecondary.opacity(0.3), lineWidth: 1) + ) + } + // Apple Sign In Error if let appleError = appleSignInViewModel.errorMessage { HStack(spacing: 10) { @@ -303,6 +327,11 @@ struct LoginView: View { activeResetToken = nil } } + .alert("Google Sign-In", isPresented: $showGoogleSignInAlert) { + Button("OK", role: .cancel) { } + } message: { + Text("Google Sign-In coming soon. This feature is under development.") + } } } diff --git a/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift b/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift index 7ae9b6b..93b16cd 100644 --- a/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift @@ -11,6 +11,7 @@ struct OnboardingCreateAccountContent: View { @State private var showingLoginSheet = false @State private var isExpanded = false @State private var isAnimating = false + @State private var showGoogleSignInAlert = false @FocusState private var focusedField: Field? @Environment(\.colorScheme) var colorScheme @@ -139,6 +140,29 @@ struct OnboardingCreateAccountContent: View { if let error = appleSignInViewModel.errorMessage { OrganicErrorMessage(message: error) } + + // Google Sign-In Button + // TODO: Replace with full Google Sign-In SDK integration (requires GoogleSignIn pod/SPM package) + Button(action: { + showGoogleSignInAlert = true + }) { + HStack(spacing: 10) { + Image(systemName: "globe") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(Color.appTextPrimary) + Text("Sign in with Google") + .font(.system(size: 17, weight: .medium)) + .foregroundColor(Color.appTextPrimary) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .background(Color.appBackgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.appTextSecondary.opacity(0.3), lineWidth: 1) + ) + } } // Divider @@ -299,6 +323,11 @@ struct OnboardingCreateAccountContent: View { onAccountCreated(true) }) } + .alert("Google Sign-In", isPresented: $showGoogleSignInAlert) { + Button("OK", role: .cancel) { } + } message: { + Text("Google Sign-In coming soon. This feature is under development.") + } .onChange(of: viewModel.isRegistered) { _, isRegistered in if isRegistered { // Registration successful - user is authenticated but not verified diff --git a/iosApp/iosApp/PushNotifications/PushNotificationManager.swift b/iosApp/iosApp/PushNotifications/PushNotificationManager.swift index e63db28..ceb6b10 100644 --- a/iosApp/iosApp/PushNotifications/PushNotificationManager.swift +++ b/iosApp/iosApp/PushNotifications/PushNotificationManager.swift @@ -518,7 +518,7 @@ class PushNotificationManager: NSObject, ObservableObject { do { let result = try await APILayer.shared.markNotificationAsRead(notificationId: notificationIdInt) - if result is ApiResultSuccess { + if result is ApiResultSuccess { print("✅ Notification marked as read") } else if let error = ApiResultBridge.error(from: result) { print("❌ Failed to mark notification as read: \(error.message)") diff --git a/iosApp/iosApp/Subscription/FeatureComparisonView.swift b/iosApp/iosApp/Subscription/FeatureComparisonView.swift index f2c7376..626a5db 100644 --- a/iosApp/iosApp/Subscription/FeatureComparisonView.swift +++ b/iosApp/iosApp/Subscription/FeatureComparisonView.swift @@ -56,8 +56,8 @@ struct FeatureComparisonView: View { ForEach(subscriptionCache.featureBenefits, id: \.featureName) { benefit in ComparisonRow( featureName: benefit.featureName, - freeText: benefit.freeTier, - proText: benefit.proTier + freeText: benefit.freeTierText, + proText: benefit.proTierText ) Divider() } diff --git a/iosApp/iosApp/Subscription/StoreKitManager.swift b/iosApp/iosApp/Subscription/StoreKitManager.swift index e5d6421..ef56bf4 100644 --- a/iosApp/iosApp/Subscription/StoreKitManager.swift +++ b/iosApp/iosApp/Subscription/StoreKitManager.swift @@ -10,6 +10,8 @@ class StoreKitManager: ObservableObject { // Product IDs can be configured via Info.plist keys: // CASERA_IAP_MONTHLY_PRODUCT_ID / CASERA_IAP_ANNUAL_PRODUCT_ID. // Falls back to local StoreKit config IDs for development. + // Canonical source: SubscriptionProducts in commonMain (Kotlin shared code). + // Keep these in sync with SubscriptionProducts.MONTHLY / SubscriptionProducts.ANNUAL. private let fallbackProductIDs = [ "com.example.casera.pro.monthly", "com.example.casera.pro.annual" diff --git a/iosApp/iosApp/Subscription/SubscriptionCache.swift b/iosApp/iosApp/Subscription/SubscriptionCache.swift index 9268070..e344d2f 100644 --- a/iosApp/iosApp/Subscription/SubscriptionCache.swift +++ b/iosApp/iosApp/Subscription/SubscriptionCache.swift @@ -1,7 +1,11 @@ import SwiftUI import ComposeApp -/// Swift wrapper for accessing Kotlin SubscriptionCache +/// Swift wrapper that reads subscription state from Kotlin DataManager (single source of truth). +/// +/// DataManager is the authoritative subscription state holder. This wrapper +/// observes DataManager's StateFlows (via polling) and publishes changes +/// to SwiftUI views via @Published properties. class SubscriptionCacheWrapper: ObservableObject { static let shared = SubscriptionCacheWrapper() @@ -10,7 +14,8 @@ class SubscriptionCacheWrapper: ObservableObject { @Published var featureBenefits: [FeatureBenefit] = [] @Published var promotions: [Promotion] = [] - /// Current tier resolved from backend status when available, with StoreKit fallback. + /// Current tier derived from backend subscription status, with StoreKit fallback. + /// Mirrors the logic in Kotlin SubscriptionHelper.currentTier. var currentTier: String { // Prefer backend subscription state when available. // `expiresAt` is only expected for active paid plans. @@ -40,9 +45,9 @@ class SubscriptionCacheWrapper: ObservableObject { return false } - // Get the appropriate limits for the current tier from StoreKit + // Get the appropriate limits for the current tier guard let tierLimits = subscription.limits[currentTier] else { - print("⚠️ No limits found for tier: \(currentTier)") + print("No limits found for tier: \(currentTier)") return false } @@ -58,7 +63,7 @@ class SubscriptionCacheWrapper: ObservableObject { case "documents": limit = tierLimits.documents.map { Int(truncating: $0) } default: - print("⚠️ Unknown limit key: \(limitKey)") + print("Unknown limit key: \(limitKey)") return false } @@ -99,69 +104,56 @@ class SubscriptionCacheWrapper: ObservableObject { } private init() { - // Start observation of Kotlin cache + // Start observation of DataManager (single source of truth) Task { @MainActor in - // Initial sync - self.observeSubscriptionStatusSync() - self.observeUpgradeTriggersSync() + // Initial sync from DataManager + self.syncFromDataManager() - // Poll for updates periodically (workaround for Kotlin StateFlow observation) + // Poll DataManager for updates periodically + // (workaround for Kotlin StateFlow observation from Swift) while true { try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds - self.observeSubscriptionStatusSync() - self.observeUpgradeTriggersSync() + self.syncFromDataManager() } } } + /// Sync all subscription state from DataManager (Kotlin single source of truth) @MainActor - private func observeSubscriptionStatus() { - // Update from Kotlin cache - if let subscription = ComposeApp.SubscriptionCache.shared.currentSubscription.value as? SubscriptionStatus { - self.currentSubscription = subscription - print("📊 Subscription Status: currentTier=\(currentTier), limitationsEnabled=\(subscription.limitationsEnabled)") - print(" 📊 Free Tier Limits - Properties: \(subscription.limits["free"]?.properties), Tasks: \(subscription.limits["free"]?.tasks), Contractors: \(subscription.limits["free"]?.contractors), Documents: \(subscription.limits["free"]?.documents)") - print(" 📊 Pro Tier Limits - Properties: \(subscription.limits["pro"]?.properties), Tasks: \(subscription.limits["pro"]?.tasks), Contractors: \(subscription.limits["pro"]?.contractors), Documents: \(subscription.limits["pro"]?.documents)") - } else { - print("⚠️ No subscription status in cache") + private func syncFromDataManager() { + // Read subscription status from DataManager + if let subscription = ComposeApp.DataManager.shared.subscription.value as? SubscriptionStatus { + if self.currentSubscription == nil || self.currentSubscription != subscription { + self.currentSubscription = subscription + syncWidgetSubscriptionStatus(subscription: subscription) + } } - } - @MainActor - private func observeUpgradeTriggers() { - // Update from Kotlin cache - let kotlinTriggers = ComposeApp.SubscriptionCache.shared.upgradeTriggers.value as? [String: UpgradeTriggerData] - if let triggers = kotlinTriggers { + // Read upgrade triggers from DataManager + if let triggers = ComposeApp.DataManager.shared.upgradeTriggers.value as? [String: UpgradeTriggerData] { self.upgradeTriggers = triggers } + + // Read feature benefits from DataManager + if let benefits = ComposeApp.DataManager.shared.featureBenefits.value as? [FeatureBenefit] { + self.featureBenefits = benefits + } + + // Read promotions from DataManager + if let promos = ComposeApp.DataManager.shared.promotions.value as? [Promotion] { + self.promotions = promos + } } func refreshFromCache() { Task { @MainActor in - observeSubscriptionStatusSync() - observeUpgradeTriggersSync() - } - } - - @MainActor - private func observeSubscriptionStatusSync() { - if let subscription = ComposeApp.SubscriptionCache.shared.currentSubscription.value as? SubscriptionStatus { - self.currentSubscription = subscription - // Sync subscription status with widget - syncWidgetSubscriptionStatus(subscription: subscription) - } - } - - @MainActor - private func observeUpgradeTriggersSync() { - let kotlinTriggers = ComposeApp.SubscriptionCache.shared.upgradeTriggers.value as? [String: UpgradeTriggerData] - if let triggers = kotlinTriggers { - self.upgradeTriggers = triggers + syncFromDataManager() } } func updateSubscription(_ subscription: SubscriptionStatus) { - ComposeApp.SubscriptionCache.shared.updateSubscriptionStatus(subscription: subscription) + // Write to DataManager (single source of truth) + ComposeApp.DataManager.shared.setSubscription(subscription: subscription) DispatchQueue.main.async { self.currentSubscription = subscription // Sync subscription status with widget @@ -178,9 +170,13 @@ class SubscriptionCacheWrapper: ObservableObject { isPremium: isPremium ) } - + func clear() { - ComposeApp.SubscriptionCache.shared.clear() + // Clear via DataManager (single source of truth) + ComposeApp.DataManager.shared.setSubscription(subscription: nil) + ComposeApp.DataManager.shared.setUpgradeTriggers(triggers: [:]) + ComposeApp.DataManager.shared.setFeatureBenefits(benefits: []) + ComposeApp.DataManager.shared.setPromotions(promos: []) DispatchQueue.main.async { self.currentSubscription = nil self.upgradeTriggers = [:]