diff --git a/edifikana/front-end/app/screenshots/OrgDetailScreenPreviewKt.orgdetail.organizations.settings.features.lib.client.edifikana.cramsan.com/OrgDetailScreenPreview_Desktop.png b/edifikana/front-end/app/screenshots/OrgDetailScreenPreviewKt.orgdetail.organizations.settings.features.lib.client.edifikana.cramsan.com/OrgDetailScreenPreview_Desktop.png index 65b6952f4..edf39bb08 100644 --- a/edifikana/front-end/app/screenshots/OrgDetailScreenPreviewKt.orgdetail.organizations.settings.features.lib.client.edifikana.cramsan.com/OrgDetailScreenPreview_Desktop.png +++ b/edifikana/front-end/app/screenshots/OrgDetailScreenPreviewKt.orgdetail.organizations.settings.features.lib.client.edifikana.cramsan.com/OrgDetailScreenPreview_Desktop.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:34805cca2294290f1b3739bfff935dfba8cbd56c89f6be843f1c0517bee0a413 -size 105694 +oid sha256:c561e275833943648178767a99e30053a6aba787e3ca6618a3d9121ce0c5a8a4 +size 146606 diff --git a/edifikana/front-end/app/screenshots/OrgDetailScreenPreviewKt.orgdetail.organizations.settings.features.lib.client.edifikana.cramsan.com/OrgDetailScreenPreview_Phone.png b/edifikana/front-end/app/screenshots/OrgDetailScreenPreviewKt.orgdetail.organizations.settings.features.lib.client.edifikana.cramsan.com/OrgDetailScreenPreview_Phone.png index 492884ada..52b5f311d 100644 --- a/edifikana/front-end/app/screenshots/OrgDetailScreenPreviewKt.orgdetail.organizations.settings.features.lib.client.edifikana.cramsan.com/OrgDetailScreenPreview_Phone.png +++ b/edifikana/front-end/app/screenshots/OrgDetailScreenPreviewKt.orgdetail.organizations.settings.features.lib.client.edifikana.cramsan.com/OrgDetailScreenPreview_Phone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7c999754880dd90e819ffd9759843570a07c7754fc9d221b842af5f115750ee4 -size 76139 +oid sha256:479bd1dbb4c1b837160551c4e570126b03b17acd55bf1ed30c5f4353f1bac3e6 +size 106711 diff --git a/edifikana/front-end/app/screenshots/OrgDetailScreenPreviewKt.orgdetail.organizations.settings.features.lib.client.edifikana.cramsan.com/OrgDetailScreenPreview_Tablet.png b/edifikana/front-end/app/screenshots/OrgDetailScreenPreviewKt.orgdetail.organizations.settings.features.lib.client.edifikana.cramsan.com/OrgDetailScreenPreview_Tablet.png index 3b4c99e7a..0bfba78f7 100644 --- a/edifikana/front-end/app/screenshots/OrgDetailScreenPreviewKt.orgdetail.organizations.settings.features.lib.client.edifikana.cramsan.com/OrgDetailScreenPreview_Tablet.png +++ b/edifikana/front-end/app/screenshots/OrgDetailScreenPreviewKt.orgdetail.organizations.settings.features.lib.client.edifikana.cramsan.com/OrgDetailScreenPreview_Tablet.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3a2bcec4313a0a9cf6f89a18837e57de08c5091ccb33e1a44d173cfaaf899757 -size 93241 +oid sha256:5c6c76dfcb4b0a04e6d84d9a7a275adb38d3183a0c3e97e3becd12dc671bee92 +size 129724 diff --git a/edifikana/front-end/app/screenshots/OrgDetailScreenPreviewKt.orgdetail.organizations.settings.features.lib.client.edifikana.cramsan.com/OrgDetailScreenSoleOwnerPreview.png b/edifikana/front-end/app/screenshots/OrgDetailScreenPreviewKt.orgdetail.organizations.settings.features.lib.client.edifikana.cramsan.com/OrgDetailScreenSoleOwnerPreview.png index c72819301..51d685ec0 100644 --- a/edifikana/front-end/app/screenshots/OrgDetailScreenPreviewKt.orgdetail.organizations.settings.features.lib.client.edifikana.cramsan.com/OrgDetailScreenSoleOwnerPreview.png +++ b/edifikana/front-end/app/screenshots/OrgDetailScreenPreviewKt.orgdetail.organizations.settings.features.lib.client.edifikana.cramsan.com/OrgDetailScreenSoleOwnerPreview.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fa9a373c39190520cb68c926031944c841b8ce7eee5ecaa2746fe53f7e273832 -size 74808 +oid sha256:7042aab96830a2b82f583bff03be517966bae108669bb66580134a8a8808c9d9 +size 105945 diff --git a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/myorganizations/MyOrganizationsScreen.kt b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/myorganizations/MyOrganizationsScreen.kt index cc765f366..2f12c2866 100644 --- a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/myorganizations/MyOrganizationsScreen.kt +++ b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/myorganizations/MyOrganizationsScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Business @@ -18,7 +17,6 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -34,6 +32,7 @@ import androidx.lifecycle.compose.LifecycleEventEffect import com.cramsan.edifikana.client.ui.components.EdifikanaPrimaryButton import com.cramsan.edifikana.client.ui.components.EdifikanaTopBar import com.cramsan.edifikana.client.ui.components.IconBadge +import com.cramsan.edifikana.client.ui.components.StatusPillBadge import com.cramsan.edifikana.client.ui.theme.Elevation import com.cramsan.edifikana.client.ui.theme.Shapes import com.cramsan.edifikana.client.ui.theme.Spacing @@ -187,24 +186,11 @@ private fun OrgCard( modifier = Modifier.weight(1f, fill = false), ) if (org.isActive) { - Surface( - color = MaterialTheme.colorScheme.primaryContainer, + StatusPillBadge( + text = "Current", + containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.primary, - shape = RoundedCornerShape(percent = 50), - ) { - Text( - text = "Current", - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - softWrap = false, - modifier = - Modifier.padding( - horizontal = Spacing.md, - vertical = Spacing.xs, - ), - ) - } + ) } } Text( diff --git a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/orgdetail/OrgDetailScreen.kt b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/orgdetail/OrgDetailScreen.kt index 3043024e0..07a00c8f6 100644 --- a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/orgdetail/OrgDetailScreen.kt +++ b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/orgdetail/OrgDetailScreen.kt @@ -1,5 +1,6 @@ package com.cramsan.edifikana.client.lib.features.settings.organizations.orgdetail +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -8,10 +9,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Build import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -23,11 +26,19 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import com.cramsan.edifikana.client.lib.features.settings.SettingsDestination import com.cramsan.edifikana.client.ui.components.EdifikanaTopBar +import com.cramsan.edifikana.client.ui.components.IconBadge +import com.cramsan.edifikana.client.ui.components.StatusPillBadge +import com.cramsan.edifikana.client.ui.theme.Elevation +import com.cramsan.edifikana.client.ui.theme.Shapes import com.cramsan.edifikana.client.ui.theme.Spacing +import com.cramsan.edifikana.client.ui.theme.StatusColors +import com.cramsan.edifikana.client.ui.theme.Typography import com.cramsan.edifikana.lib.model.organization.OrgRole import com.cramsan.framework.core.compose.ui.ObserveViewModelEvents import com.cramsan.ui.components.LoadingAnimationOverlay @@ -126,6 +137,7 @@ internal fun OrgDetailContent( InfoCard( userRole = uiState.userRole, memberCount = uiState.memberCount, + propertyCount = uiState.propertyCount, joinedDate = uiState.joinedDate, modifier = sectionModifier, ) @@ -159,7 +171,18 @@ private fun OrgHeaderCard( isActive: Boolean, modifier: Modifier = Modifier, ) { - Card(modifier = modifier.fillMaxWidth()) { + Card( + modifier = modifier.fillMaxWidth(), + shape = Shapes.pill, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + elevation = + CardDefaults.cardElevation( + defaultElevation = Elevation.resting, + hoveredElevation = Elevation.hovered, + pressedElevation = Elevation.pressed, + ), + ) { Row( modifier = Modifier @@ -169,12 +192,16 @@ private fun OrgHeaderCard( horizontalArrangement = Arrangement.spacedBy(Spacing.lg), ) { Column { - Text(text = orgName, style = MaterialTheme.typography.headlineSmall) + Text( + text = orgName, + style = Typography.cardTitle, + ) if (isActive) { - Text( + StatusPillBadge( text = "Active", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, + containerColor = StatusColors.successContainer, + contentColor = StatusColors.onSuccessContainer, + modifier = Modifier.padding(top = Spacing.xs), ) } } @@ -186,16 +213,29 @@ private fun OrgHeaderCard( private fun InfoCard( userRole: OrgRole?, memberCount: Int, + propertyCount: Int, joinedDate: String, modifier: Modifier = Modifier, ) { - Card(modifier = modifier.fillMaxWidth()) { + Card( + modifier = modifier.fillMaxWidth(), + shape = Shapes.pill, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + elevation = + CardDefaults.cardElevation( + defaultElevation = Elevation.resting, + hoveredElevation = Elevation.hovered, + pressedElevation = Elevation.pressed, + ), + ) { Column(modifier = Modifier.padding(Spacing.lg)) { InfoRow( label = "Your Role", value = userRole?.name?.lowercase()?.replaceFirstChar { it.uppercaseChar() } ?: "—", ) InfoRow(label = "Members", value = memberCount.toString()) + InfoRow(label = "Properties", value = propertyCount.toString()) InfoRow(label = "Joined", value = joinedDate.ifEmpty { "—" }) } } @@ -219,7 +259,12 @@ private fun InfoRow( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Text(text = value, style = MaterialTheme.typography.bodyMedium) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) } } @@ -229,10 +274,17 @@ private fun TransferOwnershipRow( modifier: Modifier = Modifier, ) { Card( - modifier = - modifier - .fillMaxWidth(), + modifier = modifier.fillMaxWidth(), onClick = onClick, + shape = Shapes.pill, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + elevation = + CardDefaults.cardElevation( + defaultElevation = Elevation.resting, + hoveredElevation = Elevation.hovered, + pressedElevation = Elevation.pressed, + ), ) { Row( modifier = @@ -240,7 +292,14 @@ private fun TransferOwnershipRow( .fillMaxWidth() .padding(Spacing.lg), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.md), ) { + IconBadge( + icon = Icons.Filled.Build, + contentDescription = null, + backgroundColor = StatusColors.warningContainer, + tint = StatusColors.onWarningContainer, + ) Column(modifier = Modifier.weight(1f)) { Text(text = "Transfer Ownership", style = MaterialTheme.typography.bodyLarge) Text( diff --git a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/orgdetail/OrgDetailScreenPreview.kt b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/orgdetail/OrgDetailScreenPreview.kt index 2f0e2a784..66c11356d 100644 --- a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/orgdetail/OrgDetailScreenPreview.kt +++ b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/orgdetail/OrgDetailScreenPreview.kt @@ -18,6 +18,7 @@ private fun OrgDetailScreenPreview() = isActiveOrg = true, userRole = OrgRole.OWNER, memberCount = 8, + propertyCount = 12, joinedDate = "1/15/2025", isSoleOwner = false, dialog = OrgDetailDialogState.None, @@ -40,6 +41,7 @@ private fun OrgDetailScreenSoleOwnerPreview() = isActiveOrg = true, userRole = OrgRole.OWNER, memberCount = 1, + propertyCount = 1, joinedDate = "3/1/2024", isSoleOwner = true, dialog = OrgDetailDialogState.None, diff --git a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/orgdetail/OrgDetailUIState.kt b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/orgdetail/OrgDetailUIState.kt index ae8711df8..99e4cb156 100644 --- a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/orgdetail/OrgDetailUIState.kt +++ b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/orgdetail/OrgDetailUIState.kt @@ -21,6 +21,7 @@ data class OrgDetailUIState( val isActiveOrg: Boolean, val userRole: OrgRole?, val memberCount: Int, + val propertyCount: Int, val joinedDate: String, val isSoleOwner: Boolean, val dialog: OrgDetailDialogState = OrgDetailDialogState.None, @@ -34,6 +35,7 @@ data class OrgDetailUIState( isActiveOrg = false, userRole = null, memberCount = 0, + propertyCount = 0, joinedDate = "", isSoleOwner = false, dialog = OrgDetailDialogState.None, diff --git a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/orgdetail/OrgDetailViewModel.kt b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/orgdetail/OrgDetailViewModel.kt index 0fd8dfa53..f7aa7dda2 100644 --- a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/orgdetail/OrgDetailViewModel.kt +++ b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/orgdetail/OrgDetailViewModel.kt @@ -6,6 +6,7 @@ import com.cramsan.edifikana.client.lib.features.window.EdifikanaWindowsEvent import com.cramsan.edifikana.client.lib.managers.AuthManager import com.cramsan.edifikana.client.lib.managers.MembershipManager import com.cramsan.edifikana.client.lib.managers.OrganizationManager +import com.cramsan.edifikana.client.lib.managers.PropertyManager import com.cramsan.edifikana.client.lib.settings.getLastSelectedOrganizationId import com.cramsan.edifikana.lib.model.organization.OrgRole import com.cramsan.edifikana.lib.model.organization.OrganizationId @@ -28,6 +29,7 @@ class OrgDetailViewModel( private val membershipManager: MembershipManager, private val authManager: AuthManager, private val preferencesManager: PreferencesManager, + private val propertyManager: PropertyManager, ) : BaseViewModel( dependencies, OrgDetailUIState.Initial, @@ -45,6 +47,12 @@ class OrgDetailViewModel( val org = organizationManager.getOrganization(orgId).getOrNull() val members = membershipManager.listMembers(orgId).getOrNull() + val propertyCount = + propertyManager + .getPropertyList() + .getOrNull() + ?.count { it.organizationId == orgId } + ?: 0 if (org == null || members == null) { updateUiState { it.copy(isLoading = false) } @@ -63,6 +71,7 @@ class OrgDetailViewModel( isActiveOrg = org.id == activeOrgId, userRole = myMembership?.role, memberCount = members.size, + propertyCount = propertyCount, joinedDate = myMembership?.joinedAt?.toDisplayDate() ?: "", isSoleOwner = isSoleOwner, ) diff --git a/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/orgdetail/OrgDetailViewModelTest.kt b/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/orgdetail/OrgDetailViewModelTest.kt index 6c15beb3a..7bedefb21 100644 --- a/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/orgdetail/OrgDetailViewModelTest.kt +++ b/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/orgdetail/OrgDetailViewModelTest.kt @@ -7,12 +7,15 @@ import com.cramsan.edifikana.client.lib.features.window.EdifikanaWindowsEvent import com.cramsan.edifikana.client.lib.managers.AuthManager import com.cramsan.edifikana.client.lib.managers.MembershipManager import com.cramsan.edifikana.client.lib.managers.OrganizationManager +import com.cramsan.edifikana.client.lib.managers.PropertyManager import com.cramsan.edifikana.client.lib.models.OrgMemberModel import com.cramsan.edifikana.client.lib.models.Organization +import com.cramsan.edifikana.client.lib.models.PropertyModel import com.cramsan.edifikana.client.lib.settings.EdifikanaSettingKey import com.cramsan.edifikana.lib.model.organization.OrgMemberStatus import com.cramsan.edifikana.lib.model.organization.OrgRole import com.cramsan.edifikana.lib.model.organization.OrganizationId +import com.cramsan.edifikana.lib.model.property.PropertyId import com.cramsan.edifikana.lib.model.user.UserId import com.cramsan.framework.core.UnifiedDispatcherProvider import com.cramsan.framework.core.compose.ApplicationEvent @@ -43,6 +46,7 @@ class OrgDetailViewModelTest : CoroutineTest() { private lateinit var membershipManager: MembershipManager private lateinit var authManager: AuthManager private lateinit var preferencesManager: PreferencesManager + private lateinit var propertyManager: PropertyManager private lateinit var exceptionHandler: CollectorCoroutineExceptionHandler private lateinit var windowEventBus: EventBus private lateinit var applicationEventBus: EventBus @@ -60,6 +64,7 @@ class OrgDetailViewModelTest : CoroutineTest() { membershipManager = mockk() authManager = mockk(relaxed = true) preferencesManager = mockk(relaxed = true) + propertyManager = mockk() every { authManager.activeUser() } returns MutableStateFlow(currentUserId) @@ -75,6 +80,7 @@ class OrgDetailViewModelTest : CoroutineTest() { membershipManager = membershipManager, authManager = authManager, preferencesManager = preferencesManager, + propertyManager = propertyManager, ) } @@ -89,12 +95,23 @@ class OrgDetailViewModelTest : CoroutineTest() { email = "test@example.com", ) + private fun propertyModel(id: String, organizationId: OrganizationId) = + PropertyModel(id = PropertyId(id), name = "Property $id", address = "", organizationId = organizationId) + @Test fun `initialize populates state with org details`() = runCoroutineTest { val org = Organization(id = orgId, name = "My Org", description = "") + val otherOrgId = OrganizationId("org-2") coEvery { organizationManager.getOrganization(orgId) } returns Result.success(org) coEvery { membershipManager.listMembers(orgId) } returns Result.success(listOf(memberModel())) coEvery { preferencesManager.getStringPreference(EdifikanaSettingKey.lastSelectedOrganization) } returns Result.success(orgId.id) + coEvery { propertyManager.getPropertyList() } returns Result.success( + listOf( + propertyModel("prop-1", orgId), + propertyModel("prop-2", orgId), + propertyModel("prop-3", otherOrgId), + ), + ) viewModel.initialize(orgId) @@ -104,6 +121,7 @@ class OrgDetailViewModelTest : CoroutineTest() { assertEquals(true, state.isActiveOrg) assertEquals(OrgRole.OWNER, state.userRole) assertEquals(1, state.memberCount) + assertEquals(2, state.propertyCount) assertTrue(state.isSoleOwner) } @@ -117,17 +135,35 @@ class OrgDetailViewModelTest : CoroutineTest() { coEvery { organizationManager.getOrganization(orgId) } returns Result.success(org) coEvery { membershipManager.listMembers(orgId) } returns Result.success(members) coEvery { preferencesManager.getStringPreference(EdifikanaSettingKey.lastSelectedOrganization) } returns Result.success(null) + coEvery { propertyManager.getPropertyList() } returns Result.success(emptyList()) viewModel.initialize(orgId) assertFalse(viewModel.uiState.value.isSoleOwner) } + @Test + fun `initialize sets propertyCount to 0 when property fetch fails`() = runCoroutineTest { + val org = Organization(id = orgId, name = "My Org", description = "") + coEvery { organizationManager.getOrganization(orgId) } returns Result.success(org) + coEvery { membershipManager.listMembers(orgId) } returns Result.success(listOf(memberModel())) + coEvery { preferencesManager.getStringPreference(EdifikanaSettingKey.lastSelectedOrganization) } returns Result.success(orgId.id) + coEvery { propertyManager.getPropertyList() } returns Result.failure(RuntimeException("error")) + + viewModel.initialize(orgId) + + val state = viewModel.uiState.value + assertFalse(state.isLoading) + assertEquals(0, state.propertyCount) + assertEquals("My Org", state.orgName) + } + @Test fun `initialize shows snackbar on org load failure`() = runCoroutineTest { coEvery { organizationManager.getOrganization(orgId) } returns Result.failure(RuntimeException("error")) coEvery { membershipManager.listMembers(orgId) } returns Result.success(emptyList()) coEvery { preferencesManager.getStringPreference(EdifikanaSettingKey.lastSelectedOrganization) } returns Result.success(null) + coEvery { propertyManager.getPropertyList() } returns Result.success(emptyList()) turbineScope { val turbine = windowEventBus.events.testIn(backgroundScope) diff --git a/edifikana/front-end/ui-components/src/commonMain/kotlin/com/cramsan/edifikana/client/ui/components/StatusPillBadge.kt b/edifikana/front-end/ui-components/src/commonMain/kotlin/com/cramsan/edifikana/client/ui/components/StatusPillBadge.kt new file mode 100644 index 000000000..61ac4c989 --- /dev/null +++ b/edifikana/front-end/ui-components/src/commonMain/kotlin/com/cramsan/edifikana/client/ui/components/StatusPillBadge.kt @@ -0,0 +1,48 @@ +package com.cramsan.edifikana.client.ui.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import com.cramsan.edifikana.client.ui.theme.Shapes +import com.cramsan.edifikana.client.ui.theme.Spacing + +/** + * Compact pill-shaped status badge, e.g. "Active" or "Current". + * + * @param text Label to display within the badge. + * @param containerColor Background color of the badge. + * @param contentColor Text color of the badge. + * @param modifier Modifier for the badge container. + */ +@Composable +fun StatusPillBadge( + text: String, + containerColor: Color, + contentColor: Color, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + color = containerColor, + contentColor = contentColor, + shape = Shapes.badgePill, + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + softWrap = false, + modifier = + Modifier.padding( + horizontal = Spacing.md, + vertical = Spacing.xs, + ), + ) + } +} diff --git a/edifikana/front-end/ui-components/src/commonMain/kotlin/com/cramsan/edifikana/client/ui/theme/Shapes.kt b/edifikana/front-end/ui-components/src/commonMain/kotlin/com/cramsan/edifikana/client/ui/theme/Shapes.kt index 2837b3de1..7805722be 100644 --- a/edifikana/front-end/ui-components/src/commonMain/kotlin/com/cramsan/edifikana/client/ui/theme/Shapes.kt +++ b/edifikana/front-end/ui-components/src/commonMain/kotlin/com/cramsan/edifikana/client/ui/theme/Shapes.kt @@ -10,4 +10,7 @@ import androidx.compose.ui.unit.dp object Shapes { /** Fully rounded "pill" shape used for buttons, text fields, and cards. */ val pill: Shape = RoundedCornerShape(25.dp) + + /** Fully rounded, height-relative "pill" shape used for compact status badges/chips. */ + val badgePill: Shape = RoundedCornerShape(percent = 50) } diff --git a/edifikana/front-end/ui-components/src/commonMain/kotlin/com/cramsan/edifikana/client/ui/theme/StatusColors.kt b/edifikana/front-end/ui-components/src/commonMain/kotlin/com/cramsan/edifikana/client/ui/theme/StatusColors.kt new file mode 100644 index 000000000..a85bc0627 --- /dev/null +++ b/edifikana/front-end/ui-components/src/commonMain/kotlin/com/cramsan/edifikana/client/ui/theme/StatusColors.kt @@ -0,0 +1,47 @@ +@file:Suppress("MagicNumber") + +package com.cramsan.edifikana.client.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color + +/** + * Semantic status colors (success, warning) not represented in Material3's default `ColorScheme`. + */ +object StatusColors { + /** Background for a positive/active state, e.g. an "Active" badge. */ + val successContainer: Color + @Composable + @ReadOnlyComposable + get() = if (isSystemInDarkTheme()) successContainerDark else successContainerLight + + /** Content color to pair with [successContainer]. */ + val onSuccessContainer: Color + @Composable + @ReadOnlyComposable + get() = if (isSystemInDarkTheme()) onSuccessContainerDark else onSuccessContainerLight + + /** Background for a cautionary state, e.g. an ownership-transfer action icon. */ + val warningContainer: Color + @Composable + @ReadOnlyComposable + get() = if (isSystemInDarkTheme()) warningContainerDark else warningContainerLight + + /** Content color to pair with [warningContainer]. */ + val onWarningContainer: Color + @Composable + @ReadOnlyComposable + get() = if (isSystemInDarkTheme()) onWarningContainerDark else onWarningContainerLight +} + +private val successContainerLight = Color(0xFFDCFCE7) +private val onSuccessContainerLight = Color(0xFF16A34A) +private val successContainerDark = Color(0xFF14532D) +private val onSuccessContainerDark = Color(0xFF86EFAC) + +private val warningContainerLight = Color(0xFFFEF3C7) +private val onWarningContainerLight = Color(0xFFF59E0B) +private val warningContainerDark = Color(0xFF78350F) +private val onWarningContainerDark = Color(0xFFFCD34D) diff --git a/edifikana/front-end/ui-components/src/commonMain/kotlin/com/cramsan/edifikana/client/ui/theme/Typography.kt b/edifikana/front-end/ui-components/src/commonMain/kotlin/com/cramsan/edifikana/client/ui/theme/Typography.kt new file mode 100644 index 000000000..6ac26ebc7 --- /dev/null +++ b/edifikana/front-end/ui-components/src/commonMain/kotlin/com/cramsan/edifikana/client/ui/theme/Typography.kt @@ -0,0 +1,20 @@ +@file:Suppress("MagicNumber") + +package com.cramsan.edifikana.client.ui.theme + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +/** + * Custom typography tokens not covered by Material3's default type scale. + */ +object Typography { + /** Emphasized card/section title, e.g. an organization name header. */ + val cardTitle: TextStyle = + TextStyle( + fontSize = 20.sp, + lineHeight = 28.sp, + fontWeight = FontWeight.SemiBold, + ) +}