Add haptic feedback, rich task completion, and Google Sign-In preparation
- Add platform haptic feedback abstraction (HapticFeedback.kt) with implementations for Android, iOS, JVM, JS, and WASM - Enhance CompleteTaskDialog with interactive 5-star rating, image thumbnails, and haptic feedback - Add ImageBitmap platform abstraction for displaying selected images - Localize TaskTemplatesBrowserSheet with string resources - Add Android widgets infrastructure (small, medium, large sizes) - Add Google Sign-In button components and auth flow preparation - Update strings.xml with new localization keys for completions, templates, and document features - Integrate haptic feedback into ThemePickerDialog 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
146
TODO-string-localization.md
Normal file
146
TODO-string-localization.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# String Localization Migration TODO
|
||||
|
||||
This file tracks the remaining work to migrate hardcoded strings to Compose Resources (`composeApp/src/commonMain/composeResources/values/strings.xml`).
|
||||
|
||||
## Completed ✅
|
||||
|
||||
### High Priority Files (Done)
|
||||
- [x] `DocumentFormScreen.kt` - 48 strings migrated
|
||||
- [x] `AddTaskDialog.kt` - 28 strings migrated
|
||||
|
||||
## Remaining Work
|
||||
|
||||
### Priority 1: Dialogs with Many Strings
|
||||
|
||||
#### AddContractorDialog.kt (~25 strings)
|
||||
Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddContractorDialog.kt`
|
||||
|
||||
Strings to migrate:
|
||||
- Dialog title: "Add Contractor"
|
||||
- Field labels: Name *, Company, Phone, Email, Specialty, Notes, Website, Address
|
||||
- Validation errors: "Name is required"
|
||||
- Buttons: "Create", "Cancel"
|
||||
|
||||
#### CompleteTaskDialog.kt (~22 strings)
|
||||
Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/CompleteTaskDialog.kt`
|
||||
|
||||
Strings to migrate:
|
||||
- Dialog title: "Complete Task"
|
||||
- Field labels: Notes, Actual Cost, Completion Date
|
||||
- Photo section: "Photos", "Camera", "Gallery", "Remove"
|
||||
- Buttons: "Complete", "Cancel"
|
||||
- Validation messages
|
||||
|
||||
### Priority 2: Import/Share Dialogs (~14 strings)
|
||||
|
||||
#### ContractorImportDialog.kt (~7 strings)
|
||||
Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ContractorImportDialog.kt`
|
||||
|
||||
#### ResidenceImportDialog.kt (~7 strings)
|
||||
Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ResidenceImportDialog.kt`
|
||||
|
||||
### Priority 3: Task Components (~14 strings)
|
||||
|
||||
#### TaskActionButtons.kt (~7 strings)
|
||||
Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskActionButtons.kt`
|
||||
|
||||
#### TaskCard.kt (~7 strings)
|
||||
Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskCard.kt`
|
||||
|
||||
### Priority 4: Other Dialogs (~10 strings)
|
||||
|
||||
#### JoinResidenceDialog.kt (~7 strings)
|
||||
Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/JoinResidenceDialog.kt`
|
||||
|
||||
#### ManageUsersDialog.kt (~2 strings)
|
||||
Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/ManageUsersDialog.kt`
|
||||
|
||||
#### TaskTemplatesBrowserSheet.kt (~3 strings)
|
||||
Location: `composeApp/src/commonMain/kotlin/com/example/casera/ui/components/TaskTemplatesBrowserSheet.kt`
|
||||
|
||||
### Priority 5: Smaller Components (~15 strings total)
|
||||
|
||||
Files with 1-3 hardcoded strings each:
|
||||
- `InfoCard.kt`
|
||||
- `FeatureComparisonDialog.kt`
|
||||
- `ThemePickerDialog.kt`
|
||||
- `StandardCard.kt`
|
||||
- `CompactCard.kt`
|
||||
- `ApiResultHandler.kt`
|
||||
- `DocumentCard.kt`
|
||||
- `DocumentStates.kt`
|
||||
- `CompletionHistorySheet.kt`
|
||||
- `DocumentDetailScreen.kt`
|
||||
- `EditTaskScreen.kt`
|
||||
- `MainScreen.kt`
|
||||
- `UpgradePromptDialog.kt`
|
||||
- `VerifyEmailScreen.kt`
|
||||
- `VerifyResetCodeScreen.kt`
|
||||
- `UpgradeFeatureScreen.kt`
|
||||
- `ResidenceFormScreen.kt`
|
||||
|
||||
## How to Migrate Strings
|
||||
|
||||
### 1. Add import to the file:
|
||||
```kotlin
|
||||
import casera.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
```
|
||||
|
||||
### 2. Add string to strings.xml:
|
||||
```xml
|
||||
<string name="component_field_label">Field Label</string>
|
||||
```
|
||||
|
||||
### 3. Replace hardcoded string:
|
||||
```kotlin
|
||||
// Before
|
||||
Text("Field Label")
|
||||
|
||||
// After
|
||||
Text(stringResource(Res.string.component_field_label))
|
||||
```
|
||||
|
||||
### 4. For strings with parameters:
|
||||
```xml
|
||||
<string name="items_count">%1$d items</string>
|
||||
```
|
||||
```kotlin
|
||||
Text(stringResource(Res.string.items_count, count))
|
||||
```
|
||||
|
||||
### 5. For strings used in onClick handlers:
|
||||
Define outside the lambda (stringResource is @Composable):
|
||||
```kotlin
|
||||
val errorMessage = stringResource(Res.string.error_message)
|
||||
|
||||
Button(onClick = {
|
||||
// Use errorMessage here
|
||||
showError = errorMessage
|
||||
})
|
||||
```
|
||||
|
||||
## Naming Convention
|
||||
|
||||
Use this pattern for string names:
|
||||
- `{component}_{field}` - e.g., `contractor_name_label`
|
||||
- `{component}_{action}` - e.g., `contractor_create`
|
||||
- `{component}_{error}` - e.g., `contractor_name_error`
|
||||
|
||||
Existing prefixes in strings.xml:
|
||||
- `auth_` - Authentication screens
|
||||
- `properties_` - Residence/property screens
|
||||
- `tasks_` - Task screens and components
|
||||
- `contractors_` - Contractor screens
|
||||
- `documents_` - Document screens
|
||||
- `profile_` - Profile screens
|
||||
- `common_` - Shared strings (Cancel, OK, Back, etc.)
|
||||
|
||||
## Testing
|
||||
|
||||
After migrating strings, run:
|
||||
```bash
|
||||
./gradlew :composeApp:compileDebugKotlinAndroid
|
||||
```
|
||||
|
||||
Build should complete successfully with only deprecation warnings.
|
||||
@@ -56,6 +56,18 @@ kotlin {
|
||||
|
||||
// PostHog Analytics
|
||||
implementation("com.posthog:posthog-android:3.8.2")
|
||||
|
||||
// Google Sign In - Credential Manager
|
||||
implementation("androidx.credentials:credentials:1.3.0")
|
||||
implementation("androidx.credentials:credentials-play-services-auth:1.3.0")
|
||||
implementation("com.google.android.libraries.identity.googleid:googleid:1.1.1")
|
||||
|
||||
// Jetpack Glance for Home Screen Widgets
|
||||
implementation("androidx.glance:glance-appwidget:1.1.1")
|
||||
implementation("androidx.glance:glance-material3:1.1.1")
|
||||
|
||||
// DataStore for widget data persistence
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||
}
|
||||
iosMain.dependencies {
|
||||
implementation(libs.ktor.client.darwin)
|
||||
|
||||
@@ -100,6 +100,54 @@
|
||||
<action android:name="com.example.casera.ACTION_UNCANCEL_TASK" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Widget Task Complete Receiver -->
|
||||
<receiver
|
||||
android:name=".widget.WidgetTaskActionReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.example.casera.COMPLETE_TASK" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Small Widget Receiver (2x1) -->
|
||||
<receiver
|
||||
android:name=".widget.CaseraSmallWidgetReceiver"
|
||||
android:exported="true"
|
||||
android:label="@string/widget_small_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/casera_small_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- Medium Widget Receiver (4x2) -->
|
||||
<receiver
|
||||
android:name=".widget.CaseraMediumWidgetReceiver"
|
||||
android:exported="true"
|
||||
android:label="@string/widget_medium_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/casera_medium_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- Large Widget Receiver (4x4) -->
|
||||
<receiver
|
||||
android:name=".widget.CaseraLargeWidgetReceiver"
|
||||
android:exported="true"
|
||||
android:label="@string/widget_large_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/casera_large_widget_info" />
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.example.casera.auth
|
||||
|
||||
import android.content.Context
|
||||
import androidx.credentials.CredentialManager
|
||||
import androidx.credentials.CustomCredential
|
||||
import androidx.credentials.GetCredentialRequest
|
||||
import androidx.credentials.GetCredentialResponse
|
||||
import androidx.credentials.exceptions.GetCredentialException
|
||||
import com.example.casera.network.ApiConfig
|
||||
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
|
||||
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
||||
|
||||
/**
|
||||
* Result of a Google Sign In attempt
|
||||
*/
|
||||
sealed class GoogleSignInResult {
|
||||
data class Success(val idToken: String) : GoogleSignInResult()
|
||||
data class Error(val message: String, val exception: Exception? = null) : GoogleSignInResult()
|
||||
object Cancelled : GoogleSignInResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Manager for Google Sign In using Android Credential Manager
|
||||
*/
|
||||
class GoogleSignInManager(private val context: Context) {
|
||||
|
||||
private val credentialManager = CredentialManager.create(context)
|
||||
|
||||
/**
|
||||
* Initiates Google Sign In flow and returns the ID token
|
||||
*/
|
||||
suspend fun signIn(): GoogleSignInResult {
|
||||
return try {
|
||||
val googleIdOption = GetGoogleIdOption.Builder()
|
||||
.setFilterByAuthorizedAccounts(false)
|
||||
.setServerClientId(ApiConfig.GOOGLE_WEB_CLIENT_ID)
|
||||
.setAutoSelectEnabled(true)
|
||||
.build()
|
||||
|
||||
val request = GetCredentialRequest.Builder()
|
||||
.addCredentialOption(googleIdOption)
|
||||
.build()
|
||||
|
||||
val result = credentialManager.getCredential(
|
||||
request = request,
|
||||
context = context
|
||||
)
|
||||
|
||||
handleSignInResult(result)
|
||||
} catch (e: GetCredentialException) {
|
||||
when {
|
||||
e.message?.contains("cancelled", ignoreCase = true) == true ||
|
||||
e.message?.contains("user cancelled", ignoreCase = true) == true -> {
|
||||
GoogleSignInResult.Cancelled
|
||||
}
|
||||
else -> {
|
||||
GoogleSignInResult.Error("Sign in failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
GoogleSignInResult.Error("Unexpected error during sign in: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSignInResult(result: GetCredentialResponse): GoogleSignInResult {
|
||||
val credential = result.credential
|
||||
|
||||
return when (credential) {
|
||||
is CustomCredential -> {
|
||||
if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
|
||||
try {
|
||||
val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data)
|
||||
val idToken = googleIdTokenCredential.idToken
|
||||
|
||||
if (idToken.isNotEmpty()) {
|
||||
GoogleSignInResult.Success(idToken)
|
||||
} else {
|
||||
GoogleSignInResult.Error("Empty ID token received")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
GoogleSignInResult.Error("Failed to parse Google credential: ${e.message}", e)
|
||||
}
|
||||
} else {
|
||||
GoogleSignInResult.Error("Unexpected credential type: ${credential.type}")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
GoogleSignInResult.Error("Unexpected credential type")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.example.casera.platform
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.View
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
|
||||
/**
|
||||
* Android implementation of haptic feedback using system vibrator.
|
||||
*/
|
||||
class AndroidHapticFeedbackPerformer(
|
||||
private val view: View,
|
||||
private val vibrator: Vibrator?
|
||||
) : HapticFeedbackPerformer {
|
||||
|
||||
override fun perform(type: HapticFeedbackType) {
|
||||
// First try View-based haptic feedback (works best)
|
||||
val hapticConstant = when (type) {
|
||||
HapticFeedbackType.Light -> HapticFeedbackConstants.KEYBOARD_TAP
|
||||
HapticFeedbackType.Medium -> HapticFeedbackConstants.CONTEXT_CLICK
|
||||
HapticFeedbackType.Heavy -> HapticFeedbackConstants.LONG_PRESS
|
||||
HapticFeedbackType.Selection -> HapticFeedbackConstants.CLOCK_TICK
|
||||
HapticFeedbackType.Success -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
HapticFeedbackConstants.CONFIRM
|
||||
} else {
|
||||
HapticFeedbackConstants.CONTEXT_CLICK
|
||||
}
|
||||
HapticFeedbackType.Warning -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
HapticFeedbackConstants.REJECT
|
||||
} else {
|
||||
HapticFeedbackConstants.LONG_PRESS
|
||||
}
|
||||
HapticFeedbackType.Error -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
HapticFeedbackConstants.REJECT
|
||||
} else {
|
||||
HapticFeedbackConstants.LONG_PRESS
|
||||
}
|
||||
}
|
||||
|
||||
val success = view.performHapticFeedback(hapticConstant)
|
||||
|
||||
// Fallback to vibrator if view-based feedback fails
|
||||
if (!success && vibrator?.hasVibrator() == true) {
|
||||
performVibration(type)
|
||||
}
|
||||
}
|
||||
|
||||
private fun performVibration(type: HapticFeedbackType) {
|
||||
vibrator ?: return
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val effect = when (type) {
|
||||
HapticFeedbackType.Light -> VibrationEffect.createOneShot(10, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
HapticFeedbackType.Medium -> VibrationEffect.createOneShot(20, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
HapticFeedbackType.Heavy -> VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
HapticFeedbackType.Selection -> VibrationEffect.createOneShot(5, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
HapticFeedbackType.Success -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)
|
||||
} else {
|
||||
VibrationEffect.createOneShot(30, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
}
|
||||
HapticFeedbackType.Warning -> VibrationEffect.createOneShot(40, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
HapticFeedbackType.Error -> VibrationEffect.createOneShot(60, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
}
|
||||
vibrator.vibrate(effect)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val duration = when (type) {
|
||||
HapticFeedbackType.Light -> 10L
|
||||
HapticFeedbackType.Medium -> 20L
|
||||
HapticFeedbackType.Heavy -> 50L
|
||||
HapticFeedbackType.Selection -> 5L
|
||||
HapticFeedbackType.Success -> 30L
|
||||
HapticFeedbackType.Warning -> 40L
|
||||
HapticFeedbackType.Error -> 60L
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberHapticFeedback(): HapticFeedbackPerformer {
|
||||
val context = LocalContext.current
|
||||
val view = LocalView.current
|
||||
|
||||
return remember {
|
||||
val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
|
||||
vibratorManager?.defaultVibrator
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
}
|
||||
AndroidHapticFeedbackPerformer(view, vibrator)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.example.casera.platform
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
|
||||
@Composable
|
||||
actual fun rememberImageBitmap(imageData: ImageData): ImageBitmap? {
|
||||
return remember(imageData) {
|
||||
try {
|
||||
val bitmap = BitmapFactory.decodeByteArray(imageData.bytes, 0, imageData.bytes.size)
|
||||
bitmap?.asImageBitmap()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.example.casera.ui.components.auth
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.casera.auth.GoogleSignInManager
|
||||
import com.example.casera.auth.GoogleSignInResult
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
actual fun GoogleSignInButton(
|
||||
onSignInStarted: () -> Unit,
|
||||
onSignInSuccess: (idToken: String) -> Unit,
|
||||
onSignInError: (message: String) -> Unit,
|
||||
enabled: Boolean
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
|
||||
val googleSignInManager = remember { GoogleSignInManager(context) }
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (!isLoading && enabled) {
|
||||
isLoading = true
|
||||
onSignInStarted()
|
||||
|
||||
scope.launch {
|
||||
when (val result = googleSignInManager.signIn()) {
|
||||
is GoogleSignInResult.Success -> {
|
||||
isLoading = false
|
||||
onSignInSuccess(result.idToken)
|
||||
}
|
||||
is GoogleSignInResult.Error -> {
|
||||
isLoading = false
|
||||
onSignInError(result.message)
|
||||
}
|
||||
GoogleSignInResult.Cancelled -> {
|
||||
isLoading = false
|
||||
// User cancelled, no error needed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
enabled = enabled && !isLoading,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
// Google "G" logo using Material colors
|
||||
Box(
|
||||
modifier = Modifier.size(24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "G",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFF4285F4) // Google Blue
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Text(
|
||||
text = "Continue with Google",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package com.example.casera.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.actionParametersOf
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.state.GlanceStateDefinition
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Large widget showing task list with stats and interactive actions (Pro only)
|
||||
* Size: 4x4
|
||||
*/
|
||||
class CaseraLargeWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
provideContent {
|
||||
GlanceTheme {
|
||||
LargeWidgetContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LargeWidgetContent() {
|
||||
val prefs = currentState<Preferences>()
|
||||
val overdueCount = prefs[intPreferencesKey("overdue_count")] ?: 0
|
||||
val dueSoonCount = prefs[intPreferencesKey("due_soon_count")] ?: 0
|
||||
val inProgressCount = prefs[intPreferencesKey("in_progress_count")] ?: 0
|
||||
val totalCount = prefs[intPreferencesKey("total_tasks_count")] ?: 0
|
||||
val tasksJson = prefs[stringPreferencesKey("tasks_json")] ?: "[]"
|
||||
val isProUser = prefs[stringPreferencesKey("is_pro_user")] == "true"
|
||||
|
||||
val tasks = try {
|
||||
json.decodeFromString<List<WidgetTask>>(tasksJson).take(8)
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFFFF8E7)) // Cream background
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = GlanceModifier.fillMaxSize()
|
||||
) {
|
||||
// Header with logo
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.clickable(actionRunCallback<OpenAppAction>()),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Casera",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF07A0C3)),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = GlanceModifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Tasks",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF666666)),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = GlanceModifier.height(12.dp))
|
||||
|
||||
// Stats row
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
StatBox(
|
||||
count = overdueCount,
|
||||
label = "Overdue",
|
||||
color = Color(0xFFDD1C1A),
|
||||
bgColor = Color(0xFFFFEBEB)
|
||||
)
|
||||
|
||||
Spacer(modifier = GlanceModifier.width(8.dp))
|
||||
|
||||
StatBox(
|
||||
count = dueSoonCount,
|
||||
label = "Due Soon",
|
||||
color = Color(0xFFF5A623),
|
||||
bgColor = Color(0xFFFFF4E0)
|
||||
)
|
||||
|
||||
Spacer(modifier = GlanceModifier.width(8.dp))
|
||||
|
||||
StatBox(
|
||||
count = inProgressCount,
|
||||
label = "Active",
|
||||
color = Color(0xFF07A0C3),
|
||||
bgColor = Color(0xFFE0F4F8)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = GlanceModifier.height(12.dp))
|
||||
|
||||
// Divider
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(Color(0xFFE0E0E0))
|
||||
) {}
|
||||
|
||||
Spacer(modifier = GlanceModifier.height(8.dp))
|
||||
|
||||
// Task list
|
||||
if (tasks.isEmpty()) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.clickable(actionRunCallback<OpenAppAction>()),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "All caught up!",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF07A0C3)),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "No tasks need attention",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF888888)),
|
||||
fontSize = 12.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = GlanceModifier.fillMaxSize()
|
||||
) {
|
||||
items(tasks) { task ->
|
||||
InteractiveTaskItem(
|
||||
task = task,
|
||||
isProUser = isProUser
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatBox(count: Int, label: String, color: Color, bgColor: Color) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.background(bgColor)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.cornerRadius(8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = count.toString(),
|
||||
style = TextStyle(
|
||||
color = ColorProvider(color),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
color = ColorProvider(color),
|
||||
fontSize = 10.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InteractiveTaskItem(task: WidgetTask, isProUser: Boolean) {
|
||||
val taskIdKey = ActionParameters.Key<Int>("task_id")
|
||||
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp)
|
||||
.clickable(
|
||||
actionRunCallback<OpenTaskAction>(
|
||||
actionParametersOf(taskIdKey to task.id)
|
||||
)
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Priority indicator
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(4.dp)
|
||||
.height(40.dp)
|
||||
.background(getPriorityColor(task.priorityLevel))
|
||||
) {}
|
||||
|
||||
Spacer(modifier = GlanceModifier.width(8.dp))
|
||||
|
||||
// Task details
|
||||
Column(
|
||||
modifier = GlanceModifier.defaultWeight()
|
||||
) {
|
||||
Text(
|
||||
text = task.title,
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF1A1A1A)),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
),
|
||||
maxLines = 1
|
||||
)
|
||||
Row {
|
||||
Text(
|
||||
text = task.residenceName,
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF666666)),
|
||||
fontSize = 11.sp
|
||||
),
|
||||
maxLines = 1
|
||||
)
|
||||
if (task.dueDate != null) {
|
||||
Text(
|
||||
text = " • ${task.dueDate}",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(
|
||||
if (task.isOverdue) Color(0xFFDD1C1A) else Color(0xFF666666)
|
||||
),
|
||||
fontSize = 11.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Action button (Pro only)
|
||||
if (isProUser) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.size(32.dp)
|
||||
.background(Color(0xFF07A0C3))
|
||||
.cornerRadius(16.dp)
|
||||
.clickable(
|
||||
actionRunCallback<CompleteTaskAction>(
|
||||
actionParametersOf(taskIdKey to task.id)
|
||||
)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "✓",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color.White),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPriorityColor(level: Int): Color {
|
||||
return when (level) {
|
||||
4 -> Color(0xFFDD1C1A) // Urgent - Red
|
||||
3 -> Color(0xFFF5A623) // High - Amber
|
||||
2 -> Color(0xFF07A0C3) // Medium - Primary
|
||||
else -> Color(0xFF888888) // Low - Gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to complete a task from the widget (Pro only)
|
||||
*/
|
||||
class CompleteTaskAction : ActionCallback {
|
||||
override suspend fun onAction(
|
||||
context: Context,
|
||||
glanceId: GlanceId,
|
||||
parameters: ActionParameters
|
||||
) {
|
||||
val taskId = parameters[ActionParameters.Key<Int>("task_id")] ?: return
|
||||
|
||||
// Send broadcast to app to complete the task
|
||||
val intent = Intent("com.example.casera.COMPLETE_TASK").apply {
|
||||
putExtra("task_id", taskId)
|
||||
setPackage(context.packageName)
|
||||
}
|
||||
context.sendBroadcast(intent)
|
||||
|
||||
// Update widget after action
|
||||
withContext(Dispatchers.Main) {
|
||||
CaseraLargeWidget().update(context, glanceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver for the large widget
|
||||
*/
|
||||
class CaseraLargeWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = CaseraLargeWidget()
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package com.example.casera.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.actionParametersOf
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.state.GlanceStateDefinition
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Medium widget showing a list of upcoming tasks
|
||||
* Size: 4x2
|
||||
*/
|
||||
class CaseraMediumWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
provideContent {
|
||||
GlanceTheme {
|
||||
MediumWidgetContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediumWidgetContent() {
|
||||
val prefs = currentState<Preferences>()
|
||||
val overdueCount = prefs[intPreferencesKey("overdue_count")] ?: 0
|
||||
val dueSoonCount = prefs[intPreferencesKey("due_soon_count")] ?: 0
|
||||
val tasksJson = prefs[stringPreferencesKey("tasks_json")] ?: "[]"
|
||||
|
||||
val tasks = try {
|
||||
json.decodeFromString<List<WidgetTask>>(tasksJson).take(5)
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFFFF8E7)) // Cream background
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = GlanceModifier.fillMaxSize()
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.clickable(actionRunCallback<OpenAppAction>()),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Casera",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF07A0C3)),
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = GlanceModifier.width(8.dp))
|
||||
|
||||
// Badge for overdue
|
||||
if (overdueCount > 0) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.background(Color(0xFFDD1C1A))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "$overdueCount overdue",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color.White),
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = GlanceModifier.height(8.dp))
|
||||
|
||||
// Task list
|
||||
if (tasks.isEmpty()) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.clickable(actionRunCallback<OpenAppAction>()),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "No upcoming tasks",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF888888)),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = GlanceModifier.fillMaxSize()
|
||||
) {
|
||||
items(tasks) { task ->
|
||||
TaskListItem(task = task)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskListItem(task: WidgetTask) {
|
||||
val taskIdKey = ActionParameters.Key<Int>("task_id")
|
||||
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.clickable(
|
||||
actionRunCallback<OpenTaskAction>(
|
||||
actionParametersOf(taskIdKey to task.id)
|
||||
)
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Priority indicator
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(4.dp)
|
||||
.height(32.dp)
|
||||
.background(getPriorityColor(task.priorityLevel))
|
||||
) {}
|
||||
|
||||
Spacer(modifier = GlanceModifier.width(8.dp))
|
||||
|
||||
Column(
|
||||
modifier = GlanceModifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = task.title,
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF1A1A1A)),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
),
|
||||
maxLines = 1
|
||||
)
|
||||
Row {
|
||||
Text(
|
||||
text = task.residenceName,
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF666666)),
|
||||
fontSize = 11.sp
|
||||
),
|
||||
maxLines = 1
|
||||
)
|
||||
if (task.dueDate != null) {
|
||||
Text(
|
||||
text = " • ${task.dueDate}",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(
|
||||
if (task.isOverdue) Color(0xFFDD1C1A) else Color(0xFF666666)
|
||||
),
|
||||
fontSize = 11.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPriorityColor(level: Int): Color {
|
||||
return when (level) {
|
||||
4 -> Color(0xFFDD1C1A) // Urgent - Red
|
||||
3 -> Color(0xFFF5A623) // High - Amber
|
||||
2 -> Color(0xFF07A0C3) // Medium - Primary
|
||||
else -> Color(0xFF888888) // Low - Gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to open a specific task
|
||||
*/
|
||||
class OpenTaskAction : ActionCallback {
|
||||
override suspend fun onAction(
|
||||
context: Context,
|
||||
glanceId: GlanceId,
|
||||
parameters: ActionParameters
|
||||
) {
|
||||
val taskId = parameters[ActionParameters.Key<Int>("task_id")]
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
intent?.let {
|
||||
it.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
if (taskId != null) {
|
||||
it.putExtra("navigate_to_task", taskId)
|
||||
}
|
||||
context.startActivity(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver for the medium widget
|
||||
*/
|
||||
class CaseraMediumWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = CaseraMediumWidget()
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.example.casera.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.state.GlanceStateDefinition
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import com.example.casera.R
|
||||
|
||||
/**
|
||||
* Small widget showing task count summary
|
||||
* Size: 2x1 or 2x2
|
||||
*/
|
||||
class CaseraSmallWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
provideContent {
|
||||
GlanceTheme {
|
||||
SmallWidgetContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SmallWidgetContent() {
|
||||
val prefs = currentState<Preferences>()
|
||||
val overdueCount = prefs[intPreferencesKey("overdue_count")] ?: 0
|
||||
val dueSoonCount = prefs[intPreferencesKey("due_soon_count")] ?: 0
|
||||
val inProgressCount = prefs[intPreferencesKey("in_progress_count")] ?: 0
|
||||
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFFFF8E7)) // Cream background
|
||||
.clickable(actionRunCallback<OpenAppAction>())
|
||||
.padding(12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// App name/logo
|
||||
Text(
|
||||
text = "Casera",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF07A0C3)),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = GlanceModifier.height(8.dp))
|
||||
|
||||
// Task counts row
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Overdue
|
||||
TaskCountItem(
|
||||
count = overdueCount,
|
||||
label = "Overdue",
|
||||
color = Color(0xFFDD1C1A) // Red
|
||||
)
|
||||
|
||||
Spacer(modifier = GlanceModifier.width(16.dp))
|
||||
|
||||
// Due Soon
|
||||
TaskCountItem(
|
||||
count = dueSoonCount,
|
||||
label = "Due Soon",
|
||||
color = Color(0xFFF5A623) // Amber
|
||||
)
|
||||
|
||||
Spacer(modifier = GlanceModifier.width(16.dp))
|
||||
|
||||
// In Progress
|
||||
TaskCountItem(
|
||||
count = inProgressCount,
|
||||
label = "Active",
|
||||
color = Color(0xFF07A0C3) // Primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskCountItem(count: Int, label: String, color: Color) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = count.toString(),
|
||||
style = TextStyle(
|
||||
color = ColorProvider(color),
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF666666)),
|
||||
fontSize = 10.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to open the main app
|
||||
*/
|
||||
class OpenAppAction : ActionCallback {
|
||||
override suspend fun onAction(
|
||||
context: Context,
|
||||
glanceId: GlanceId,
|
||||
parameters: ActionParameters
|
||||
) {
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
intent?.let {
|
||||
it.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
context.startActivity(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver for the small widget
|
||||
*/
|
||||
class CaseraSmallWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = CaseraSmallWidget()
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.example.casera.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
// DataStore instance
|
||||
private val Context.widgetDataStore: DataStore<Preferences> by preferencesDataStore(name = "widget_data")
|
||||
|
||||
/**
|
||||
* Data class representing a task for the widget
|
||||
*/
|
||||
@Serializable
|
||||
data class WidgetTask(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val residenceName: String,
|
||||
val dueDate: String?,
|
||||
val isOverdue: Boolean,
|
||||
val categoryName: String,
|
||||
val priorityLevel: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Data class representing widget summary data
|
||||
*/
|
||||
@Serializable
|
||||
data class WidgetSummary(
|
||||
val overdueCount: Int = 0,
|
||||
val dueSoonCount: Int = 0,
|
||||
val inProgressCount: Int = 0,
|
||||
val totalTasksCount: Int = 0,
|
||||
val tasks: List<WidgetTask> = emptyList(),
|
||||
val lastUpdated: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
/**
|
||||
* Repository for managing widget data persistence
|
||||
*/
|
||||
class WidgetDataRepository(private val context: Context) {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
companion object {
|
||||
private val OVERDUE_COUNT = intPreferencesKey("overdue_count")
|
||||
private val DUE_SOON_COUNT = intPreferencesKey("due_soon_count")
|
||||
private val IN_PROGRESS_COUNT = intPreferencesKey("in_progress_count")
|
||||
private val TOTAL_TASKS_COUNT = intPreferencesKey("total_tasks_count")
|
||||
private val TASKS_JSON = stringPreferencesKey("tasks_json")
|
||||
private val LAST_UPDATED = longPreferencesKey("last_updated")
|
||||
private val IS_PRO_USER = stringPreferencesKey("is_pro_user")
|
||||
private val USER_NAME = stringPreferencesKey("user_name")
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: WidgetDataRepository? = null
|
||||
|
||||
fun getInstance(context: Context): WidgetDataRepository {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: WidgetDataRepository(context.applicationContext).also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the widget summary as a Flow
|
||||
*/
|
||||
val widgetSummary: Flow<WidgetSummary> = context.widgetDataStore.data.map { preferences ->
|
||||
val tasksJson = preferences[TASKS_JSON] ?: "[]"
|
||||
val tasks = try {
|
||||
json.decodeFromString<List<WidgetTask>>(tasksJson)
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
WidgetSummary(
|
||||
overdueCount = preferences[OVERDUE_COUNT] ?: 0,
|
||||
dueSoonCount = preferences[DUE_SOON_COUNT] ?: 0,
|
||||
inProgressCount = preferences[IN_PROGRESS_COUNT] ?: 0,
|
||||
totalTasksCount = preferences[TOTAL_TASKS_COUNT] ?: 0,
|
||||
tasks = tasks,
|
||||
lastUpdated = preferences[LAST_UPDATED] ?: 0L
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is a Pro subscriber
|
||||
*/
|
||||
val isProUser: Flow<Boolean> = context.widgetDataStore.data.map { preferences ->
|
||||
preferences[IS_PRO_USER] == "true"
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's display name
|
||||
*/
|
||||
val userName: Flow<String> = context.widgetDataStore.data.map { preferences ->
|
||||
preferences[USER_NAME] ?: ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the widget data
|
||||
*/
|
||||
suspend fun updateWidgetData(summary: WidgetSummary) {
|
||||
context.widgetDataStore.edit { preferences ->
|
||||
preferences[OVERDUE_COUNT] = summary.overdueCount
|
||||
preferences[DUE_SOON_COUNT] = summary.dueSoonCount
|
||||
preferences[IN_PROGRESS_COUNT] = summary.inProgressCount
|
||||
preferences[TOTAL_TASKS_COUNT] = summary.totalTasksCount
|
||||
preferences[TASKS_JSON] = json.encodeToString(summary.tasks)
|
||||
preferences[LAST_UPDATED] = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user subscription status
|
||||
*/
|
||||
suspend fun updateProStatus(isPro: Boolean) {
|
||||
context.widgetDataStore.edit { preferences ->
|
||||
preferences[IS_PRO_USER] = if (isPro) "true" else "false"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user name
|
||||
*/
|
||||
suspend fun updateUserName(name: String) {
|
||||
context.widgetDataStore.edit { preferences ->
|
||||
preferences[USER_NAME] = name
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all widget data (called on logout)
|
||||
*/
|
||||
suspend fun clearData() {
|
||||
context.widgetDataStore.edit { preferences ->
|
||||
preferences.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.example.casera.widget
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.example.casera.data.DataManager
|
||||
import com.example.casera.models.TaskCompletionCreateRequest
|
||||
import com.example.casera.network.APILayer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* BroadcastReceiver for handling task actions from widgets
|
||||
*/
|
||||
class WidgetTaskActionReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
"com.example.casera.COMPLETE_TASK" -> {
|
||||
val taskId = intent.getIntExtra("task_id", -1)
|
||||
if (taskId != -1) {
|
||||
completeTask(context, taskId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun completeTask(context: Context, taskId: Int) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
// Check if user is authenticated
|
||||
val token = DataManager.authToken.value
|
||||
if (token.isNullOrEmpty()) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Create completion request
|
||||
val request = TaskCompletionCreateRequest(
|
||||
taskId = taskId,
|
||||
notes = "Completed from widget"
|
||||
)
|
||||
|
||||
// Complete the task via API
|
||||
val result = APILayer.createTaskCompletion(request)
|
||||
|
||||
// Update widgets after completion
|
||||
if (result is com.example.casera.network.ApiResult.Success) {
|
||||
WidgetUpdateManager.updateAllWidgets(context)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.example.casera.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Manager for updating all widgets with new data
|
||||
*/
|
||||
object WidgetUpdateManager {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* Update all Casera widgets with new data
|
||||
*/
|
||||
fun updateAllWidgets(context: Context) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val repository = WidgetDataRepository.getInstance(context)
|
||||
val summary = repository.widgetSummary.first()
|
||||
val isProUser = repository.isProUser.first()
|
||||
|
||||
updateWidgetsWithData(context, summary, isProUser)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update widgets with the provided summary data
|
||||
*/
|
||||
suspend fun updateWidgetsWithData(
|
||||
context: Context,
|
||||
summary: WidgetSummary,
|
||||
isProUser: Boolean
|
||||
) {
|
||||
val glanceManager = GlanceAppWidgetManager(context)
|
||||
|
||||
// Update small widgets
|
||||
val smallWidgetIds = glanceManager.getGlanceIds(CaseraSmallWidget::class.java)
|
||||
smallWidgetIds.forEach { id ->
|
||||
updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs ->
|
||||
prefs.toMutablePreferences().apply {
|
||||
this[intPreferencesKey("overdue_count")] = summary.overdueCount
|
||||
this[intPreferencesKey("due_soon_count")] = summary.dueSoonCount
|
||||
this[intPreferencesKey("in_progress_count")] = summary.inProgressCount
|
||||
}
|
||||
}
|
||||
CaseraSmallWidget().update(context, id)
|
||||
}
|
||||
|
||||
// Update medium widgets
|
||||
val mediumWidgetIds = glanceManager.getGlanceIds(CaseraMediumWidget::class.java)
|
||||
mediumWidgetIds.forEach { id ->
|
||||
updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs ->
|
||||
prefs.toMutablePreferences().apply {
|
||||
this[intPreferencesKey("overdue_count")] = summary.overdueCount
|
||||
this[intPreferencesKey("due_soon_count")] = summary.dueSoonCount
|
||||
this[intPreferencesKey("in_progress_count")] = summary.inProgressCount
|
||||
this[stringPreferencesKey("tasks_json")] = json.encodeToString(summary.tasks)
|
||||
}
|
||||
}
|
||||
CaseraMediumWidget().update(context, id)
|
||||
}
|
||||
|
||||
// Update large widgets
|
||||
val largeWidgetIds = glanceManager.getGlanceIds(CaseraLargeWidget::class.java)
|
||||
largeWidgetIds.forEach { id ->
|
||||
updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs ->
|
||||
prefs.toMutablePreferences().apply {
|
||||
this[intPreferencesKey("overdue_count")] = summary.overdueCount
|
||||
this[intPreferencesKey("due_soon_count")] = summary.dueSoonCount
|
||||
this[intPreferencesKey("in_progress_count")] = summary.inProgressCount
|
||||
this[intPreferencesKey("total_tasks_count")] = summary.totalTasksCount
|
||||
this[stringPreferencesKey("tasks_json")] = json.encodeToString(summary.tasks)
|
||||
this[stringPreferencesKey("is_pro_user")] = if (isProUser) "true" else "false"
|
||||
this[longPreferencesKey("last_updated")] = summary.lastUpdated
|
||||
}
|
||||
}
|
||||
CaseraLargeWidget().update(context, id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all widget data (called on logout)
|
||||
*/
|
||||
fun clearAllWidgets(context: Context) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val emptyData = WidgetSummary()
|
||||
updateWidgetsWithData(context, emptyData, false)
|
||||
|
||||
// Also clear the repository
|
||||
val repository = WidgetDataRepository.getInstance(context)
|
||||
repository.clearData()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
176
composeApp/src/androidMain/res/layout/widget_large_preview.xml
Normal file
176
composeApp/src/androidMain/res/layout/widget_large_preview.xml
Normal file
@@ -0,0 +1,176 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#FFF8E7"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Casera"
|
||||
android:textColor="#07A0C3"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="Tasks"
|
||||
android:textColor="#666666"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="#FFEBEB"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="2"
|
||||
android:textColor="#DD1C1A"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Overdue"
|
||||
android:textColor="#DD1C1A"
|
||||
android:textSize="10sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:background="#FFF4E0"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="3"
|
||||
android:textColor="#F5A623"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Due Soon"
|
||||
android:textColor="#F5A623"
|
||||
android:textSize="10sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:background="#E0F4F8"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="1"
|
||||
android:textColor="#07A0C3"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Active"
|
||||
android:textColor="#07A0C3"
|
||||
android:textSize="10sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginVertical="12dp"
|
||||
android:background="#E0E0E0" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Change HVAC filter"
|
||||
android:textColor="#1A1A1A"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="My Home • Due today"
|
||||
android:textColor="#DD1C1A"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="Clean gutters"
|
||||
android:textColor="#1A1A1A"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="My Home • Tomorrow"
|
||||
android:textColor="#666666"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="Test smoke detectors"
|
||||
android:textColor="#1A1A1A"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Beach House • In 3 days"
|
||||
android:textColor="#666666"
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
16
composeApp/src/androidMain/res/layout/widget_loading.xml
Normal file
16
composeApp/src/androidMain/res/layout/widget_loading.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#FFF8E7"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:text="Loading..."
|
||||
android:textColor="#666666"
|
||||
android:textSize="14sp" />
|
||||
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#FFF8E7"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Casera"
|
||||
android:textColor="#07A0C3"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:background="#DD1C1A"
|
||||
android:paddingHorizontal="6dp"
|
||||
android:paddingVertical="2dp"
|
||||
android:text="2 overdue"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Change HVAC filter"
|
||||
android:textColor="#1A1A1A"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="My Home • Due today"
|
||||
android:textColor="#666666"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="Clean gutters"
|
||||
android:textColor="#1A1A1A"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="My Home • Tomorrow"
|
||||
android:textColor="#666666"
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,93 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#FFF8E7"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Casera"
|
||||
android:textColor="#07A0C3"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="2"
|
||||
android:textColor="#DD1C1A"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Overdue"
|
||||
android:textColor="#666666"
|
||||
android:textSize="10sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="3"
|
||||
android:textColor="#F5A623"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Due Soon"
|
||||
android:textColor="#666666"
|
||||
android:textSize="10sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="1"
|
||||
android:textColor="#07A0C3"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Active"
|
||||
android:textColor="#666666"
|
||||
android:textSize="10sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -1,4 +1,14 @@
|
||||
<resources>
|
||||
<string name="app_name">Casera</string>
|
||||
<string name="default_notification_channel_id">casera_notifications</string>
|
||||
|
||||
<!-- Widget Strings -->
|
||||
<string name="widget_small_name">Casera Summary</string>
|
||||
<string name="widget_small_description">Quick task count summary showing overdue, due soon, and active tasks</string>
|
||||
|
||||
<string name="widget_medium_name">Casera Tasks</string>
|
||||
<string name="widget_medium_description">List of upcoming tasks with quick access to task details</string>
|
||||
|
||||
<string name="widget_large_name">Casera Dashboard</string>
|
||||
<string name="widget_large_description">Full task dashboard with stats and interactive actions (Pro feature)</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="250dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="4"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="180dp"
|
||||
android:maxResizeWidth="400dp"
|
||||
android:maxResizeHeight="400dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:initialLayout="@layout/widget_loading"
|
||||
android:previewLayout="@layout/widget_large_preview"
|
||||
android:description="@string/widget_large_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetFeatures="reconfigurable" />
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="80dp"
|
||||
android:maxResizeWidth="400dp"
|
||||
android:maxResizeHeight="200dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:initialLayout="@layout/widget_loading"
|
||||
android:previewLayout="@layout/widget_medium_preview"
|
||||
android:description="@string/widget_medium_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetFeatures="reconfigurable" />
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="40dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="1"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="40dp"
|
||||
android:maxResizeWidth="300dp"
|
||||
android:maxResizeHeight="120dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:initialLayout="@layout/widget_loading"
|
||||
android:previewLayout="@layout/widget_small_preview"
|
||||
android:description="@string/widget_small_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetFeatures="reconfigurable" />
|
||||
@@ -72,6 +72,14 @@
|
||||
<string name="properties_join_title">Join Property</string>
|
||||
<string name="properties_join_code_label">Enter Share Code</string>
|
||||
<string name="properties_join_button">Join</string>
|
||||
<string name="properties_join_residence_title">Join Residence</string>
|
||||
<string name="properties_join_residence_message">Would you like to join this shared residence?</string>
|
||||
<string name="properties_join_success">Joined Residence</string>
|
||||
<string name="properties_join_success_message">You now have access to %1$s.</string>
|
||||
<string name="properties_join_failed">Join Failed</string>
|
||||
<string name="properties_joining">Joining...</string>
|
||||
<string name="properties_shared_by">Shared by: %1$s</string>
|
||||
<string name="properties_expires">Expires: %1$s</string>
|
||||
<string name="properties_share_upgrade_title">Pro Feature</string>
|
||||
<string name="properties_share_upgrade_message">Sharing residences is a Pro feature. Upgrade to invite family members to collaborate on home maintenance.</string>
|
||||
<string name="properties_users_title">Property Members</string>
|
||||
@@ -163,6 +171,35 @@
|
||||
<string name="tasks_due_date_placeholder">2025-01-31</string>
|
||||
<string name="tasks_update">Update Task</string>
|
||||
<string name="tasks_failed_to_update">Failed to Update Task</string>
|
||||
<string name="tasks_failed_to_cancel">Failed to cancel task</string>
|
||||
<string name="tasks_failed_to_restore">Failed to restore task</string>
|
||||
<string name="tasks_failed_to_mark_in_progress">Failed to mark task in progress</string>
|
||||
<string name="tasks_failed_to_archive">Failed to archive task</string>
|
||||
<string name="tasks_failed_to_unarchive">Failed to unarchive task</string>
|
||||
<string name="tasks_card_in_progress">IN PROGRESS</string>
|
||||
<string name="tasks_card_actions">Actions</string>
|
||||
<string name="tasks_card_mark_in_progress">Mark In Progress</string>
|
||||
<string name="tasks_card_complete_task">Complete Task</string>
|
||||
<string name="tasks_card_edit_task">Edit Task</string>
|
||||
<string name="tasks_card_cancel_task">Cancel Task</string>
|
||||
<string name="tasks_card_restore_task">Restore Task</string>
|
||||
<string name="tasks_card_archive_task">Archive Task</string>
|
||||
<string name="tasks_card_unarchive_task">Unarchive Task</string>
|
||||
<string name="tasks_card_not_available">N/A</string>
|
||||
<string name="tasks_card_completed_by">By: %1$s</string>
|
||||
<string name="tasks_card_cost">Cost: $%1$s</string>
|
||||
<string name="tasks_card_view_photos">View Photos (%1$d)</string>
|
||||
<string name="tasks_add_new">Add New Task</string>
|
||||
<string name="tasks_property_required">Property *</string>
|
||||
<string name="tasks_property_error">Property is required</string>
|
||||
<string name="tasks_browse_templates">Browse Task Templates</string>
|
||||
<string name="tasks_common_tasks">%1$d common tasks</string>
|
||||
<string name="tasks_category_error">Category is required</string>
|
||||
<string name="tasks_interval_days">Interval Days (optional)</string>
|
||||
<string name="tasks_interval_override">Override default frequency interval</string>
|
||||
<string name="tasks_due_date_format_error">Due date is required (format: YYYY-MM-DD)</string>
|
||||
<string name="tasks_due_date_format">Format: YYYY-MM-DD</string>
|
||||
<string name="tasks_create">Create Task</string>
|
||||
|
||||
<!-- Task Columns / Kanban -->
|
||||
<string name="tasks_column_overdue">Overdue</string>
|
||||
@@ -184,6 +221,21 @@
|
||||
<string name="tasks_in_progress_label">In Progress</string>
|
||||
<string name="tasks_cancelled_message">Task cancelled</string>
|
||||
|
||||
<!-- Task Templates -->
|
||||
<string name="templates_title">Task Templates</string>
|
||||
<string name="templates_done">Done</string>
|
||||
<string name="templates_search_placeholder">Search templates...</string>
|
||||
<string name="templates_clear">Clear</string>
|
||||
<string name="templates_result">result</string>
|
||||
<string name="templates_results">results</string>
|
||||
<string name="templates_no_results_title">No Templates Found</string>
|
||||
<string name="templates_no_results_message">Try a different search term</string>
|
||||
<string name="templates_empty_title">No Templates Available</string>
|
||||
<string name="templates_empty_message">Templates will appear here once loaded</string>
|
||||
<string name="templates_expand">Expand</string>
|
||||
<string name="templates_collapse">Collapse</string>
|
||||
<string name="templates_add">Add</string>
|
||||
|
||||
<!-- Task Completions -->
|
||||
<string name="completions_title">Task Completions</string>
|
||||
<string name="completions_add_title">Complete Task</string>
|
||||
@@ -193,6 +245,29 @@
|
||||
<string name="completions_photos_label">Photos</string>
|
||||
<string name="completions_add_photo">Add Photo</string>
|
||||
<string name="completions_delete_confirm">Delete this completion record?</string>
|
||||
<string name="completions_complete_task_title">Complete Task: %1$s</string>
|
||||
<string name="completions_select_contractor">Select Contractor (optional)</string>
|
||||
<string name="completions_choose_contractor_placeholder">Choose a contractor or leave blank</string>
|
||||
<string name="completions_expand">Expand</string>
|
||||
<string name="completions_none_manual">None (manual entry)</string>
|
||||
<string name="completions_loading_contractors">Loading contractors...</string>
|
||||
<string name="completions_error_loading_contractors">Error loading contractors</string>
|
||||
<string name="completions_completed_by_name">Completed By Name (optional)</string>
|
||||
<string name="completions_completed_by_placeholder">Enter name if not using contractor above</string>
|
||||
<string name="completions_actual_cost_optional">Actual Cost (optional)</string>
|
||||
<string name="completions_notes_optional">Notes (optional)</string>
|
||||
<string name="completions_rating">Rating: %1$d out of 5</string>
|
||||
<string name="completions_add_images">Add Images</string>
|
||||
<string name="completions_take_photo">Take Photo</string>
|
||||
<string name="completions_choose_from_library">Choose from Library</string>
|
||||
<string name="completions_images_selected">%1$d image(s) selected</string>
|
||||
<string name="completions_remove_image">Remove image</string>
|
||||
<string name="completions_complete_button">Complete</string>
|
||||
<string name="completions_quality_rating">Quality Rating</string>
|
||||
<string name="completions_photos_count">Photos (%1$d/%2$d)</string>
|
||||
<string name="completions_camera">Camera</string>
|
||||
<string name="completions_library">Library</string>
|
||||
<string name="completions_add_photos_helper">Add photos of completed work (optional)</string>
|
||||
|
||||
<!-- Contractors -->
|
||||
<string name="contractors_title">Contractors</string>
|
||||
@@ -259,6 +334,32 @@
|
||||
<string name="contractors_import_failed">Import Failed</string>
|
||||
<string name="contractors_shared_by">Shared by: %1$s</string>
|
||||
|
||||
<!-- Contractor Form/Dialog -->
|
||||
<string name="contractors_form_add_title">Add Contractor</string>
|
||||
<string name="contractors_form_edit_title">Edit Contractor</string>
|
||||
<string name="contractors_form_basic_info">Basic Information</string>
|
||||
<string name="contractors_form_name_required">Name *</string>
|
||||
<string name="contractors_form_company">Company</string>
|
||||
<string name="contractors_form_residence_optional">Residence (Optional)</string>
|
||||
<string name="contractors_form_personal_no_residence">Personal (No Residence)</string>
|
||||
<string name="contractors_form_personal_visibility">Only you will see this contractor</string>
|
||||
<string name="contractors_form_shared_visibility">All users of %1$s will see this contractor</string>
|
||||
<string name="contractors_form_contact_info">Contact Information</string>
|
||||
<string name="contractors_form_phone">Phone</string>
|
||||
<string name="contractors_form_email">Email</string>
|
||||
<string name="contractors_form_website">Website</string>
|
||||
<string name="contractors_form_specialties">Specialties</string>
|
||||
<string name="contractors_form_address_section">Address</string>
|
||||
<string name="contractors_form_street_address">Street Address</string>
|
||||
<string name="contractors_form_city">City</string>
|
||||
<string name="contractors_form_state">State</string>
|
||||
<string name="contractors_form_zip_code">ZIP Code</string>
|
||||
<string name="contractors_form_notes_section">Notes</string>
|
||||
<string name="contractors_form_private_notes">Private Notes</string>
|
||||
<string name="contractors_form_mark_favorite">Mark as Favorite</string>
|
||||
<string name="contractors_form_add_button">Add</string>
|
||||
<string name="contractors_form_save_button">Save</string>
|
||||
|
||||
<!-- Documents -->
|
||||
<string name="documents_title">Documents</string>
|
||||
<string name="documents_and_warranties">Documents & Warranties</string>
|
||||
@@ -338,6 +439,51 @@
|
||||
<string name="documents_previous">Previous</string>
|
||||
<string name="documents_next">Next</string>
|
||||
|
||||
<!-- Document Form -->
|
||||
<string name="documents_form_edit_warranty">Edit Warranty</string>
|
||||
<string name="documents_form_edit_document">Edit Document</string>
|
||||
<string name="documents_form_add_warranty">Add Warranty</string>
|
||||
<string name="documents_form_add_document">Add Document</string>
|
||||
<string name="documents_form_select_residence">Select Residence</string>
|
||||
<string name="documents_form_residence_required">Residence *</string>
|
||||
<string name="documents_form_document_type_required">Document Type *</string>
|
||||
<string name="documents_form_title_required">Title *</string>
|
||||
<string name="documents_form_item_name_required">Item Name *</string>
|
||||
<string name="documents_form_model_number">Model Number</string>
|
||||
<string name="documents_form_serial_number">Serial Number</string>
|
||||
<string name="documents_form_provider_required">Provider/Company *</string>
|
||||
<string name="documents_form_provider_contact">Provider Contact</string>
|
||||
<string name="documents_form_claim_phone">Claim Phone</string>
|
||||
<string name="documents_form_claim_email">Claim Email</string>
|
||||
<string name="documents_form_claim_website">Claim Website</string>
|
||||
<string name="documents_form_purchase_date">Purchase Date (YYYY-MM-DD)</string>
|
||||
<string name="documents_form_warranty_start">Warranty Start Date (YYYY-MM-DD)</string>
|
||||
<string name="documents_form_warranty_end_required">Warranty End Date (YYYY-MM-DD) *</string>
|
||||
<string name="documents_form_description">Description</string>
|
||||
<string name="documents_form_category">Category</string>
|
||||
<string name="documents_form_select_category">Select Category</string>
|
||||
<string name="documents_form_category_none">None</string>
|
||||
<string name="documents_form_tags">Tags</string>
|
||||
<string name="documents_form_tags_placeholder">tag1, tag2, tag3</string>
|
||||
<string name="documents_form_notes">Notes</string>
|
||||
<string name="documents_form_active">Active</string>
|
||||
<string name="documents_form_existing_photos">Existing Photos (%1$d)</string>
|
||||
<string name="documents_form_new_photos">New Photos (%1$d/%2$d)</string>
|
||||
<string name="documents_form_photos">Photos (%1$d/%2$d)</string>
|
||||
<string name="documents_form_camera">Camera</string>
|
||||
<string name="documents_form_gallery">Gallery</string>
|
||||
<string name="documents_form_image_number">Image %1$d</string>
|
||||
<string name="documents_form_remove_image">Remove image</string>
|
||||
<string name="documents_form_update_warranty">Update Warranty</string>
|
||||
<string name="documents_form_update_document">Update Document</string>
|
||||
<string name="documents_form_select_residence_error">Please select a residence</string>
|
||||
<string name="documents_form_title_error">Title is required</string>
|
||||
<string name="documents_form_item_name_error">Item name is required for warranties</string>
|
||||
<string name="documents_form_provider_error">Provider is required for warranties</string>
|
||||
<string name="documents_form_date_placeholder">2024-01-15</string>
|
||||
<string name="documents_form_date_placeholder_end">2025-01-15</string>
|
||||
<string name="documents_form_failed_to_load_residences">Failed to load residences: %1$s</string>
|
||||
|
||||
<!-- Profile -->
|
||||
<string name="profile_title">Profile</string>
|
||||
<string name="profile_edit_title">Edit Profile</string>
|
||||
@@ -453,6 +599,7 @@
|
||||
<string name="common_share">Share</string>
|
||||
<string name="common_import">Import</string>
|
||||
<string name="common_importing">Importing...</string>
|
||||
<string name="common_try_again">Try Again</string>
|
||||
|
||||
<!-- Errors -->
|
||||
<string name="error_generic">Something went wrong. Please try again.</string>
|
||||
|
||||
@@ -81,11 +81,14 @@ fun App(
|
||||
val navController = rememberNavController()
|
||||
|
||||
// Handle navigation from notification tap
|
||||
// Note: The actual navigation to the task column happens in MainScreen -> AllTasksScreen
|
||||
// We just need to ensure the user is on MainRoute when a task navigation is requested
|
||||
LaunchedEffect(navigateToTaskId) {
|
||||
if (navigateToTaskId != null && isLoggedIn && isVerified) {
|
||||
// Navigate to tasks screen (task detail view is handled within the screen)
|
||||
navController.navigate(TasksRoute)
|
||||
onClearNavigateToTask()
|
||||
// Ensure we're on the main screen - MainScreen will handle navigating to the tasks tab
|
||||
navController.navigate(MainRoute) {
|
||||
popUpTo(MainRoute) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,6 +376,8 @@ fun App(
|
||||
// Navigate to first residence or show message if no residences exist
|
||||
// For now, this will be handled by the UI showing "add a property first"
|
||||
},
|
||||
navigateToTaskId = navigateToTaskId,
|
||||
onClearNavigateToTask = onClearNavigateToTask,
|
||||
onNavigateToEditResidence = { residence ->
|
||||
navController.navigate(
|
||||
EditResidenceRoute(
|
||||
|
||||
@@ -186,3 +186,23 @@ data class AppleSignInResponse(
|
||||
val user: User,
|
||||
@SerialName("is_new_user") val isNewUser: Boolean
|
||||
)
|
||||
|
||||
// Google Sign In Models
|
||||
|
||||
/**
|
||||
* Google Sign In request matching Go API
|
||||
*/
|
||||
@Serializable
|
||||
data class GoogleSignInRequest(
|
||||
@SerialName("id_token") val idToken: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Google Sign In response matching Go API
|
||||
*/
|
||||
@Serializable
|
||||
data class GoogleSignInResponse(
|
||||
val token: String,
|
||||
val user: User,
|
||||
@SerialName("is_new_user") val isNewUser: Boolean
|
||||
)
|
||||
|
||||
@@ -1180,6 +1180,22 @@ object APILayer {
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult<GoogleSignInResponse> {
|
||||
val result = authApi.googleSignIn(request)
|
||||
|
||||
// Update DataManager on success
|
||||
if (result is ApiResult.Success) {
|
||||
DataManager.setAuthToken(result.data.token)
|
||||
DataManager.setCurrentUser(result.data.user)
|
||||
// Initialize lookups after successful Google sign in
|
||||
initializeLookups()
|
||||
// Prefetch all data
|
||||
prefetchAllData()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun updateProfile(token: String, request: UpdateProfileRequest): ApiResult<User> {
|
||||
val result = authApi.updateProfile(token, request)
|
||||
|
||||
|
||||
@@ -45,4 +45,16 @@ object ApiConfig {
|
||||
Environment.DEV -> "Dev Server (casera.treytartt.com)"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Google OAuth Web Client ID
|
||||
* This is the Web application client ID from Google Cloud Console.
|
||||
* It should match the GOOGLE_CLIENT_ID configured in the backend.
|
||||
*
|
||||
* 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)
|
||||
*/
|
||||
const val GOOGLE_WEB_CLIENT_ID = "YOUR_WEB_CLIENT_ID.apps.googleusercontent.com"
|
||||
}
|
||||
|
||||
@@ -223,4 +223,27 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
// Google Sign In
|
||||
suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult<GoogleSignInResponse> {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/auth/google-sign-in/") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
val errorBody = try {
|
||||
response.body<Map<String, String>>()
|
||||
} catch (e: Exception) {
|
||||
mapOf("error" to "Google Sign In failed")
|
||||
}
|
||||
ApiResult.Error(errorBody["error"] ?: "Google Sign In failed", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.example.casera.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* Types of haptic feedback available.
|
||||
*/
|
||||
enum class HapticFeedbackType {
|
||||
/** Light feedback - for selections, toggles */
|
||||
Light,
|
||||
/** Medium feedback - for confirmations */
|
||||
Medium,
|
||||
/** Heavy feedback - for important actions */
|
||||
Heavy,
|
||||
/** Selection changed feedback */
|
||||
Selection,
|
||||
/** Success feedback */
|
||||
Success,
|
||||
/** Warning feedback */
|
||||
Warning,
|
||||
/** Error feedback */
|
||||
Error
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for performing haptic feedback.
|
||||
*/
|
||||
interface HapticFeedbackPerformer {
|
||||
fun perform(type: HapticFeedbackType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember a haptic feedback performer for the current platform.
|
||||
*/
|
||||
@Composable
|
||||
expect fun rememberHapticFeedback(): HapticFeedbackPerformer
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.example.casera.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
|
||||
/**
|
||||
* Converts ImageData bytes to an ImageBitmap for display.
|
||||
* Returns null if conversion fails.
|
||||
*/
|
||||
@Composable
|
||||
expect fun rememberImageBitmap(imageData: ImageData): ImageBitmap?
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import casera.composeapp.generated.resources.*
|
||||
import com.example.casera.viewmodel.ContractorViewModel
|
||||
import com.example.casera.viewmodel.ResidenceViewModel
|
||||
import com.example.casera.models.ContractorCreateRequest
|
||||
@@ -22,6 +23,7 @@ import com.example.casera.network.ApiResult
|
||||
import com.example.casera.repository.LookupsRepository
|
||||
import com.example.casera.analytics.PostHogAnalytics
|
||||
import com.example.casera.analytics.AnalyticsEvents
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -109,12 +111,19 @@ fun AddContractorDialog(
|
||||
}
|
||||
}
|
||||
|
||||
val dialogTitle = if (contractorId == null)
|
||||
stringResource(Res.string.contractors_form_add_title)
|
||||
else
|
||||
stringResource(Res.string.contractors_form_edit_title)
|
||||
val personalNoResidence = stringResource(Res.string.contractors_form_personal_no_residence)
|
||||
val cancelText = stringResource(Res.string.common_cancel)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = Modifier.fillMaxWidth(0.95f),
|
||||
title = {
|
||||
Text(
|
||||
if (contractorId == null) "Add Contractor" else "Edit Contractor",
|
||||
dialogTitle,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
@@ -128,7 +137,7 @@ fun AddContractorDialog(
|
||||
) {
|
||||
// Basic Information Section
|
||||
Text(
|
||||
"Basic Information",
|
||||
stringResource(Res.string.contractors_form_basic_info),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color(0xFF111827)
|
||||
@@ -137,7 +146,7 @@ fun AddContractorDialog(
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name *") },
|
||||
label = { Text(stringResource(Res.string.contractors_form_name_required)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
@@ -151,7 +160,7 @@ fun AddContractorDialog(
|
||||
OutlinedTextField(
|
||||
value = company,
|
||||
onValueChange = { company = it },
|
||||
label = { Text("Company") },
|
||||
label = { Text(stringResource(Res.string.contractors_form_company)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
@@ -168,10 +177,10 @@ fun AddContractorDialog(
|
||||
onExpandedChange = { expandedResidenceMenu = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedResidence?.name ?: "Personal (No Residence)",
|
||||
value = selectedResidence?.name ?: personalNoResidence,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Residence (Optional)") },
|
||||
label = { Text(stringResource(Res.string.contractors_form_residence_optional)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
@@ -190,7 +199,7 @@ fun AddContractorDialog(
|
||||
) {
|
||||
// Option for no residence (personal contractor)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Personal (No Residence)") },
|
||||
text = { Text(personalNoResidence) },
|
||||
onClick = {
|
||||
selectedResidence = null
|
||||
expandedResidenceMenu = false
|
||||
@@ -214,8 +223,8 @@ fun AddContractorDialog(
|
||||
}
|
||||
|
||||
Text(
|
||||
if (selectedResidence == null) "Only you will see this contractor"
|
||||
else "All users of ${selectedResidence?.name} will see this contractor",
|
||||
if (selectedResidence == null) stringResource(Res.string.contractors_form_personal_visibility)
|
||||
else stringResource(Res.string.contractors_form_shared_visibility, selectedResidence?.name ?: ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color(0xFF6B7280)
|
||||
)
|
||||
@@ -224,7 +233,7 @@ fun AddContractorDialog(
|
||||
|
||||
// Contact Information Section
|
||||
Text(
|
||||
"Contact Information",
|
||||
stringResource(Res.string.contractors_form_contact_info),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color(0xFF111827)
|
||||
@@ -233,7 +242,7 @@ fun AddContractorDialog(
|
||||
OutlinedTextField(
|
||||
value = phone,
|
||||
onValueChange = { phone = it },
|
||||
label = { Text("Phone") },
|
||||
label = { Text(stringResource(Res.string.contractors_form_phone)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
@@ -247,7 +256,7 @@ fun AddContractorDialog(
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = { Text("Email") },
|
||||
label = { Text(stringResource(Res.string.contractors_form_email)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
@@ -261,7 +270,7 @@ fun AddContractorDialog(
|
||||
OutlinedTextField(
|
||||
value = website,
|
||||
onValueChange = { website = it },
|
||||
label = { Text("Website") },
|
||||
label = { Text(stringResource(Res.string.contractors_form_website)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
@@ -276,7 +285,7 @@ fun AddContractorDialog(
|
||||
|
||||
// Specialties Section
|
||||
Text(
|
||||
"Specialties",
|
||||
stringResource(Res.string.contractors_form_specialties),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color(0xFF111827)
|
||||
@@ -310,7 +319,7 @@ fun AddContractorDialog(
|
||||
|
||||
// Address Section
|
||||
Text(
|
||||
"Address",
|
||||
stringResource(Res.string.contractors_form_address_section),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color(0xFF111827)
|
||||
@@ -319,7 +328,7 @@ fun AddContractorDialog(
|
||||
OutlinedTextField(
|
||||
value = streetAddress,
|
||||
onValueChange = { streetAddress = it },
|
||||
label = { Text("Street Address") },
|
||||
label = { Text(stringResource(Res.string.contractors_form_street_address)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
@@ -334,7 +343,7 @@ fun AddContractorDialog(
|
||||
OutlinedTextField(
|
||||
value = city,
|
||||
onValueChange = { city = it },
|
||||
label = { Text("City") },
|
||||
label = { Text(stringResource(Res.string.contractors_form_city)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
@@ -347,7 +356,7 @@ fun AddContractorDialog(
|
||||
OutlinedTextField(
|
||||
value = stateProvince,
|
||||
onValueChange = { stateProvince = it },
|
||||
label = { Text("State") },
|
||||
label = { Text(stringResource(Res.string.contractors_form_state)) },
|
||||
modifier = Modifier.weight(0.5f),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
@@ -361,7 +370,7 @@ fun AddContractorDialog(
|
||||
OutlinedTextField(
|
||||
value = postalCode,
|
||||
onValueChange = { postalCode = it },
|
||||
label = { Text("ZIP Code") },
|
||||
label = { Text(stringResource(Res.string.contractors_form_zip_code)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
@@ -375,7 +384,7 @@ fun AddContractorDialog(
|
||||
|
||||
// Notes Section
|
||||
Text(
|
||||
"Notes",
|
||||
stringResource(Res.string.contractors_form_notes_section),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color(0xFF111827)
|
||||
@@ -384,7 +393,7 @@ fun AddContractorDialog(
|
||||
OutlinedTextField(
|
||||
value = notes,
|
||||
onValueChange = { notes = it },
|
||||
label = { Text("Private Notes") },
|
||||
label = { Text(stringResource(Res.string.contractors_form_private_notes)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(100.dp),
|
||||
@@ -409,7 +418,7 @@ fun AddContractorDialog(
|
||||
tint = if (isFavorite) Color(0xFFF59E0B) else Color(0xFF9CA3AF)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Mark as Favorite", color = Color(0xFF111827))
|
||||
Text(stringResource(Res.string.contractors_form_mark_favorite), color = Color(0xFF111827))
|
||||
}
|
||||
Switch(
|
||||
checked = isFavorite,
|
||||
@@ -491,13 +500,13 @@ fun AddContractorDialog(
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text(if (contractorId == null) "Add" else "Save")
|
||||
Text(if (contractorId == null) stringResource(Res.string.contractors_form_add_button) else stringResource(Res.string.contractors_form_save_button))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel", color = Color(0xFF6B7280))
|
||||
Text(cancelText, color = Color(0xFF6B7280))
|
||||
}
|
||||
},
|
||||
containerColor = Color.White,
|
||||
|
||||
@@ -24,6 +24,8 @@ import com.example.casera.models.TaskFrequency
|
||||
import com.example.casera.models.TaskPriority
|
||||
import com.example.casera.analytics.PostHogAnalytics
|
||||
import com.example.casera.analytics.AnalyticsEvents
|
||||
import casera.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -132,7 +134,7 @@ fun AddTaskDialog(
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Add New Task") },
|
||||
title = { Text(stringResource(Res.string.tasks_add_new)) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -149,13 +151,13 @@ fun AddTaskDialog(
|
||||
OutlinedTextField(
|
||||
value = residencesResponse.residences.find { it.id == selectedResidenceId }?.name ?: "",
|
||||
onValueChange = { },
|
||||
label = { Text("Property *") },
|
||||
label = { Text(stringResource(Res.string.tasks_property_required)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
isError = residenceError,
|
||||
supportingText = if (residenceError) {
|
||||
{ Text("Property is required") }
|
||||
{ Text(stringResource(Res.string.tasks_property_error)) }
|
||||
} else null,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showResidenceDropdown) },
|
||||
readOnly = true,
|
||||
@@ -202,11 +204,11 @@ fun AddTaskDialog(
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Browse Task Templates",
|
||||
text = stringResource(Res.string.tasks_browse_templates),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "${allTemplates.size} common tasks",
|
||||
text = stringResource(Res.string.tasks_common_tasks, allTemplates.size),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -230,11 +232,11 @@ fun AddTaskDialog(
|
||||
titleError = false
|
||||
showSuggestions = it.length >= 2 && filteredSuggestions.isNotEmpty()
|
||||
},
|
||||
label = { Text("Title *") },
|
||||
label = { Text(stringResource(Res.string.tasks_title_required)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = titleError,
|
||||
supportingText = if (titleError) {
|
||||
{ Text("Title is required") }
|
||||
{ Text(stringResource(Res.string.tasks_title_error)) }
|
||||
} else null,
|
||||
singleLine = true
|
||||
)
|
||||
@@ -255,7 +257,7 @@ fun AddTaskDialog(
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Description") },
|
||||
label = { Text(stringResource(Res.string.tasks_description_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
maxLines = 4
|
||||
@@ -269,13 +271,13 @@ fun AddTaskDialog(
|
||||
OutlinedTextField(
|
||||
value = categories.find { it == category }?.name ?: "",
|
||||
onValueChange = { },
|
||||
label = { Text("Category *") },
|
||||
label = { Text(stringResource(Res.string.tasks_category_required)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
isError = categoryError,
|
||||
supportingText = if (categoryError) {
|
||||
{ Text("Category is required") }
|
||||
{ Text(stringResource(Res.string.tasks_category_error)) }
|
||||
} else null,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showCategoryDropdown) },
|
||||
readOnly = false,
|
||||
@@ -306,7 +308,7 @@ fun AddTaskDialog(
|
||||
OutlinedTextField(
|
||||
value = frequencies.find { it == frequency }?.displayName ?: "",
|
||||
onValueChange = { },
|
||||
label = { Text("Frequency") },
|
||||
label = { Text(stringResource(Res.string.tasks_frequency_label)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
@@ -339,10 +341,10 @@ fun AddTaskDialog(
|
||||
OutlinedTextField(
|
||||
value = intervalDays,
|
||||
onValueChange = { intervalDays = it.filter { char -> char.isDigit() } },
|
||||
label = { Text("Interval Days (optional)") },
|
||||
label = { Text(stringResource(Res.string.tasks_interval_days)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
supportingText = { Text("Override default frequency interval") },
|
||||
supportingText = { Text(stringResource(Res.string.tasks_interval_override)) },
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
@@ -354,13 +356,13 @@ fun AddTaskDialog(
|
||||
dueDate = it
|
||||
dueDateError = false
|
||||
},
|
||||
label = { Text("Due Date (YYYY-MM-DD) *") },
|
||||
label = { Text(stringResource(Res.string.tasks_due_date_required)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = dueDateError,
|
||||
supportingText = if (dueDateError) {
|
||||
{ Text("Due date is required (format: YYYY-MM-DD)") }
|
||||
{ Text(stringResource(Res.string.tasks_due_date_format_error)) }
|
||||
} else {
|
||||
{ Text("Format: YYYY-MM-DD") }
|
||||
{ Text(stringResource(Res.string.tasks_due_date_format)) }
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
@@ -373,7 +375,7 @@ fun AddTaskDialog(
|
||||
OutlinedTextField(
|
||||
value = priorities.find { it.name == priority.name }?.displayName ?: "",
|
||||
onValueChange = { },
|
||||
label = { Text("Priority") },
|
||||
label = { Text(stringResource(Res.string.tasks_priority_label)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
@@ -401,7 +403,7 @@ fun AddTaskDialog(
|
||||
OutlinedTextField(
|
||||
value = estimatedCost,
|
||||
onValueChange = { estimatedCost = it },
|
||||
label = { Text("Estimated Cost") },
|
||||
label = { Text(stringResource(Res.string.tasks_estimated_cost_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
prefix = { Text("$") },
|
||||
@@ -465,13 +467,13 @@ fun AddTaskDialog(
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text("Create Task")
|
||||
Text(stringResource(Res.string.tasks_create))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
Text(stringResource(Res.string.common_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,26 +1,48 @@
|
||||
package com.example.casera.ui.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StarOutline
|
||||
import androidx.compose.material.icons.filled.CameraAlt
|
||||
import androidx.compose.material.icons.filled.PhotoLibrary
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import casera.composeapp.generated.resources.*
|
||||
import com.example.casera.viewmodel.ContractorViewModel
|
||||
import com.example.casera.models.TaskCompletionCreateRequest
|
||||
import com.example.casera.network.ApiResult
|
||||
import com.example.casera.platform.ImageData
|
||||
import com.example.casera.platform.rememberImagePicker
|
||||
import com.example.casera.platform.rememberCameraPicker
|
||||
import com.example.casera.platform.HapticFeedbackType
|
||||
import com.example.casera.platform.rememberHapticFeedback
|
||||
import com.example.casera.platform.rememberImageBitmap
|
||||
import kotlinx.datetime.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
private const val MAX_IMAGES = 5
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -41,6 +63,7 @@ fun CompleteTaskDialog(
|
||||
var showContractorDropdown by remember { mutableStateOf(false) }
|
||||
|
||||
val contractorsState by contractorViewModel.contractorsState.collectAsState()
|
||||
val hapticFeedback = rememberHapticFeedback()
|
||||
|
||||
// Load contractors when dialog opens
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -48,16 +71,24 @@ fun CompleteTaskDialog(
|
||||
}
|
||||
|
||||
val imagePicker = rememberImagePicker { images ->
|
||||
selectedImages = images
|
||||
// Add new images up to the max limit
|
||||
val newTotal = (selectedImages + images).take(MAX_IMAGES)
|
||||
selectedImages = newTotal
|
||||
}
|
||||
|
||||
val cameraPicker = rememberCameraPicker { image ->
|
||||
selectedImages = selectedImages + image
|
||||
if (selectedImages.size < MAX_IMAGES) {
|
||||
selectedImages = selectedImages + image
|
||||
}
|
||||
}
|
||||
|
||||
val noneManualEntry = stringResource(Res.string.completions_none_manual)
|
||||
val cancelText = stringResource(Res.string.common_cancel)
|
||||
val removeImageDesc = stringResource(Res.string.completions_remove_image)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Complete Task: $taskTitle") },
|
||||
title = { Text(stringResource(Res.string.completions_complete_task_title, taskTitle)) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -74,10 +105,10 @@ fun CompleteTaskDialog(
|
||||
value = selectedContractorName ?: "",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Select Contractor (optional)") },
|
||||
placeholder = { Text("Choose a contractor or leave blank") },
|
||||
label = { Text(stringResource(Res.string.completions_select_contractor)) },
|
||||
placeholder = { Text(stringResource(Res.string.completions_choose_contractor_placeholder)) },
|
||||
trailingIcon = {
|
||||
Icon(Icons.Default.ArrowDropDown, "Expand")
|
||||
Icon(Icons.Default.ArrowDropDown, stringResource(Res.string.completions_expand))
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -91,7 +122,7 @@ fun CompleteTaskDialog(
|
||||
) {
|
||||
// "None" option to clear selection
|
||||
DropdownMenuItem(
|
||||
text = { Text("None (manual entry)") },
|
||||
text = { Text(noneManualEntry) },
|
||||
onClick = {
|
||||
selectedContractorId = null
|
||||
selectedContractorName = null
|
||||
@@ -130,14 +161,14 @@ fun CompleteTaskDialog(
|
||||
}
|
||||
is ApiResult.Loading -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Loading contractors...") },
|
||||
text = { Text(stringResource(Res.string.completions_loading_contractors)) },
|
||||
onClick = {},
|
||||
enabled = false
|
||||
)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Error loading contractors") },
|
||||
text = { Text(stringResource(Res.string.completions_error_loading_contractors)) },
|
||||
onClick = {},
|
||||
enabled = false
|
||||
)
|
||||
@@ -150,16 +181,16 @@ fun CompleteTaskDialog(
|
||||
OutlinedTextField(
|
||||
value = completedByName,
|
||||
onValueChange = { completedByName = it },
|
||||
label = { Text("Completed By Name (optional)") },
|
||||
label = { Text(stringResource(Res.string.completions_completed_by_name)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Enter name if not using contractor above") },
|
||||
placeholder = { Text(stringResource(Res.string.completions_completed_by_placeholder)) },
|
||||
enabled = selectedContractorId == null
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = actualCost,
|
||||
onValueChange = { actualCost = it },
|
||||
label = { Text("Actual Cost (optional)") },
|
||||
label = { Text(stringResource(Res.string.completions_actual_cost_optional)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
prefix = { Text("$") }
|
||||
@@ -168,84 +199,151 @@ fun CompleteTaskDialog(
|
||||
OutlinedTextField(
|
||||
value = notes,
|
||||
onValueChange = { notes = it },
|
||||
label = { Text("Notes (optional)") },
|
||||
label = { Text(stringResource(Res.string.completions_notes_optional)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
maxLines = 5
|
||||
)
|
||||
|
||||
// Quality Rating Section - Interactive Stars
|
||||
Column {
|
||||
Text("Rating: $rating out of 5")
|
||||
Slider(
|
||||
value = rating.toFloat(),
|
||||
onValueChange = { rating = it.toInt() },
|
||||
valueRange = 1f..5f,
|
||||
steps = 3,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.completions_quality_rating),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Text(
|
||||
text = "$rating / 5",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Interactive Star Rating
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
(1..5).forEach { star ->
|
||||
val isSelected = star <= rating
|
||||
val starColor by animateColorAsState(
|
||||
targetValue = if (isSelected) Color(0xFFFFD700) else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
|
||||
animationSpec = tween(durationMillis = 150),
|
||||
label = "starColor"
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
hapticFeedback.perform(HapticFeedbackType.Selection)
|
||||
rating = star
|
||||
},
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isSelected) Icons.Default.Star else Icons.Default.StarOutline,
|
||||
contentDescription = "$star stars",
|
||||
tint = starColor,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Image upload section
|
||||
// Image upload section with thumbnails
|
||||
Column {
|
||||
Text(
|
||||
text = "Add Images",
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.completions_photos_count, selectedImages.size, MAX_IMAGES),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Photo buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { cameraPicker() },
|
||||
modifier = Modifier.weight(1f)
|
||||
onClick = {
|
||||
hapticFeedback.perform(HapticFeedbackType.Light)
|
||||
cameraPicker()
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = selectedImages.size < MAX_IMAGES
|
||||
) {
|
||||
Text("Take Photo")
|
||||
Icon(
|
||||
Icons.Default.CameraAlt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(stringResource(Res.string.completions_camera))
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { imagePicker() },
|
||||
modifier = Modifier.weight(1f)
|
||||
onClick = {
|
||||
hapticFeedback.perform(HapticFeedbackType.Light)
|
||||
imagePicker()
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = selectedImages.size < MAX_IMAGES
|
||||
) {
|
||||
Text("Choose from Library")
|
||||
Icon(
|
||||
Icons.Default.PhotoLibrary,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(stringResource(Res.string.completions_library))
|
||||
}
|
||||
}
|
||||
|
||||
// Display selected images
|
||||
// Image thumbnails with preview
|
||||
if (selectedImages.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "${selectedImages.size} image(s) selected",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
selectedImages.forEach { image ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = image.fileName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
selectedImages.forEachIndexed { index, imageData ->
|
||||
ImageThumbnail(
|
||||
imageData = imageData,
|
||||
onRemove = {
|
||||
hapticFeedback.perform(HapticFeedbackType.Light)
|
||||
selectedImages = selectedImages.toMutableList().also {
|
||||
it.removeAt(index)
|
||||
}
|
||||
},
|
||||
removeContentDescription = removeImageDesc
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
selectedImages = selectedImages.filter { it != image }
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove image",
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper text
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.completions_add_photos_helper),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -281,12 +379,12 @@ fun CompleteTaskDialog(
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text("Complete")
|
||||
Text(stringResource(Res.string.completions_complete_button))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
Text(cancelText)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -296,3 +394,63 @@ fun CompleteTaskDialog(
|
||||
private fun getCurrentDateTime(): String {
|
||||
return kotlinx.datetime.LocalDate.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Image thumbnail with remove button for displaying selected images.
|
||||
*/
|
||||
@Composable
|
||||
private fun ImageThumbnail(
|
||||
imageData: ImageData,
|
||||
onRemove: () -> Unit,
|
||||
removeContentDescription: String
|
||||
) {
|
||||
val imageBitmap = rememberImageBitmap(imageData)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
if (imageBitmap != null) {
|
||||
Image(
|
||||
bitmap = imageBitmap,
|
||||
contentDescription = imageData.fileName,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
// Fallback placeholder
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PhotoLibrary,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove button
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(20.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.error)
|
||||
.clickable(onClick = onRemove),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = removeContentDescription,
|
||||
tint = MaterialTheme.colorScheme.onError,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import casera.composeapp.generated.resources.*
|
||||
import com.example.casera.models.SharedContractor
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/**
|
||||
* Dialog shown when a user attempts to import a contractor from a .casera file.
|
||||
@@ -51,7 +53,7 @@ fun ContractorImportConfirmDialog(
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Import Contractor",
|
||||
text = stringResource(Res.string.contractors_import_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
@@ -62,7 +64,7 @@ fun ContractorImportConfirmDialog(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Would you like to import this contractor?",
|
||||
text = stringResource(Res.string.contractors_import_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
@@ -100,7 +102,7 @@ fun ContractorImportConfirmDialog(
|
||||
sharedContractor.exportedBy?.let { exportedBy ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Shared by: $exportedBy",
|
||||
text = stringResource(Res.string.contractors_shared_by, exportedBy),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -123,9 +125,9 @@ fun ContractorImportConfirmDialog(
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Importing...")
|
||||
Text(stringResource(Res.string.common_importing))
|
||||
} else {
|
||||
Text("Import")
|
||||
Text(stringResource(Res.string.common_import))
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -134,7 +136,7 @@ fun ContractorImportConfirmDialog(
|
||||
onClick = onDismiss,
|
||||
enabled = !isImporting
|
||||
) {
|
||||
Text("Cancel")
|
||||
Text(stringResource(Res.string.common_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -160,14 +162,14 @@ fun ContractorImportSuccessDialog(
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Contractor Imported",
|
||||
text = stringResource(Res.string.contractors_import_success),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = "$contractorName has been added to your contacts.",
|
||||
text = stringResource(Res.string.contractors_import_success_message, contractorName),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
@@ -179,7 +181,7 @@ fun ContractorImportSuccessDialog(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text("OK")
|
||||
Text(stringResource(Res.string.common_ok))
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -206,7 +208,7 @@ fun ContractorImportErrorDialog(
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Import Failed",
|
||||
text = stringResource(Res.string.contractors_import_failed),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center
|
||||
@@ -230,7 +232,7 @@ fun ContractorImportErrorDialog(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text("Try Again")
|
||||
Text(stringResource(Res.string.common_try_again))
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
@@ -239,14 +241,14 @@ fun ContractorImportErrorDialog(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text("OK")
|
||||
Text(stringResource(Res.string.common_ok))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
if (onRetry != null) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
Text(stringResource(Res.string.common_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import casera.composeapp.generated.resources.*
|
||||
import com.example.casera.models.SharedResidence
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/**
|
||||
* Dialog shown when a user attempts to join a residence from a .casera file.
|
||||
@@ -48,7 +50,7 @@ fun ResidenceImportConfirmDialog(
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Join Residence",
|
||||
text = stringResource(Res.string.properties_join_residence_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
@@ -59,7 +61,7 @@ fun ResidenceImportConfirmDialog(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Would you like to join this shared residence?",
|
||||
text = stringResource(Res.string.properties_join_residence_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
@@ -80,7 +82,7 @@ fun ResidenceImportConfirmDialog(
|
||||
sharedResidence.sharedBy?.let { sharedBy ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Shared by: $sharedBy",
|
||||
text = stringResource(Res.string.properties_shared_by, sharedBy),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -89,7 +91,7 @@ fun ResidenceImportConfirmDialog(
|
||||
sharedResidence.expiresAt?.let { expiresAt ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Expires: $expiresAt",
|
||||
text = stringResource(Res.string.properties_expires, expiresAt),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -112,9 +114,9 @@ fun ResidenceImportConfirmDialog(
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Joining...")
|
||||
Text(stringResource(Res.string.properties_joining))
|
||||
} else {
|
||||
Text("Join")
|
||||
Text(stringResource(Res.string.properties_join_button))
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -123,7 +125,7 @@ fun ResidenceImportConfirmDialog(
|
||||
onClick = onDismiss,
|
||||
enabled = !isImporting
|
||||
) {
|
||||
Text("Cancel")
|
||||
Text(stringResource(Res.string.common_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -149,14 +151,14 @@ fun ResidenceImportSuccessDialog(
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Joined Residence",
|
||||
text = stringResource(Res.string.properties_join_success),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = "You now have access to $residenceName.",
|
||||
text = stringResource(Res.string.properties_join_success_message, residenceName),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
@@ -168,7 +170,7 @@ fun ResidenceImportSuccessDialog(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text("OK")
|
||||
Text(stringResource(Res.string.common_ok))
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -195,7 +197,7 @@ fun ResidenceImportErrorDialog(
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Join Failed",
|
||||
text = stringResource(Res.string.properties_join_failed),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center
|
||||
@@ -219,7 +221,7 @@ fun ResidenceImportErrorDialog(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text("Try Again")
|
||||
Text(stringResource(Res.string.common_try_again))
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
@@ -228,14 +230,14 @@ fun ResidenceImportErrorDialog(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text("OK")
|
||||
Text(stringResource(Res.string.common_ok))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
if (onRetry != null) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
Text(stringResource(Res.string.common_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import casera.composeapp.generated.resources.*
|
||||
import com.example.casera.data.DataManager
|
||||
import com.example.casera.models.TaskTemplate
|
||||
import com.example.casera.models.TaskTemplateCategoryGroup
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/**
|
||||
* Bottom sheet for browsing all task templates from backend.
|
||||
@@ -59,12 +61,12 @@ fun TaskTemplatesBrowserSheet(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Task Templates",
|
||||
text = stringResource(Res.string.templates_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Done")
|
||||
Text(stringResource(Res.string.templates_done))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,14 +77,14 @@ fun TaskTemplatesBrowserSheet(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
placeholder = { Text("Search templates...") },
|
||||
placeholder = { Text(stringResource(Res.string.templates_search_placeholder)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Search, contentDescription = null)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (searchText.isNotEmpty()) {
|
||||
IconButton(onClick = { searchText = "" }) {
|
||||
Icon(Icons.Default.Clear, contentDescription = "Clear")
|
||||
Icon(Icons.Default.Clear, contentDescription = stringResource(Res.string.templates_clear))
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -104,8 +106,13 @@ fun TaskTemplatesBrowserSheet(
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
val resultsText = if (filteredTemplates.size == 1) {
|
||||
stringResource(Res.string.templates_result)
|
||||
} else {
|
||||
stringResource(Res.string.templates_results)
|
||||
}
|
||||
Text(
|
||||
text = "${filteredTemplates.size} ${if (filteredTemplates.size == 1) "result" else "results"}",
|
||||
text = "${filteredTemplates.size} $resultsText",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
@@ -226,7 +233,7 @@ private fun CategoryHeader(
|
||||
// Expand/collapse indicator
|
||||
Icon(
|
||||
imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = if (isExpanded) "Collapse" else "Expand",
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
@@ -288,7 +295,7 @@ private fun TaskTemplateItem(
|
||||
// Add indicator
|
||||
Icon(
|
||||
imageVector = Icons.Default.AddCircleOutline,
|
||||
contentDescription = "Add",
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
@@ -311,12 +318,12 @@ private fun EmptySearchState() {
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
Text(
|
||||
text = "No Templates Found",
|
||||
text = stringResource(Res.string.templates_no_results_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "Try a different search term",
|
||||
text = stringResource(Res.string.templates_no_results_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -339,12 +346,12 @@ private fun EmptyTemplatesState() {
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
Text(
|
||||
text = "No Templates Available",
|
||||
text = stringResource(Res.string.templates_empty_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "Templates will appear here once loaded",
|
||||
text = stringResource(Res.string.templates_empty_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.example.casera.ui.components.auth
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* Google Sign In button - only shows on Android platform.
|
||||
* On other platforms, this composable shows nothing.
|
||||
*/
|
||||
@Composable
|
||||
expect fun GoogleSignInButton(
|
||||
onSignInStarted: () -> Unit,
|
||||
onSignInSuccess: (idToken: String) -> Unit,
|
||||
onSignInError: (message: String) -> Unit,
|
||||
enabled: Boolean = true
|
||||
)
|
||||
@@ -21,6 +21,8 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.example.casera.ui.theme.*
|
||||
import com.example.casera.platform.HapticFeedbackType
|
||||
import com.example.casera.platform.rememberHapticFeedback
|
||||
|
||||
/**
|
||||
* ThemePickerDialog - Shows all available themes in a grid
|
||||
@@ -52,6 +54,8 @@ fun ThemePickerDialog(
|
||||
onThemeSelected: (ThemeColors) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val hapticFeedback = rememberHapticFeedback()
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(AppRadius.lg),
|
||||
@@ -84,7 +88,10 @@ fun ThemePickerDialog(
|
||||
ThemeCard(
|
||||
theme = theme,
|
||||
isSelected = theme.id == currentTheme.id,
|
||||
onClick = { onThemeSelected(theme) }
|
||||
onClick = {
|
||||
hapticFeedback.perform(HapticFeedbackType.Selection)
|
||||
onThemeSelected(theme)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import com.example.casera.utils.SubscriptionHelper
|
||||
@Composable
|
||||
fun DocumentsTabContent(
|
||||
state: ApiResult<List<Document>>,
|
||||
filteredDocuments: List<Document> = emptyList(),
|
||||
isWarrantyTab: Boolean,
|
||||
onDocumentClick: (Int) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
@@ -48,7 +49,8 @@ fun DocumentsTabContent(
|
||||
}
|
||||
}
|
||||
is ApiResult.Success -> {
|
||||
val documents = state.data
|
||||
// Use filteredDocuments if provided, otherwise fall back to state.data
|
||||
val documents = if (filteredDocuments.isNotEmpty() || state.data.isEmpty()) filteredDocuments else state.data
|
||||
if (documents.isEmpty()) {
|
||||
if (shouldShowUpgradePrompt) {
|
||||
// Free tier users see upgrade prompt
|
||||
|
||||
@@ -8,7 +8,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import casera.composeapp.generated.resources.*
|
||||
import com.example.casera.viewmodel.TaskViewModel
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
// MARK: - Edit Task Button
|
||||
@Composable
|
||||
@@ -18,6 +20,8 @@ fun EditTaskButton(
|
||||
onError: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val editText = stringResource(Res.string.common_edit)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
// Edit navigates to edit screen - handled by parent
|
||||
@@ -31,11 +35,11 @@ fun EditTaskButton(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = "Edit",
|
||||
contentDescription = editText,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Edit", style = MaterialTheme.typography.labelLarge)
|
||||
Text(editText, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,13 +52,16 @@ fun CancelTaskButton(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: TaskViewModel = viewModel { TaskViewModel() }
|
||||
) {
|
||||
val cancelText = stringResource(Res.string.tasks_cancel)
|
||||
val errorMessage = stringResource(Res.string.tasks_failed_to_cancel)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
viewModel.cancelTask(taskId) { success ->
|
||||
if (success) {
|
||||
onCompletion()
|
||||
} else {
|
||||
onError("Failed to cancel task")
|
||||
onError(errorMessage)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -65,11 +72,11 @@ fun CancelTaskButton(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Cancel,
|
||||
contentDescription = "Cancel",
|
||||
contentDescription = cancelText,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Cancel", style = MaterialTheme.typography.labelLarge)
|
||||
Text(cancelText, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,13 +89,16 @@ fun UncancelTaskButton(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: TaskViewModel = viewModel { TaskViewModel() }
|
||||
) {
|
||||
val restoreText = stringResource(Res.string.tasks_uncancel)
|
||||
val errorMessage = stringResource(Res.string.tasks_failed_to_restore)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.uncancelTask(taskId) { success ->
|
||||
if (success) {
|
||||
onCompletion()
|
||||
} else {
|
||||
onError("Failed to restore task")
|
||||
onError(errorMessage)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -99,11 +109,11 @@ fun UncancelTaskButton(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Undo,
|
||||
contentDescription = "Restore",
|
||||
contentDescription = restoreText,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Restore", style = MaterialTheme.typography.labelLarge)
|
||||
Text(restoreText, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,13 +126,16 @@ fun MarkInProgressButton(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: TaskViewModel = viewModel { TaskViewModel() }
|
||||
) {
|
||||
val inProgressText = stringResource(Res.string.tasks_in_progress_label)
|
||||
val errorMessage = stringResource(Res.string.tasks_failed_to_mark_in_progress)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
viewModel.markInProgress(taskId) { success ->
|
||||
if (success) {
|
||||
onCompletion()
|
||||
} else {
|
||||
onError("Failed to mark task in progress")
|
||||
onError(errorMessage)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -133,11 +146,11 @@ fun MarkInProgressButton(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PlayCircle,
|
||||
contentDescription = "Mark In Progress",
|
||||
contentDescription = inProgressText,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("In Progress", style = MaterialTheme.typography.labelLarge)
|
||||
Text(inProgressText, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +162,8 @@ fun CompleteTaskButton(
|
||||
onError: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val completeText = stringResource(Res.string.tasks_mark_complete)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
// Complete shows dialog - handled by parent
|
||||
@@ -161,11 +176,11 @@ fun CompleteTaskButton(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = "Complete",
|
||||
contentDescription = completeText,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Complete", style = MaterialTheme.typography.labelLarge)
|
||||
Text(completeText, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,13 +193,16 @@ fun ArchiveTaskButton(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: TaskViewModel = viewModel { TaskViewModel() }
|
||||
) {
|
||||
val archiveText = stringResource(Res.string.tasks_archive)
|
||||
val errorMessage = stringResource(Res.string.tasks_failed_to_archive)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
viewModel.archiveTask(taskId) { success ->
|
||||
if (success) {
|
||||
onCompletion()
|
||||
} else {
|
||||
onError("Failed to archive task")
|
||||
onError(errorMessage)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -195,11 +213,11 @@ fun ArchiveTaskButton(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Archive,
|
||||
contentDescription = "Archive",
|
||||
contentDescription = archiveText,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Archive", style = MaterialTheme.typography.labelLarge)
|
||||
Text(archiveText, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,13 +230,16 @@ fun UnarchiveTaskButton(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: TaskViewModel = viewModel { TaskViewModel() }
|
||||
) {
|
||||
val unarchiveText = stringResource(Res.string.tasks_unarchive)
|
||||
val errorMessage = stringResource(Res.string.tasks_failed_to_unarchive)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.unarchiveTask(taskId) { success ->
|
||||
if (success) {
|
||||
onCompletion()
|
||||
} else {
|
||||
onError("Failed to unarchive task")
|
||||
onError(errorMessage)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -230,10 +251,10 @@ fun UnarchiveTaskButton(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Unarchive,
|
||||
contentDescription = "Unarchive",
|
||||
contentDescription = unarchiveText,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Unarchive", style = MaterialTheme.typography.labelLarge)
|
||||
Text(unarchiveText, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import casera.composeapp.generated.resources.*
|
||||
import com.example.casera.models.TaskDetail
|
||||
import com.example.casera.models.TaskCategory
|
||||
import com.example.casera.models.TaskPriority
|
||||
import com.example.casera.models.TaskFrequency
|
||||
import com.example.casera.models.TaskCompletion
|
||||
import com.example.casera.util.DateUtils
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.jetbrains.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Composable
|
||||
@@ -115,7 +117,7 @@ fun TaskCard(
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "IN PROGRESS",
|
||||
text = stringResource(Res.string.tasks_card_in_progress),
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = statusColor
|
||||
@@ -161,7 +163,7 @@ fun TaskCard(
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = DateUtils.formatDate(task.nextScheduledDate ?: task.dueDate) ?: "N/A",
|
||||
text = DateUtils.formatDate(task.nextScheduledDate ?: task.dueDate) ?: stringResource(Res.string.tasks_card_not_available),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -225,7 +227,7 @@ fun TaskCard(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Actions",
|
||||
text = stringResource(Res.string.tasks_card_actions),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
@@ -337,7 +339,7 @@ private fun getActionMenuItem(
|
||||
"mark_in_progress" -> {
|
||||
onMarkInProgressClick?.let {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Mark In Progress") },
|
||||
text = { Text(stringResource(Res.string.tasks_card_mark_in_progress)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||
},
|
||||
@@ -351,7 +353,7 @@ private fun getActionMenuItem(
|
||||
"complete" -> {
|
||||
onCompleteClick?.let {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Complete Task") },
|
||||
text = { Text(stringResource(Res.string.tasks_card_complete_task)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.CheckCircle, contentDescription = null)
|
||||
},
|
||||
@@ -365,7 +367,7 @@ private fun getActionMenuItem(
|
||||
"edit" -> {
|
||||
onEditClick?.let {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Edit Task") },
|
||||
text = { Text(stringResource(Res.string.tasks_card_edit_task)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Edit, contentDescription = null)
|
||||
},
|
||||
@@ -379,7 +381,7 @@ private fun getActionMenuItem(
|
||||
"cancel" -> {
|
||||
onCancelClick?.let {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Cancel Task") },
|
||||
text = { Text(stringResource(Res.string.tasks_card_cancel_task)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Cancel,
|
||||
@@ -397,7 +399,7 @@ private fun getActionMenuItem(
|
||||
"uncancel" -> {
|
||||
onUncancelClick?.let {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Restore Task") },
|
||||
text = { Text(stringResource(Res.string.tasks_card_restore_task)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Undo, contentDescription = null)
|
||||
},
|
||||
@@ -411,7 +413,7 @@ private fun getActionMenuItem(
|
||||
"archive" -> {
|
||||
onArchiveClick?.let {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Archive Task") },
|
||||
text = { Text(stringResource(Res.string.tasks_card_archive_task)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Archive, contentDescription = null)
|
||||
},
|
||||
@@ -425,7 +427,7 @@ private fun getActionMenuItem(
|
||||
"unarchive" -> {
|
||||
onUnarchiveClick?.let {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Unarchive Task") },
|
||||
text = { Text(stringResource(Res.string.tasks_card_unarchive_task)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Unarchive, contentDescription = null)
|
||||
},
|
||||
@@ -498,7 +500,7 @@ fun CompletionCard(completion: TaskCompletion) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "By: ${contractor.name}",
|
||||
text = stringResource(Res.string.tasks_card_completed_by, contractor.name),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
@@ -514,7 +516,7 @@ fun CompletionCard(completion: TaskCompletion) {
|
||||
} ?: completion.completedByName?.let {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "By: $it",
|
||||
text = stringResource(Res.string.tasks_card_completed_by, it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
@@ -522,7 +524,7 @@ fun CompletionCard(completion: TaskCompletion) {
|
||||
|
||||
completion.actualCost?.let {
|
||||
Text(
|
||||
text = "Cost: $$it",
|
||||
text = stringResource(Res.string.tasks_card_cost, it.toString()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
fontWeight = FontWeight.Medium
|
||||
@@ -559,7 +561,7 @@ fun CompletionCard(completion: TaskCompletion) {
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "View Photos (${completion.images?.size ?: 0})",
|
||||
text = stringResource(Res.string.tasks_card_view_photos, completion.images?.size ?: 0),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
@@ -258,10 +258,20 @@ fun DynamicTaskKanbanView(
|
||||
onArchiveTask: ((TaskDetail) -> Unit)?,
|
||||
onUnarchiveTask: ((TaskDetail) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
bottomPadding: androidx.compose.ui.unit.Dp = 0.dp
|
||||
bottomPadding: androidx.compose.ui.unit.Dp = 0.dp,
|
||||
scrollToColumnIndex: Int? = null,
|
||||
onScrollComplete: () -> Unit = {}
|
||||
) {
|
||||
val pagerState = rememberPagerState(pageCount = { columns.size })
|
||||
|
||||
// Handle scrolling to a specific column when requested (e.g., from push notification)
|
||||
LaunchedEffect(scrollToColumnIndex) {
|
||||
if (scrollToColumnIndex != null && scrollToColumnIndex in columns.indices) {
|
||||
pagerState.animateScrollToPage(scrollToColumnIndex)
|
||||
onScrollComplete()
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
|
||||
@@ -32,7 +32,9 @@ fun AllTasksScreen(
|
||||
viewModel: TaskViewModel = viewModel { TaskViewModel() },
|
||||
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() },
|
||||
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
|
||||
bottomNavBarPadding: androidx.compose.ui.unit.Dp = 0.dp
|
||||
bottomNavBarPadding: androidx.compose.ui.unit.Dp = 0.dp,
|
||||
navigateToTaskId: Int? = null,
|
||||
onClearNavigateToTask: () -> Unit = {}
|
||||
) {
|
||||
val tasksState by viewModel.tasksState.collectAsState()
|
||||
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
|
||||
@@ -43,11 +45,32 @@ fun AllTasksScreen(
|
||||
var showNewTaskDialog by remember { mutableStateOf(false) }
|
||||
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
|
||||
|
||||
// Track which column to scroll to (from push notification navigation)
|
||||
var scrollToColumnIndex by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadTasks()
|
||||
residenceViewModel.loadMyResidences()
|
||||
}
|
||||
|
||||
// When tasks load and we have a pending navigation, find the column containing the task
|
||||
LaunchedEffect(navigateToTaskId, tasksState) {
|
||||
if (navigateToTaskId != null && tasksState is ApiResult.Success) {
|
||||
val taskData = (tasksState as ApiResult.Success).data
|
||||
// Find which column contains the task
|
||||
taskData.columns.forEachIndexed { index, column ->
|
||||
if (column.tasks.any { it.id == navigateToTaskId }) {
|
||||
println("📬 Found task $navigateToTaskId in column $index '${column.name}'")
|
||||
scrollToColumnIndex = index
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
// Task not found in any column
|
||||
println("📬 Task $navigateToTaskId not found in any column")
|
||||
onClearNavigateToTask()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle completion success
|
||||
LaunchedEffect(completionState) {
|
||||
when (completionState) {
|
||||
@@ -224,7 +247,12 @@ fun AllTasksScreen(
|
||||
}
|
||||
},
|
||||
modifier = Modifier,
|
||||
bottomPadding = bottomNavBarPadding
|
||||
bottomPadding = bottomNavBarPadding,
|
||||
scrollToColumnIndex = scrollToColumnIndex,
|
||||
onScrollComplete = {
|
||||
scrollToColumnIndex = null
|
||||
onClearNavigateToTask()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ import com.example.casera.platform.rememberImagePicker
|
||||
import com.example.casera.platform.rememberCameraPicker
|
||||
import com.example.casera.analytics.PostHogAnalytics
|
||||
import com.example.casera.analytics.AnalyticsEvents
|
||||
import casera.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -184,16 +186,16 @@ fun DocumentFormScreen(
|
||||
title = {
|
||||
Text(
|
||||
when {
|
||||
isEditMode && isWarranty -> "Edit Warranty"
|
||||
isEditMode -> "Edit Document"
|
||||
isWarranty -> "Add Warranty"
|
||||
else -> "Add Document"
|
||||
isEditMode && isWarranty -> stringResource(Res.string.documents_form_edit_warranty)
|
||||
isEditMode -> stringResource(Res.string.documents_form_edit_document)
|
||||
isWarranty -> stringResource(Res.string.documents_form_add_warranty)
|
||||
else -> stringResource(Res.string.documents_form_add_document)
|
||||
}
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, "Back")
|
||||
Icon(Icons.Default.ArrowBack, stringResource(Res.string.common_back))
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -231,10 +233,10 @@ fun DocumentFormScreen(
|
||||
onExpandedChange = { residenceExpanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedResidence?.name ?: "Select Residence",
|
||||
value = selectedResidence?.name ?: stringResource(Res.string.documents_form_select_residence),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Residence *") },
|
||||
label = { Text(stringResource(Res.string.documents_form_residence_required)) },
|
||||
isError = residenceError.isNotEmpty(),
|
||||
supportingText = if (residenceError.isNotEmpty()) {
|
||||
{ Text(residenceError) }
|
||||
@@ -261,7 +263,7 @@ fun DocumentFormScreen(
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
Text(
|
||||
"Failed to load residences: ${com.example.casera.util.ErrorMessageParser.parse((residencesState as ApiResult.Error).message)}",
|
||||
stringResource(Res.string.documents_form_failed_to_load_residences, com.example.casera.util.ErrorMessageParser.parse((residencesState as ApiResult.Error).message)),
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
@@ -278,7 +280,7 @@ fun DocumentFormScreen(
|
||||
value = DocumentType.fromValue(selectedDocumentType).displayName,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Document Type *") },
|
||||
label = { Text(stringResource(Res.string.documents_form_document_type_required)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = documentTypeExpanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor()
|
||||
)
|
||||
@@ -305,7 +307,7 @@ fun DocumentFormScreen(
|
||||
title = it
|
||||
titleError = ""
|
||||
},
|
||||
label = { Text("Title *") },
|
||||
label = { Text(stringResource(Res.string.documents_form_title_required)) },
|
||||
isError = titleError.isNotEmpty(),
|
||||
supportingText = if (titleError.isNotEmpty()) {
|
||||
{ Text(titleError) }
|
||||
@@ -321,7 +323,7 @@ fun DocumentFormScreen(
|
||||
itemName = it
|
||||
itemNameError = ""
|
||||
},
|
||||
label = { Text("Item Name *") },
|
||||
label = { Text(stringResource(Res.string.documents_form_item_name_required)) },
|
||||
isError = itemNameError.isNotEmpty(),
|
||||
supportingText = if (itemNameError.isNotEmpty()) {
|
||||
{ Text(itemNameError) }
|
||||
@@ -332,14 +334,14 @@ fun DocumentFormScreen(
|
||||
OutlinedTextField(
|
||||
value = modelNumber,
|
||||
onValueChange = { modelNumber = it },
|
||||
label = { Text("Model Number") },
|
||||
label = { Text(stringResource(Res.string.documents_form_model_number)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = serialNumber,
|
||||
onValueChange = { serialNumber = it },
|
||||
label = { Text("Serial Number") },
|
||||
label = { Text(stringResource(Res.string.documents_form_serial_number)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
@@ -349,7 +351,7 @@ fun DocumentFormScreen(
|
||||
provider = it
|
||||
providerError = ""
|
||||
},
|
||||
label = { Text("Provider/Company *") },
|
||||
label = { Text(stringResource(Res.string.documents_form_provider_required)) },
|
||||
isError = providerError.isNotEmpty(),
|
||||
supportingText = if (providerError.isNotEmpty()) {
|
||||
{ Text(providerError) }
|
||||
@@ -360,14 +362,14 @@ fun DocumentFormScreen(
|
||||
OutlinedTextField(
|
||||
value = providerContact,
|
||||
onValueChange = { providerContact = it },
|
||||
label = { Text("Provider Contact") },
|
||||
label = { Text(stringResource(Res.string.documents_form_provider_contact)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = claimPhone,
|
||||
onValueChange = { claimPhone = it },
|
||||
label = { Text("Claim Phone") },
|
||||
label = { Text(stringResource(Res.string.documents_form_claim_phone)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
@@ -375,7 +377,7 @@ fun DocumentFormScreen(
|
||||
OutlinedTextField(
|
||||
value = claimEmail,
|
||||
onValueChange = { claimEmail = it },
|
||||
label = { Text("Claim Email") },
|
||||
label = { Text(stringResource(Res.string.documents_form_claim_email)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
@@ -383,7 +385,7 @@ fun DocumentFormScreen(
|
||||
OutlinedTextField(
|
||||
value = claimWebsite,
|
||||
onValueChange = { claimWebsite = it },
|
||||
label = { Text("Claim Website") },
|
||||
label = { Text(stringResource(Res.string.documents_form_claim_website)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
@@ -391,24 +393,24 @@ fun DocumentFormScreen(
|
||||
OutlinedTextField(
|
||||
value = purchaseDate,
|
||||
onValueChange = { purchaseDate = it },
|
||||
label = { Text("Purchase Date (YYYY-MM-DD)") },
|
||||
placeholder = { Text("2024-01-15") },
|
||||
label = { Text(stringResource(Res.string.documents_form_purchase_date)) },
|
||||
placeholder = { Text(stringResource(Res.string.documents_form_date_placeholder)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = startDate,
|
||||
onValueChange = { startDate = it },
|
||||
label = { Text("Warranty Start Date (YYYY-MM-DD)") },
|
||||
placeholder = { Text("2024-01-15") },
|
||||
label = { Text(stringResource(Res.string.documents_form_warranty_start)) },
|
||||
placeholder = { Text(stringResource(Res.string.documents_form_date_placeholder)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = endDate,
|
||||
onValueChange = { endDate = it },
|
||||
label = { Text("Warranty End Date (YYYY-MM-DD) *") },
|
||||
placeholder = { Text("2025-01-15") },
|
||||
label = { Text(stringResource(Res.string.documents_form_warranty_end_required)) },
|
||||
placeholder = { Text(stringResource(Res.string.documents_form_date_placeholder_end)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
@@ -417,7 +419,7 @@ fun DocumentFormScreen(
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Description") },
|
||||
label = { Text(stringResource(Res.string.documents_form_description)) },
|
||||
minLines = 3,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
@@ -429,10 +431,10 @@ fun DocumentFormScreen(
|
||||
onExpandedChange = { categoryExpanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedCategory?.let { DocumentCategory.fromValue(it).displayName } ?: "Select Category",
|
||||
value = selectedCategory?.let { DocumentCategory.fromValue(it).displayName } ?: stringResource(Res.string.documents_form_select_category),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Category") },
|
||||
label = { Text(stringResource(Res.string.documents_form_category)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = categoryExpanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor()
|
||||
)
|
||||
@@ -441,7 +443,7 @@ fun DocumentFormScreen(
|
||||
onDismissRequest = { categoryExpanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("None") },
|
||||
text = { Text(stringResource(Res.string.documents_form_category_none)) },
|
||||
onClick = {
|
||||
selectedCategory = null
|
||||
categoryExpanded = false
|
||||
@@ -464,8 +466,8 @@ fun DocumentFormScreen(
|
||||
OutlinedTextField(
|
||||
value = tags,
|
||||
onValueChange = { tags = it },
|
||||
label = { Text("Tags") },
|
||||
placeholder = { Text("tag1, tag2, tag3") },
|
||||
label = { Text(stringResource(Res.string.documents_form_tags)) },
|
||||
placeholder = { Text(stringResource(Res.string.documents_form_tags_placeholder)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
@@ -473,7 +475,7 @@ fun DocumentFormScreen(
|
||||
OutlinedTextField(
|
||||
value = notes,
|
||||
onValueChange = { notes = it },
|
||||
label = { Text("Notes") },
|
||||
label = { Text(stringResource(Res.string.documents_form_notes)) },
|
||||
minLines = 3,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
@@ -485,7 +487,7 @@ fun DocumentFormScreen(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Active")
|
||||
Text(stringResource(Res.string.documents_form_active))
|
||||
Switch(
|
||||
checked = isActive,
|
||||
onCheckedChange = { isActive = it }
|
||||
@@ -506,7 +508,7 @@ fun DocumentFormScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Existing Photos (${existingImages.size})",
|
||||
stringResource(Res.string.documents_form_existing_photos, existingImages.size),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
@@ -538,7 +540,11 @@ fun DocumentFormScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"${if (isEditMode) "New " else ""}Photos (${selectedImages.size}/$maxImages)",
|
||||
if (isEditMode) {
|
||||
stringResource(Res.string.documents_form_new_photos, selectedImages.size, maxImages)
|
||||
} else {
|
||||
stringResource(Res.string.documents_form_photos, selectedImages.size, maxImages)
|
||||
},
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
@@ -552,7 +558,7 @@ fun DocumentFormScreen(
|
||||
) {
|
||||
Icon(Icons.Default.CameraAlt, null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Camera")
|
||||
Text(stringResource(Res.string.documents_form_camera))
|
||||
}
|
||||
|
||||
Button(
|
||||
@@ -562,7 +568,7 @@ fun DocumentFormScreen(
|
||||
) {
|
||||
Icon(Icons.Default.Photo, null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Gallery")
|
||||
Text(stringResource(Res.string.documents_form_gallery))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,7 +593,7 @@ fun DocumentFormScreen(
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
"Image ${index + 1}",
|
||||
stringResource(Res.string.documents_form_image_number, index + 1),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
@@ -598,7 +604,7 @@ fun DocumentFormScreen(
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove image",
|
||||
contentDescription = stringResource(Res.string.documents_form_remove_image),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
@@ -625,6 +631,12 @@ fun DocumentFormScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Error messages (need to be defined outside onClick)
|
||||
val selectResidenceError = stringResource(Res.string.documents_form_select_residence_error)
|
||||
val titleRequiredError = stringResource(Res.string.documents_form_title_error)
|
||||
val itemRequiredError = stringResource(Res.string.documents_form_item_name_error)
|
||||
val providerRequiredError = stringResource(Res.string.documents_form_provider_error)
|
||||
|
||||
// Save Button
|
||||
Button(
|
||||
onClick = {
|
||||
@@ -634,7 +646,7 @@ fun DocumentFormScreen(
|
||||
// Determine the actual residenceId to use
|
||||
val actualResidenceId = if (needsResidenceSelection) {
|
||||
if (selectedResidence == null) {
|
||||
residenceError = "Please select a residence"
|
||||
residenceError = selectResidenceError
|
||||
hasError = true
|
||||
-1
|
||||
} else {
|
||||
@@ -645,17 +657,17 @@ fun DocumentFormScreen(
|
||||
}
|
||||
|
||||
if (title.isBlank()) {
|
||||
titleError = "Title is required"
|
||||
titleError = titleRequiredError
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (isWarranty) {
|
||||
if (itemName.isBlank()) {
|
||||
itemNameError = "Item name is required for warranties"
|
||||
itemNameError = itemRequiredError
|
||||
hasError = true
|
||||
}
|
||||
if (provider.isBlank()) {
|
||||
providerError = "Provider is required for warranties"
|
||||
providerError = providerRequiredError
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
@@ -722,10 +734,10 @@ fun DocumentFormScreen(
|
||||
} else {
|
||||
Text(
|
||||
when {
|
||||
isEditMode && isWarranty -> "Update Warranty"
|
||||
isEditMode -> "Update Document"
|
||||
isWarranty -> "Add Warranty"
|
||||
else -> "Add Document"
|
||||
isEditMode && isWarranty -> stringResource(Res.string.documents_form_update_warranty)
|
||||
isEditMode -> stringResource(Res.string.documents_form_update_document)
|
||||
isWarranty -> stringResource(Res.string.documents_form_add_warranty)
|
||||
else -> stringResource(Res.string.documents_form_add_document)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,27 +51,27 @@ fun DocumentsScreen(
|
||||
LaunchedEffect(Unit) {
|
||||
// Track screen view
|
||||
PostHogAnalytics.screen(AnalyticsEvents.DOCUMENTS_SCREEN_SHOWN)
|
||||
// Load warranties by default (documentType="warranty")
|
||||
documentViewModel.loadDocuments(
|
||||
residenceId = residenceId,
|
||||
documentType = "warranty",
|
||||
isActive = true
|
||||
)
|
||||
// Load all documents once - filtering happens client-side
|
||||
documentViewModel.loadAllDocuments(residenceId = residenceId)
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedTab, selectedCategory, selectedDocType, showActiveOnly) {
|
||||
if (selectedTab == DocumentTab.WARRANTIES) {
|
||||
documentViewModel.loadDocuments(
|
||||
residenceId = residenceId,
|
||||
documentType = "warranty",
|
||||
category = selectedCategory,
|
||||
isActive = if (showActiveOnly) true else null
|
||||
)
|
||||
} else {
|
||||
documentViewModel.loadDocuments(
|
||||
residenceId = residenceId,
|
||||
documentType = selectedDocType
|
||||
)
|
||||
// Client-side filtering - no API calls on filter changes
|
||||
val filteredDocuments = remember(documentsState, selectedTab, selectedCategory, selectedDocType, showActiveOnly) {
|
||||
val allDocuments = (documentsState as? com.example.casera.network.ApiResult.Success)?.data ?: emptyList()
|
||||
allDocuments.filter { document ->
|
||||
val matchesTab = if (selectedTab == DocumentTab.WARRANTIES) {
|
||||
document.documentType == "warranty"
|
||||
} else {
|
||||
document.documentType != "warranty"
|
||||
}
|
||||
val matchesCategory = selectedCategory == null || document.category == selectedCategory
|
||||
val matchesDocType = selectedDocType == null || document.documentType == selectedDocType
|
||||
val matchesActive = if (selectedTab == DocumentTab.WARRANTIES && showActiveOnly) {
|
||||
document.isActive == true
|
||||
} else {
|
||||
true
|
||||
}
|
||||
matchesTab && matchesCategory && matchesDocType && matchesActive
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,39 +212,18 @@ fun DocumentsScreen(
|
||||
onNavigateBack = onNavigateBack
|
||||
)
|
||||
} else {
|
||||
// Pro users see normal content
|
||||
when (selectedTab) {
|
||||
DocumentTab.WARRANTIES -> {
|
||||
DocumentsTabContent(
|
||||
state = documentsState,
|
||||
isWarrantyTab = true,
|
||||
onDocumentClick = onNavigateToDocumentDetail,
|
||||
onRetry = {
|
||||
documentViewModel.loadDocuments(
|
||||
residenceId = residenceId,
|
||||
documentType = "warranty",
|
||||
category = selectedCategory,
|
||||
isActive = if (showActiveOnly) true else null
|
||||
)
|
||||
},
|
||||
onNavigateBack = onNavigateBack
|
||||
)
|
||||
}
|
||||
DocumentTab.DOCUMENTS -> {
|
||||
DocumentsTabContent(
|
||||
state = documentsState,
|
||||
isWarrantyTab = false,
|
||||
onDocumentClick = onNavigateToDocumentDetail,
|
||||
onRetry = {
|
||||
documentViewModel.loadDocuments(
|
||||
residenceId = residenceId,
|
||||
documentType = selectedDocType
|
||||
)
|
||||
},
|
||||
onNavigateBack = onNavigateBack
|
||||
)
|
||||
}
|
||||
}
|
||||
// Pro users see normal content - use client-side filtered documents
|
||||
DocumentsTabContent(
|
||||
state = documentsState,
|
||||
filteredDocuments = filteredDocuments,
|
||||
isWarrantyTab = selectedTab == DocumentTab.WARRANTIES,
|
||||
onDocumentClick = onNavigateToDocumentDetail,
|
||||
onRetry = {
|
||||
// Reload all documents on pull-to-refresh
|
||||
documentViewModel.loadAllDocuments(residenceId = residenceId)
|
||||
},
|
||||
onNavigateBack = onNavigateBack
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.example.casera.ui.components.HandleErrors
|
||||
import com.example.casera.ui.components.auth.AuthHeader
|
||||
import com.example.casera.ui.components.auth.GoogleSignInButton
|
||||
import com.example.casera.ui.components.common.ErrorCard
|
||||
import com.example.casera.viewmodel.AuthViewModel
|
||||
import com.example.casera.network.ApiResult
|
||||
@@ -41,7 +42,9 @@ fun LoginScreen(
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
var googleSignInError by remember { mutableStateOf<String?>(null) }
|
||||
val loginState by viewModel.loginState.collectAsState()
|
||||
val googleSignInState by viewModel.googleSignInState.collectAsState()
|
||||
|
||||
// Handle errors for login
|
||||
loginState.HandleErrors(
|
||||
@@ -63,12 +66,32 @@ fun LoginScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val errorMessage = when (loginState) {
|
||||
is ApiResult.Error -> com.example.casera.util.ErrorMessageParser.parse((loginState as ApiResult.Error).message)
|
||||
// Handle Google Sign In state changes
|
||||
LaunchedEffect(googleSignInState) {
|
||||
when (googleSignInState) {
|
||||
is ApiResult.Success -> {
|
||||
val response = (googleSignInState as ApiResult.Success).data
|
||||
// Track successful Google sign in
|
||||
PostHogAnalytics.capture(AnalyticsEvents.USER_SIGNED_IN, mapOf("method" to "google", "is_new_user" to response.isNewUser))
|
||||
PostHogAnalytics.identify(response.user.id.toString(), mapOf("email" to (response.user.email ?: ""), "username" to (response.user.username ?: "")))
|
||||
viewModel.resetGoogleSignInState()
|
||||
onLoginSuccess(response.user)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
googleSignInError = com.example.casera.util.ErrorMessageParser.parse((googleSignInState as ApiResult.Error).message)
|
||||
viewModel.resetGoogleSignInState()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
val errorMessage = when {
|
||||
loginState is ApiResult.Error -> com.example.casera.util.ErrorMessageParser.parse((loginState as ApiResult.Error).message)
|
||||
googleSignInError != null -> googleSignInError ?: ""
|
||||
else -> ""
|
||||
}
|
||||
|
||||
val isLoading = loginState is ApiResult.Loading
|
||||
val isLoading = loginState is ApiResult.Loading || googleSignInState is ApiResult.Loading
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -140,6 +163,11 @@ fun LoginScreen(
|
||||
|
||||
ErrorCard(message = errorMessage)
|
||||
|
||||
// Clear Google error when user starts typing
|
||||
LaunchedEffect(username, password) {
|
||||
googleSignInError = null
|
||||
}
|
||||
|
||||
// Gradient button
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -191,6 +219,41 @@ fun LoginScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Divider with "or"
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
||||
)
|
||||
Text(
|
||||
text = "or",
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
||||
)
|
||||
}
|
||||
|
||||
// Google Sign In button (only shows on Android)
|
||||
GoogleSignInButton(
|
||||
onSignInStarted = {
|
||||
googleSignInError = null
|
||||
},
|
||||
onSignInSuccess = { idToken ->
|
||||
viewModel.googleSignIn(idToken)
|
||||
},
|
||||
onSignInError = { error ->
|
||||
googleSignInError = error
|
||||
},
|
||||
enabled = !isLoading
|
||||
)
|
||||
|
||||
TextButton(
|
||||
onClick = onNavigateToForgotPassword,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
|
||||
@@ -25,11 +25,23 @@ fun MainScreen(
|
||||
onAddResidence: () -> Unit,
|
||||
onNavigateToEditResidence: (Residence) -> Unit,
|
||||
onNavigateToEditTask: (com.example.casera.models.TaskDetail) -> Unit,
|
||||
onAddTask: () -> Unit
|
||||
onAddTask: () -> Unit,
|
||||
navigateToTaskId: Int? = null,
|
||||
onClearNavigateToTask: () -> Unit = {}
|
||||
) {
|
||||
var selectedTab by remember { mutableStateOf(0) }
|
||||
val navController = rememberNavController()
|
||||
|
||||
// When navigateToTaskId is set, switch to tasks tab
|
||||
LaunchedEffect(navigateToTaskId) {
|
||||
if (navigateToTaskId != null) {
|
||||
selectedTab = 1
|
||||
navController.navigate(MainTabTasksRoute) {
|
||||
popUpTo(MainTabResidencesRoute) { inclusive = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar(
|
||||
@@ -141,7 +153,7 @@ fun MainScreen(
|
||||
onAddResidence = onAddResidence,
|
||||
onLogout = onLogout,
|
||||
onNavigateToProfile = {
|
||||
selectedTab = 3
|
||||
// Don't change selectedTab since Profile isn't in the bottom nav
|
||||
navController.navigate(MainTabProfileRoute)
|
||||
}
|
||||
)
|
||||
@@ -153,7 +165,9 @@ fun MainScreen(
|
||||
AllTasksScreen(
|
||||
onNavigateToEditTask = onNavigateToEditTask,
|
||||
onAddTask = onAddTask,
|
||||
bottomNavBarPadding = paddingValues.calculateBottomPadding()
|
||||
bottomNavBarPadding = paddingValues.calculateBottomPadding(),
|
||||
navigateToTaskId = navigateToTaskId,
|
||||
onClearNavigateToTask = onClearNavigateToTask
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.example.casera.data.DataManager
|
||||
import com.example.casera.models.AppleSignInRequest
|
||||
import com.example.casera.models.AppleSignInResponse
|
||||
import com.example.casera.models.GoogleSignInRequest
|
||||
import com.example.casera.models.GoogleSignInResponse
|
||||
import com.example.casera.models.AuthResponse
|
||||
import com.example.casera.models.ForgotPasswordRequest
|
||||
import com.example.casera.models.ForgotPasswordResponse
|
||||
@@ -53,6 +55,9 @@ class AuthViewModel : ViewModel() {
|
||||
private val _appleSignInState = MutableStateFlow<ApiResult<AppleSignInResponse>>(ApiResult.Idle)
|
||||
val appleSignInState: StateFlow<ApiResult<AppleSignInResponse>> = _appleSignInState
|
||||
|
||||
private val _googleSignInState = MutableStateFlow<ApiResult<GoogleSignInResponse>>(ApiResult.Idle)
|
||||
val googleSignInState: StateFlow<ApiResult<GoogleSignInResponse>> = _googleSignInState
|
||||
|
||||
fun login(username: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_loginState.value = ApiResult.Loading
|
||||
@@ -241,6 +246,25 @@ class AuthViewModel : ViewModel() {
|
||||
_appleSignInState.value = ApiResult.Idle
|
||||
}
|
||||
|
||||
fun googleSignIn(idToken: String) {
|
||||
viewModelScope.launch {
|
||||
_googleSignInState.value = ApiResult.Loading
|
||||
val result = APILayer.googleSignIn(
|
||||
GoogleSignInRequest(idToken = idToken)
|
||||
)
|
||||
// APILayer.googleSignIn already stores token in DataManager
|
||||
_googleSignInState.value = when (result) {
|
||||
is ApiResult.Success -> ApiResult.Success(result.data)
|
||||
is ApiResult.Error -> result
|
||||
else -> ApiResult.Error("Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetGoogleSignInState() {
|
||||
_googleSignInState.value = ApiResult.Idle
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
// APILayer.logout clears DataManager
|
||||
|
||||
@@ -60,6 +60,23 @@ class DocumentViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all documents without any filters - filtering is done client-side.
|
||||
* This reduces API calls when switching tabs or applying filters.
|
||||
*/
|
||||
fun loadAllDocuments(
|
||||
residenceId: Int? = null,
|
||||
forceRefresh: Boolean = false
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_documentsState.value = ApiResult.Loading
|
||||
_documentsState.value = APILayer.getDocuments(
|
||||
residenceId = residenceId,
|
||||
forceRefresh = forceRefresh
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadDocumentDetail(id: Int) {
|
||||
viewModelScope.launch {
|
||||
_documentDetailState.value = ApiResult.Loading
|
||||
|
||||
@@ -9,6 +9,7 @@ import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import platform.Foundation.NSLocale
|
||||
import platform.Foundation.NSTimeZone
|
||||
import platform.Foundation.localTimeZone
|
||||
import platform.Foundation.preferredLanguages
|
||||
|
||||
actual fun getLocalhostAddress(): String = "127.0.0.1"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.casera.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
/**
|
||||
* iOS implementation - no-op since iOS haptics are handled by SwiftUI.
|
||||
* This is only used when running the shared Compose code on iOS
|
||||
* (which isn't the primary iOS UI).
|
||||
*/
|
||||
class IOSHapticFeedbackPerformer : HapticFeedbackPerformer {
|
||||
override fun perform(type: HapticFeedbackType) {
|
||||
// iOS haptic feedback is handled natively in SwiftUI views
|
||||
// This is a no-op for the Compose layer on iOS
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberHapticFeedback(): HapticFeedbackPerformer {
|
||||
return remember { IOSHapticFeedbackPerformer() }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.example.casera.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import org.jetbrains.skia.Image
|
||||
|
||||
@Composable
|
||||
actual fun rememberImageBitmap(imageData: ImageData): ImageBitmap? {
|
||||
return remember(imageData) {
|
||||
try {
|
||||
val skiaImage = Image.makeFromEncoded(imageData.bytes)
|
||||
skiaImage.toComposeImageBitmap()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.example.casera.ui.components.auth
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* iOS stub - Google Sign In is not available on iOS (use Apple Sign In instead)
|
||||
*/
|
||||
@Composable
|
||||
actual fun GoogleSignInButton(
|
||||
onSignInStarted: () -> Unit,
|
||||
onSignInSuccess: (idToken: String) -> Unit,
|
||||
onSignInError: (message: String) -> Unit,
|
||||
enabled: Boolean
|
||||
) {
|
||||
// No-op on iOS - Apple Sign In is used instead
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.example.casera.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
/**
|
||||
* JS/Web implementation - no-op since web doesn't have haptic feedback.
|
||||
*/
|
||||
class JsHapticFeedbackPerformer : HapticFeedbackPerformer {
|
||||
override fun perform(type: HapticFeedbackType) {
|
||||
// Web doesn't support haptic feedback
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberHapticFeedback(): HapticFeedbackPerformer {
|
||||
return remember { JsHapticFeedbackPerformer() }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.example.casera.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import org.jetbrains.skia.Image
|
||||
|
||||
@Composable
|
||||
actual fun rememberImageBitmap(imageData: ImageData): ImageBitmap? {
|
||||
return remember(imageData) {
|
||||
try {
|
||||
val skiaImage = Image.makeFromEncoded(imageData.bytes)
|
||||
skiaImage.toComposeImageBitmap()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.example.casera.ui.components.auth
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* JS stub - Google Sign In not implemented for web JS target
|
||||
*/
|
||||
@Composable
|
||||
actual fun GoogleSignInButton(
|
||||
onSignInStarted: () -> Unit,
|
||||
onSignInSuccess: (idToken: String) -> Unit,
|
||||
onSignInError: (message: String) -> Unit,
|
||||
enabled: Boolean
|
||||
) {
|
||||
// No-op on JS
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.example.casera.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
/**
|
||||
* JVM/Desktop implementation - no-op since desktop doesn't have haptic feedback.
|
||||
*/
|
||||
class JvmHapticFeedbackPerformer : HapticFeedbackPerformer {
|
||||
override fun perform(type: HapticFeedbackType) {
|
||||
// Desktop doesn't support haptic feedback
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberHapticFeedback(): HapticFeedbackPerformer {
|
||||
return remember { JvmHapticFeedbackPerformer() }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.example.casera.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import org.jetbrains.skia.Image
|
||||
|
||||
@Composable
|
||||
actual fun rememberImageBitmap(imageData: ImageData): ImageBitmap? {
|
||||
return remember(imageData) {
|
||||
try {
|
||||
val skiaImage = Image.makeFromEncoded(imageData.bytes)
|
||||
skiaImage.toComposeImageBitmap()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.example.casera.ui.components.auth
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* JVM/Desktop stub - Google Sign In not implemented for desktop
|
||||
*/
|
||||
@Composable
|
||||
actual fun GoogleSignInButton(
|
||||
onSignInStarted: () -> Unit,
|
||||
onSignInSuccess: (idToken: String) -> Unit,
|
||||
onSignInError: (message: String) -> Unit,
|
||||
enabled: Boolean
|
||||
) {
|
||||
// No-op on JVM/Desktop
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.example.casera.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
/**
|
||||
* WASM/Web implementation - no-op since web doesn't have haptic feedback.
|
||||
*/
|
||||
class WasmJsHapticFeedbackPerformer : HapticFeedbackPerformer {
|
||||
override fun perform(type: HapticFeedbackType) {
|
||||
// Web doesn't support haptic feedback
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberHapticFeedback(): HapticFeedbackPerformer {
|
||||
return remember { WasmJsHapticFeedbackPerformer() }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.example.casera.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import org.jetbrains.skia.Image
|
||||
|
||||
@Composable
|
||||
actual fun rememberImageBitmap(imageData: ImageData): ImageBitmap? {
|
||||
return remember(imageData) {
|
||||
try {
|
||||
val skiaImage = Image.makeFromEncoded(imageData.bytes)
|
||||
skiaImage.toComposeImageBitmap()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.example.casera.ui.components.auth
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* WASM stub - Google Sign In not implemented for web WASM target
|
||||
*/
|
||||
@Composable
|
||||
actual fun GoogleSignInButton(
|
||||
onSignInStarted: () -> Unit,
|
||||
onSignInSuccess: (idToken: String) -> Unit,
|
||||
onSignInError: (message: String) -> Unit,
|
||||
enabled: Boolean
|
||||
) {
|
||||
// No-op on WASM
|
||||
}
|
||||
Reference in New Issue
Block a user