Initial commit

This commit is contained in:
2026-07-22 14:08:23 +01:00
commit 01af2acb3d
39 changed files with 3990 additions and 0 deletions

View File

@@ -0,0 +1,279 @@
package com.keeply.pantry.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Groups
import androidx.compose.material.icons.outlined.Logout
import androidx.compose.material.icons.outlined.MailOutline
import androidx.compose.material.icons.outlined.Shield
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.keeply.pantry.KeeplyViewModel
import com.keeply.pantry.data.Household
import com.keeply.pantry.data.HouseholdMember
import com.keeply.pantry.data.formatDate
import com.keeply.pantry.ui.theme.Ink
import com.keeply.pantry.ui.theme.KeeplyGreen
import com.keeply.pantry.ui.theme.KeeplyGreenSoft
import com.keeply.pantry.ui.theme.Muted
import com.keeply.pantry.ui.theme.Warning
import com.keeply.pantry.ui.theme.WarningSoft
@Composable
fun HouseholdsScreen(viewModel: KeeplyViewModel) {
val households = viewModel.households.data.orEmpty()
val isSiteAdmin = viewModel.user?.isSiteAdmin == true
var editing by remember { mutableStateOf<Household?>(null) }
var editorOpen by remember { mutableStateOf(false) }
var inviting by remember { mutableStateOf<Household?>(null) }
var leaving by remember { mutableStateOf<Household?>(null) }
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 18.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 24.dp, bottom = 80.dp),
verticalArrangement = Arrangement.spacedBy(15.dp),
) {
item {
PageHeader(
eyebrow = "Pantries work better together",
title = "Households",
description = "Manage shared spaces and the people who keep them running.",
action = if (isSiteAdmin) {
{
Button(onClick = { editing = null; viewModel.clearActionError(); editorOpen = true }) {
Icon(Icons.Outlined.Add, null, Modifier.size(18.dp)); Spacer(Modifier.width(6.dp)); Text("New")
}
}
} else null,
)
}
if (!isSiteAdmin) {
item {
Surface(color = KeeplyGreenSoft, shape = RoundedCornerShape(13.dp), modifier = Modifier.fillMaxWidth()) {
Row(Modifier.padding(15.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Outlined.Shield, null, tint = KeeplyGreen)
Spacer(Modifier.width(11.dp))
Column {
Text("Your household access is managed safely", fontWeight = FontWeight.SemiBold)
Text("Household admins can update details and add existing Keeply users.", color = Muted, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
when {
viewModel.households.loading -> item { LoadingPane("Loading households…") }
viewModel.households.error != null -> item {
EmptyPane("Households are unavailable", viewModel.households.error.orEmpty(), "Try again") { viewModel.loadHouseholds(true) }
}
households.isEmpty() -> item {
EmptyPane(
"No households yet",
if (isSiteAdmin) "Create a household, then add the people who share it." else "A site administrator can create a household and add you to it.",
if (isSiteAdmin) "Create household" else null,
if (isSiteAdmin) ({ editing = null; editorOpen = true }) else null,
)
}
else -> items(households, key = { it.id }) { household ->
HouseholdCard(
household = household,
canManage = isSiteAdmin || household.isCurrentUserHouseholdAdmin,
onEdit = { editing = household; viewModel.clearActionError(); editorOpen = true },
onInvite = { inviting = household; viewModel.clearActionError() },
onLeave = { leaving = household; viewModel.clearActionError() },
)
}
}
}
if (editorOpen) {
HouseholdEditorDialog(
household = editing,
working = viewModel.working,
error = viewModel.actionError,
onSave = { name, description -> viewModel.saveHousehold(editing, name, description) { editorOpen = false } },
onDismiss = { editorOpen = false },
)
}
inviting?.let { household ->
InviteMemberDialog(
household = household,
working = viewModel.working,
error = viewModel.actionError,
onInvite = { email -> viewModel.inviteMember(household, email) { inviting = null } },
onDismiss = { inviting = null },
)
}
leaving?.let { household ->
ConfirmActionDialog(
title = "Leave this household?",
message = "You will lose access to ${household.name} and its shared search results.",
confirmLabel = "Leave household",
working = viewModel.working,
onConfirm = { viewModel.leaveHousehold(household) { leaving = null } },
onDismiss = { leaving = null },
)
}
}
@Composable
private fun HouseholdCard(
household: Household,
canManage: Boolean,
onEdit: () -> Unit,
onInvite: () -> Unit,
onLeave: () -> Unit,
) {
KeeplyCard {
Column {
Row(Modifier.fillMaxWidth().padding(17.dp), verticalAlignment = Alignment.Top) {
Surface(Modifier.size(48.dp), shape = RoundedCornerShape(13.dp), color = KeeplyGreenSoft) {
androidx.compose.foundation.layout.Box(contentAlignment = Alignment.Center) { Icon(Icons.Outlined.Groups, null, tint = KeeplyGreen) }
}
Spacer(Modifier.width(13.dp))
Column(Modifier.weight(1f)) {
SectionEyebrow("Established ${formatDate(household.createdAt)}")
Spacer(Modifier.height(4.dp))
Text(household.name, style = MaterialTheme.typography.titleLarge, color = Ink)
Text(household.description ?: "A shared place for a well-kept kitchen.", color = Muted, style = MaterialTheme.typography.bodySmall)
}
if (household.isCurrentUserHouseholdAdmin) {
Surface(color = WarningSoft, contentColor = Warning, shape = RoundedCornerShape(50)) {
Text("You manage this", Modifier.padding(horizontal = 8.dp, vertical = 5.dp), style = MaterialTheme.typography.labelSmall)
}
}
}
FieldDivider()
Row(Modifier.fillMaxWidth().padding(horizontal = 17.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) {
Text("${household.members.size} ${if (household.members.size == 1) "member" else "members"}", fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
if (canManage) TextButton(onClick = onInvite) { Icon(Icons.Outlined.MailOutline, null, Modifier.size(17.dp)); Spacer(Modifier.width(5.dp)); Text("Add member") }
}
Column(Modifier.padding(horizontal = 17.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) {
if (household.members.isEmpty()) Text("No members have been added yet.", color = Muted, style = MaterialTheme.typography.bodySmall)
household.members.forEach { MemberRow(it) }
}
Spacer(Modifier.height(13.dp))
FieldDivider()
Row(Modifier.fillMaxWidth().padding(horizontal = 17.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
Text("Managed by ${household.adminEmail}", color = Muted, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
if (canManage) OutlinedButton(onClick = onEdit) {
Icon(Icons.Outlined.Edit, null, Modifier.size(16.dp)); Spacer(Modifier.width(5.dp)); Text("Edit")
}
if (!household.isCurrentUserHouseholdAdmin) TextButton(onClick = onLeave) {
Icon(Icons.Outlined.Logout, null, Modifier.size(16.dp)); Spacer(Modifier.width(5.dp)); Text("Leave")
}
}
}
}
}
@Composable
private fun MemberRow(member: HouseholdMember) {
Surface(color = Color(0xFFFBFCFA), shape = RoundedCornerShape(11.dp), modifier = Modifier.fillMaxWidth()) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
InitialsAvatar(member.initials)
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
Text(member.displayName, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(member.email, color = Muted, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (member.isHouseholdAdmin) Text("Household admin", color = Warning, style = MaterialTheme.typography.labelSmall)
}
}
}
@Composable
private fun HouseholdEditorDialog(
household: Household?,
working: Boolean,
error: String?,
onSave: (String, String?) -> Unit,
onDismiss: () -> Unit,
) {
var name by remember(household?.id) { mutableStateOf(household?.name.orEmpty()) }
var description by remember(household?.id) { mutableStateOf(household?.description.orEmpty()) }
AlertDialog(
onDismissRequest = { if (!working) onDismiss() },
title = { Column { SectionEyebrow("Shared pantry"); Spacer(Modifier.height(4.dp)); Text(if (household == null) "Create a household" else "Edit household") } },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(name, { name = it }, label = { Text("Household name") }, placeholder = { Text("e.g. The Morgan household") }, singleLine = true, modifier = Modifier.fillMaxWidth())
OutlinedTextField(description, { description = it }, label = { Text("Description (optional)") }, minLines = 3, modifier = Modifier.fillMaxWidth())
ErrorText(error)
}
},
confirmButton = {
Button(onClick = { onSave(name.trim(), description.trim().ifBlank { null }) }, enabled = !working && name.isNotBlank()) {
if (working) CircularProgressIndicator(Modifier.size(17.dp), color = Color.White, strokeWidth = 2.dp)
else Text(if (household == null) "Create household" else "Save changes")
}
},
dismissButton = { TextButton(onClick = onDismiss, enabled = !working) { Text("Cancel") } },
)
}
@Composable
private fun InviteMemberDialog(
household: Household,
working: Boolean,
error: String?,
onInvite: (String) -> Unit,
onDismiss: () -> Unit,
) {
var email by remember(household.id) { mutableStateOf("") }
AlertDialog(
onDismissRequest = { if (!working) onDismiss() },
title = { Column { SectionEyebrow(household.name); Spacer(Modifier.height(4.dp)); Text("Add a household member") } },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Invite an existing Keeply user using the email address they use to sign in.", color = Muted)
OutlinedTextField(
email, { email = it }, label = { Text("Email address") }, singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
)
ErrorText(error)
}
},
confirmButton = {
Button(onClick = { onInvite(email.trim()) }, enabled = !working && email.isNotBlank()) {
if (working) CircularProgressIndicator(Modifier.size(17.dp), color = Color.White, strokeWidth = 2.dp) else Text("Add member")
}
},
dismissButton = { TextButton(onClick = onDismiss, enabled = !working) { Text("Cancel") } },
)
}