From 97eed0eee90497c4fe7ceed0dc903a2aadf66d2a Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 8 Nov 2025 10:52:28 -0600 Subject: [PATCH] wip --- .../example/mycrib/network/ResidenceApi.kt | 89 ++++++++++++ .../ui/components/JoinResidenceDialog.kt | 134 ++++++++++++++++++ .../mycrib/ui/screens/ResidencesScreen.kt | 37 +++++ .../iosApp/Residence/JoinResidenceView.swift | 106 ++++++++++++++ .../iosApp/Residence/ResidencesListView.swift | 14 +- .../Subviews/Residence/ResidenceCard.swift | 2 + 6 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/JoinResidenceDialog.kt create mode 100644 iosApp/iosApp/Residence/JoinResidenceView.swift diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ResidenceApi.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ResidenceApi.kt index 6e5896a..1f346e6 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ResidenceApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ResidenceApi.kt @@ -125,6 +125,95 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) { ApiResult.Error(e.message ?: "Unknown error occurred") } } + + // Share Code Management + suspend fun generateShareCode(token: String, residenceId: Int): ApiResult { + return try { + val response = client.post("$baseUrl/residences/$residenceId/generate-share-code/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + val errorMessage = ErrorParser.parseError(response) + ApiResult.Error(errorMessage, response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun getShareCode(token: String, residenceId: Int): ApiResult { + return try { + val response = client.get("$baseUrl/residences/$residenceId/share-code/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + val errorMessage = ErrorParser.parseError(response) + ApiResult.Error(errorMessage, response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun joinWithCode(token: String, code: String): ApiResult { + return try { + val response = client.post("$baseUrl/residences/join-with-code/") { + header("Authorization", "Token $token") + contentType(ContentType.Application.Json) + setBody(JoinResidenceRequest(code)) + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + val errorMessage = ErrorParser.parseError(response) + ApiResult.Error(errorMessage, response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + // User Management + suspend fun getResidenceUsers(token: String, residenceId: Int): ApiResult { + return try { + val response = client.get("$baseUrl/residences/$residenceId/users/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + val errorMessage = ErrorParser.parseError(response) + ApiResult.Error(errorMessage, response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun removeUser(token: String, residenceId: Int, userId: Int): ApiResult { + return try { + val response = client.delete("$baseUrl/residences/$residenceId/users/$userId/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + val errorMessage = ErrorParser.parseError(response) + ApiResult.Error(errorMessage, response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } } @kotlinx.serialization.Serializable diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/JoinResidenceDialog.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/JoinResidenceDialog.kt new file mode 100644 index 0000000..0c79286 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/JoinResidenceDialog.kt @@ -0,0 +1,134 @@ +package com.mycrib.android.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import com.mycrib.shared.network.ApiResult +import com.mycrib.shared.network.ResidenceApi +import com.mycrib.storage.TokenStorage +import kotlinx.coroutines.launch + +@Composable +fun JoinResidenceDialog( + onDismiss: () -> Unit, + onJoined: () -> Unit = {} +) { + var shareCode by remember { mutableStateOf(TextFieldValue("")) } + var isJoining by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + val residenceApi = remember { ResidenceApi() } + val scope = rememberCoroutineScope() + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Join Residence") + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, "Close") + } + } + }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Enter the 6-character share code to join a residence", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 16.dp) + ) + + OutlinedTextField( + value = shareCode, + onValueChange = { + if (it.text.length <= 6) { + shareCode = it.copy(text = it.text.uppercase()) + error = null + } + }, + label = { Text("Share Code") }, + placeholder = { Text("ABC123") }, + singleLine = true, + enabled = !isJoining, + isError = error != null, + supportingText = { + if (error != null) { + Text( + text = error ?: "", + color = MaterialTheme.colorScheme.error + ) + } + }, + modifier = Modifier.fillMaxWidth() + ) + + if (isJoining) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } + }, + confirmButton = { + Button( + onClick = { + if (shareCode.text.length == 6) { + scope.launch { + isJoining = true + error = null + val token = TokenStorage.getToken() + if (token != null) { + when (val result = residenceApi.joinWithCode(token, shareCode.text)) { + is ApiResult.Success -> { + isJoining = false + onJoined() + onDismiss() + } + is ApiResult.Error -> { + error = result.message + isJoining = false + } + else -> { + isJoining = false + } + } + } else { + error = "Not authenticated" + isJoining = false + } + } + } else { + error = "Share code must be 6 characters" + } + }, + enabled = !isJoining && shareCode.text.length == 6 + ) { + Text("Join") + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + enabled = !isJoining + ) { + Text("Cancel") + } + } + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt index 076e28f..c1113ef 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.mycrib.android.ui.components.JoinResidenceDialog import com.mycrib.android.ui.components.common.StatItem import com.mycrib.android.ui.components.residence.TaskStatChip import com.mycrib.android.viewmodel.ResidenceViewModel @@ -29,11 +30,24 @@ fun ResidencesScreen( viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() } ) { val myResidencesState by viewModel.myResidencesState.collectAsState() + var showJoinDialog by remember { mutableStateOf(false) } LaunchedEffect(Unit) { viewModel.loadMyResidences() } + if (showJoinDialog) { + JoinResidenceDialog( + onDismiss = { + showJoinDialog = false + }, + onJoined = { + // Reload residences after joining + viewModel.loadMyResidences() + } + ) + } + Scaffold( topBar = { TopAppBar( @@ -44,6 +58,9 @@ fun ResidencesScreen( ) }, actions = { + IconButton(onClick = { showJoinDialog = true }) { + Icon(Icons.Default.GroupAdd, contentDescription = "Join with Code") + } IconButton(onClick = onNavigateToProfile) { Icon(Icons.Default.AccountCircle, contentDescription = "Profile") } @@ -174,6 +191,26 @@ fun ResidencesScreen( ) } } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = { showJoinDialog = true }, + modifier = Modifier + .fillMaxWidth(0.7f) + .height(56.dp), + shape = RoundedCornerShape(12.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.GroupAdd, contentDescription = null) + Text( + "Join with Code", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } } } } else { diff --git a/iosApp/iosApp/Residence/JoinResidenceView.swift b/iosApp/iosApp/Residence/JoinResidenceView.swift new file mode 100644 index 0000000..0bb5c71 --- /dev/null +++ b/iosApp/iosApp/Residence/JoinResidenceView.swift @@ -0,0 +1,106 @@ +import SwiftUI +import ComposeApp + +struct JoinResidenceView: View { + @Environment(\.dismiss) private var dismiss + let onJoined: () -> Void + + @State private var shareCode: String = "" + @State private var isJoining = false + @State private var errorMessage: String? + + private let residenceApi = ResidenceApi(client: ApiClient_iosKt.createHttpClient()) + + var body: some View { + NavigationView { + Form { + Section { + TextField("Share Code", text: $shareCode) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .onChange(of: shareCode) { newValue in + // Limit to 6 characters and uppercase + if newValue.count > 6 { + shareCode = String(newValue.prefix(6)) + } + shareCode = shareCode.uppercased() + errorMessage = nil + } + .disabled(isJoining) + } header: { + Text("Enter Share Code") + } footer: { + Text("Enter the 6-character code shared with you to join a residence") + .foregroundColor(.secondary) + } + + if let error = errorMessage { + Section { + Text(error) + .foregroundColor(.red) + } + } + + Section { + Button(action: joinResidence) { + HStack { + Spacer() + if isJoining { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } else { + Text("Join Residence") + .fontWeight(.semibold) + } + Spacer() + } + } + .disabled(shareCode.count != 6 || isJoining) + } + } + .navigationTitle("Join Residence") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + .disabled(isJoining) + } + } + } + } + + private func joinResidence() { + guard shareCode.count == 6 else { + errorMessage = "Share code must be 6 characters" + return + } + + guard let token = TokenStorage.shared.getToken() else { + errorMessage = "Not authenticated" + return + } + + isJoining = true + errorMessage = nil + + residenceApi.joinWithCode(token: token, code: shareCode) { result, error in + if result is ApiResultSuccess { + self.isJoining = false + self.onJoined() + self.dismiss() + } else if let errorResult = result as? ApiResultError { + self.errorMessage = errorResult.message + self.isJoining = false + } else if let error = error { + self.errorMessage = error.localizedDescription + self.isJoining = false + } + } + } +} + +#Preview { + JoinResidenceView(onJoined: {}) +} diff --git a/iosApp/iosApp/Residence/ResidencesListView.swift b/iosApp/iosApp/Residence/ResidencesListView.swift index 3a6c3cb..c9c6398 100644 --- a/iosApp/iosApp/Residence/ResidencesListView.swift +++ b/iosApp/iosApp/Residence/ResidencesListView.swift @@ -4,6 +4,7 @@ import ComposeApp struct ResidencesListView: View { @StateObject private var viewModel = ResidenceViewModel() @State private var showingAddResidence = false + @State private var showingJoinResidence = false var body: some View { ZStack { @@ -54,7 +55,13 @@ struct ResidencesListView: View { .navigationTitle("My Properties") .navigationBarTitleDisplayMode(.large) .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button(action: { + showingJoinResidence = true + }) { + Image(systemName: "person.badge.plus") + } + Button(action: { showingAddResidence = true }) { @@ -65,6 +72,11 @@ struct ResidencesListView: View { .sheet(isPresented: $showingAddResidence) { AddResidenceView(isPresented: $showingAddResidence) } + .sheet(isPresented: $showingJoinResidence) { + JoinResidenceView(onJoined: { + viewModel.loadMyResidences() + }) + } .onAppear { viewModel.loadMyResidences() } diff --git a/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift b/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift index 9b6d300..d43b488 100644 --- a/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift +++ b/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift @@ -58,6 +58,8 @@ struct ResidenceCard: View { id: 1, owner: 1, ownerUsername: "testuser", + isPrimaryOwner: false, + userCount: 1, name: "My Home", propertyType: "House", streetAddress: "123 Main St",