feat: Android Gruppen + kombinierte Ansicht direkt im Kalender

Neuer Gruppen-Verwaltungsscreen (Liste, Erstellen, Verwalten: Name, wählbares
Emoji-Icon, Mitglieder, server-definierte Mitgliederfarben, Löschen) über das
Menü. Top-Bar-Umschalter (Mein Kalender / Gruppen) + Akzent-Banner schalten den
Kalender in die kombinierte Gruppenansicht: Events laufen durch die normale
Cache-/Render-Pipeline (Monat/Woche/Tag/Termine), mit Namens-Präfix der
Besitzer und 👥 für Gruppentermine; im Gruppenmodus kein Sichtbarkeitsfilter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido Schmit
2026-05-31 22:36:26 +02:00
parent e0d1b24afc
commit 3352aa3be7
5 changed files with 507 additions and 7 deletions

View File

@@ -20,6 +20,7 @@ import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Today import androidx.compose.material.icons.filled.Today
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
@@ -55,15 +56,17 @@ import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.CalViewType import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.ui.LocalLang import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.ui.accounts.AccountsScreen import com.scarriffle.calendarr.ui.accounts.AccountsScreen
import com.scarriffle.calendarr.domain.model.Group
import com.scarriffle.calendarr.ui.event.EventDetailScreen import com.scarriffle.calendarr.ui.event.EventDetailScreen
import com.scarriffle.calendarr.ui.event.EventEditorSheet import com.scarriffle.calendarr.ui.event.EventEditorSheet
import com.scarriffle.calendarr.ui.groups.GroupsScreen
import com.scarriffle.calendarr.ui.menu.MenuSheet import com.scarriffle.calendarr.ui.menu.MenuSheet
import com.scarriffle.calendarr.ui.profile.ProfileScreen import com.scarriffle.calendarr.ui.profile.ProfileScreen
import com.scarriffle.calendarr.ui.settings.SettingsScreen import com.scarriffle.calendarr.ui.settings.SettingsScreen
import com.scarriffle.calendarr.ui.tr import com.scarriffle.calendarr.ui.tr
import java.time.LocalDate import java.time.LocalDate
private enum class Overlay { NONE, PROFILE, SETTINGS, ACCOUNTS } private enum class Overlay { NONE, PROFILE, SETTINGS, ACCOUNTS, GROUPS }
data class EditorRequest(val existing: CalEvent?, val date: LocalDate, val prefill: CalEvent? = null) data class EditorRequest(val existing: CalEvent?, val date: LocalDate, val prefill: CalEvent? = null)
@@ -113,6 +116,9 @@ fun CalendarScreen(
viewType = state.viewType, viewType = state.viewType,
loading = state.isLoading || state.isBackgroundCaching, loading = state.isLoading || state.isBackgroundCaching,
viewMenuOpen = viewMenuOpen, viewMenuOpen = viewMenuOpen,
groups = state.groups,
activeGroup = state.activeGroup,
onSwitchGroup = { vm.switchGroup(it) },
onMenu = { showMenu = true }, onMenu = { showMenu = true },
onPrev = { goPrev() }, onPrev = { goPrev() },
onToday = { goToday() }, onToday = { goToday() },
@@ -136,6 +142,9 @@ fun CalendarScreen(
state.error?.let { err -> state.error?.let { err ->
ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError) ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError)
} }
state.activeGroup?.let { g ->
GroupBanner(group = g, onExit = { vm.switchGroup(null) })
}
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
CalendarBody( CalendarBody(
state = state, state = state,
@@ -162,6 +171,7 @@ fun CalendarScreen(
onProfile = { showMenu = false; overlay = Overlay.PROFILE }, onProfile = { showMenu = false; overlay = Overlay.PROFILE },
onAppearance = { showMenu = false; overlay = Overlay.SETTINGS }, onAppearance = { showMenu = false; overlay = Overlay.SETTINGS },
onAccounts = { showMenu = false; overlay = Overlay.ACCOUNTS }, onAccounts = { showMenu = false; overlay = Overlay.ACCOUNTS },
onGroups = { showMenu = false; overlay = Overlay.GROUPS },
onSync = { showMenu = false; vm.syncWithServer() }, onSync = { showMenu = false; vm.syncWithServer() },
onLogout = { showMenu = false; onLogout() }, onLogout = { showMenu = false; onLogout() },
onSwitchServer = { showMenu = false; onSwitchServer() }, onSwitchServer = { showMenu = false; onSwitchServer() },
@@ -230,6 +240,10 @@ fun CalendarScreen(
onClose = { overlay = Overlay.NONE }, onClose = { overlay = Overlay.NONE },
onChanged = { vm.loadWritableCalendars(); vm.syncWithServer() }, onChanged = { vm.loadWritableCalendars(); vm.syncWithServer() },
) )
Overlay.GROUPS -> GroupsScreen(
onClose = { overlay = Overlay.NONE },
onChanged = { vm.loadGroups(); vm.loadWritableCalendars() },
)
Overlay.NONE -> Unit Overlay.NONE -> Unit
} }
} }
@@ -296,6 +310,9 @@ private fun CompactTopBar(
viewType: CalViewType, viewType: CalViewType,
loading: Boolean, loading: Boolean,
viewMenuOpen: Boolean, viewMenuOpen: Boolean,
groups: List<Group>,
activeGroup: Group?,
onSwitchGroup: (Group?) -> Unit,
onMenu: () -> Unit, onMenu: () -> Unit,
onPrev: () -> Unit, onPrev: () -> Unit,
onToday: () -> Unit, onToday: () -> Unit,
@@ -331,6 +348,9 @@ private fun CompactTopBar(
strokeWidth = 2.dp, strokeWidth = 2.dp,
) )
} }
if (groups.isNotEmpty()) {
GroupSwitcher(groups = groups, activeGroup = activeGroup, onSwitchGroup = onSwitchGroup)
}
CompactIcon(Icons.Filled.FilterList, onFilter, tr("filter.button")) CompactIcon(Icons.Filled.FilterList, onFilter, tr("filter.button"))
Box { Box {
CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change")) CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change"))
@@ -360,6 +380,53 @@ private fun CompactIcon(
} }
} }
/** Top-bar switcher: "My calendar" + each group; flips the calendar into the group overlay. */
@Composable
private fun GroupSwitcher(groups: List<Group>, activeGroup: Group?, onSwitchGroup: (Group?) -> Unit) {
var open by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { open = true }, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Filled.People,
contentDescription = tr("groups.title"),
modifier = Modifier.size(22.dp),
tint = if (activeGroup != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
)
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
DropdownMenuItem(
text = { Text(tr("group.switch.personal")) },
onClick = { open = false; onSwitchGroup(null) },
)
groups.forEach { g ->
DropdownMenuItem(
text = { Text("${g.icon ?: "👥"} ${g.name}") },
onClick = { open = false; onSwitchGroup(g) },
)
}
}
}
}
@Composable
private fun GroupBanner(group: Group, onExit: () -> Unit) {
Surface(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), modifier = Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"${tr("groups.view")}: ${group.icon ?: "👥"} ${group.name}",
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
)
TextButton(onClick = onExit) { Text(tr("group.switch.personal")) }
}
}
}
/** Distinct calendars currently present in the cache, for the filter sheet. */ /** Distinct calendars currently present in the cache, for the filter sheet. */
private fun allKnownCalendars(vm: CalendarViewModel): List<CalendarFilterEntry> { private fun allKnownCalendars(vm: CalendarViewModel): List<CalendarFilterEntry> {
val st = vm.state.value val st = vm.state.value

View File

@@ -6,6 +6,7 @@ import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.data.SettingsStore import com.scarriffle.calendarr.data.SettingsStore
import com.scarriffle.calendarr.domain.model.CalEvent import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.CalViewType import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.domain.model.Group
import com.scarriffle.calendarr.domain.model.WritableCalendar import com.scarriffle.calendarr.domain.model.WritableCalendar
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -38,6 +39,9 @@ data class CalendarUiState(
val writableCalendars: List<WritableCalendar> = emptyList(), val writableCalendars: List<WritableCalendar> = emptyList(),
val hiddenKeys: Set<String> = emptySet(), val hiddenKeys: Set<String> = emptySet(),
val banishedKeys: Set<String> = emptySet(), val banishedKeys: Set<String> = emptySet(),
// Group overlay: when non-null the calendar shows the group's combined view.
val groups: List<Group> = emptyList(),
val activeGroup: Group? = null,
) )
@HiltViewModel @HiltViewModel
@@ -68,6 +72,7 @@ class CalendarViewModel @Inject constructor(
init { init {
loadWritableCalendars() loadWritableCalendars()
loadGroups()
initialLoad() initialLoad()
} }
@@ -193,15 +198,35 @@ class CalendarViewModel @Inject constructor(
loadMutex.withLock { loadMutex.withLock {
// Another load (e.g. the background prefetch) may have covered this range. // Another load (e.g. the background prefetch) may have covered this range.
if (isCached(start, end)) return if (isCached(start, end)) return
val group = _state.value.activeGroup
val flag = if (background) "bg" else "fg" val flag = if (background) "bg" else "fg"
_state.update { if (flag == "bg") it.copy(isBackgroundCaching = true) else it.copy(isLoading = true, error = null) } _state.update { if (flag == "bg") it.copy(isBackgroundCaching = true) else it.copy(isLoading = true, error = null) }
runCatching { repository.fetchEvents(start, end) } runCatching {
if (group != null) decorateGroup(repository.fetchGroupCombined(group.id, start, end))
else repository.fetchEvents(start, end)
}
.onSuccess { mergeIntoCache(it, start, end); refreshFromCache() } .onSuccess { mergeIntoCache(it, start, end); refreshFromCache() }
.onFailure { e -> if (!background) _state.update { it.copy(error = e.message) } } .onFailure { e -> if (!background) _state.update { it.copy(error = e.message) } }
_state.update { it.copy(isLoading = false, isBackgroundCaching = false) } _state.update { it.copy(isLoading = false, isBackgroundCaching = false) }
} }
} }
/** Prefix combined-view events with the owner's / creator's first name (and 👥 for group events). */
private fun decorateGroup(events: List<CalEvent>): List<CalEvent> {
val me = currentUserId
return events.map { ev ->
val prefix = when {
ev.isGroupEvent && ev.creator != null && ev.creator.id != me -> "👥 ${firstName(ev.creator.displayName)}: "
ev.isGroupEvent -> "👥 "
ev.owner != null && ev.owner.id != me -> "${firstName(ev.owner.displayName)}: "
else -> ""
}
if (prefix.isEmpty()) ev else ev.copy(title = prefix + ev.title)
}
}
private fun firstName(s: String): String = s.trim().substringBefore(' ').ifBlank { s }
private fun markReady() { private fun markReady() {
_ready.value = true _ready.value = true
} }
@@ -222,12 +247,18 @@ class CalendarViewModel @Inject constructor(
} }
private fun refreshFromCache() { private fun refreshFromCache() {
val hidden = _state.value.hiddenKeys val st = _state.value
val banished = _state.value.banishedKeys // In group mode the server already scopes + filters; show everything.
val visible = allCachedEvents.filter { ev -> val visible = if (st.activeGroup != null) {
allCachedEvents
} else {
val hidden = st.hiddenKeys
val banished = st.banishedKeys
allCachedEvents.filter { ev ->
val key = calendarKey(ev.source, ev.calendarId) val key = calendarKey(ev.source, ev.calendarId)
key !in hidden && key !in banished key !in hidden && key !in banished
} }
}
// Skip the state write (and resulting recomposition) when nothing changed. // Skip the state write (and resulting recomposition) when nothing changed.
_state.update { if (it.events == visible) it else it.copy(events = visible) } _state.update { if (it.events == visible) it else it.copy(events = visible) }
} }
@@ -240,6 +271,7 @@ class CalendarViewModel @Inject constructor(
fun syncWithServer() { fun syncWithServer() {
invalidateCache() invalidateCache()
loadGroups()
initialLoad() initialLoad()
} }
@@ -275,6 +307,29 @@ class CalendarViewModel @Inject constructor(
refreshFromCache() refreshFromCache()
} }
// ---- Groups ----
fun loadGroups() {
viewModelScope.launch {
runCatching { repository.getGroups() }
.onSuccess { gs ->
_state.update { st ->
// If the active group was deleted elsewhere, drop back to personal.
val stillActive = st.activeGroup?.let { a -> gs.firstOrNull { it.id == a.id } }
st.copy(groups = gs, activeGroup = stillActive)
}
}
}
}
/** Flip between personal and a group's combined overlay; reloads the wide window. */
fun switchGroup(group: Group?) {
if (_state.value.activeGroup?.id == group?.id) return
_state.update { it.copy(activeGroup = group) }
invalidateCache()
initialLoad()
}
// ---- Writable calendars ---- // ---- Writable calendars ----
fun loadWritableCalendars() { fun loadWritableCalendars() {

View File

@@ -0,0 +1,298 @@
package com.scarriffle.calendarr.ui.groups
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.domain.model.Group
import com.scarriffle.calendarr.ui.components.ColorPickerDialog
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
private val GROUP_ICONS = listOf(
"👥", "👨‍👩‍👧", "🏠", "❤️", "🧑‍🤝‍🧑", "", "🎓", "💼", "🎉", "🐶", "✈️", "🎵", "🍕", "📚", "🌳", "",
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroupsScreen(
onClose: () -> Unit,
onChanged: () -> Unit,
vm: GroupsViewModel = hiltViewModel(),
) {
var createOpen by remember { mutableStateOf(false) }
var manageId by remember { mutableStateOf<Int?>(null) }
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(tr("groups.title")) },
navigationIcon = {
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) }
},
actions = {
IconButton(onClick = { createOpen = true }) { Icon(Icons.Filled.Add, contentDescription = tr("groups.create")) }
},
)
},
) { padding ->
if (vm.loading) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
return@Scaffold
}
LazyColumn(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
if (vm.groups.isEmpty()) {
item {
Text(tr("groups.empty"), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 16.dp))
}
}
items(vm.groups, key = { it.id }) { g ->
Row(
Modifier.fillMaxWidth().clickable { manageId = g.id }.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(g.icon ?: "👥", style = MaterialTheme.typography.titleMedium)
Text(g.name, modifier = Modifier.weight(1f).padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge)
Text(
tr("groups.member_count", g.memberCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Divider()
}
vm.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 12.dp)) } }
}
}
}
if (createOpen) {
GroupEditSheet(
vm = vm,
existing = null,
onDismiss = { createOpen = false },
onSaved = { createOpen = false; onChanged() },
)
}
manageId?.let { id ->
val existing = vm.groups.firstOrNull { it.id == id }
GroupEditSheet(
vm = vm,
existing = existing,
onDismiss = { manageId = null },
onSaved = { manageId = null; onChanged() },
)
}
}
/** Create (existing == null) or manage (existing != null) a group. */
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun GroupEditSheet(
vm: GroupsViewModel,
existing: Group?,
onDismiss: () -> Unit,
onSaved: () -> Unit,
) {
val me = vm.currentUserId
var name by remember { mutableStateOf(existing?.name ?: "") }
var icon by remember { mutableStateOf(existing?.icon ?: "👥") }
var selected by remember { mutableStateOf(setOf<Int>()) }
var existingMembers by remember { mutableStateOf(setOf<Int>()) }
var detail by remember { mutableStateOf<Group?>(null) }
var loaded by remember { mutableStateOf(existing == null) }
var confirmDelete by remember { mutableStateOf(false) }
var memberColorTarget by remember { mutableStateOf<Pair<Int, String>?>(null) } // userId, current hex
// Manage: load full details (members + colours) and pre-fill selection.
LaunchedEffect(existing?.id) {
val id = existing?.id ?: return@LaunchedEffect
val g = vm.groupDetail(id)
detail = g
if (g != null) {
name = g.name
icon = g.icon ?: "👥"
val members = g.members.map { it.id }.filter { it != me }.toSet()
existingMembers = members
selected = members
}
loaded = true
}
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 24.dp).verticalScroll(rememberScrollState())) {
Text(
if (existing == null) tr("groups.create") else tr("groups.manage"),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.size(16.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(tr("groups.name")) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(16.dp))
Text(tr("groups.icon"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.size(8.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
GROUP_ICONS.forEach { ic ->
val sel = ic == icon
Box(
Modifier.size(44.dp).clip(RoundedCornerShape(8.dp))
.background(if (sel) MaterialTheme.colorScheme.primary.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceVariant)
.clickable { icon = ic },
contentAlignment = Alignment.Center,
) {
Text(ic, style = MaterialTheme.typography.titleLarge)
}
}
}
Spacer(Modifier.size(16.dp))
Text(tr("groups.members"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
if (!loaded) {
Box(Modifier.fillMaxWidth().padding(12.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(22.dp), strokeWidth = 2.dp) }
} else {
vm.directory.forEach { u ->
Row(
Modifier.fillMaxWidth().clickable {
selected = if (u.id in selected) selected - u.id else selected + u.id
}.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = u.id in selected, onCheckedChange = {
selected = if (u.id in selected) selected - u.id else selected + u.id
})
Text(u.displayName, modifier = Modifier.padding(start = 4.dp))
}
}
}
// Member colours (manage only)
detail?.members?.takeIf { it.isNotEmpty() }?.let { members ->
Spacer(Modifier.size(8.dp))
Divider()
Spacer(Modifier.size(8.dp))
Text(tr("groups.member_color"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
members.forEach { m ->
val hex = m.color ?: "#4285f4"
Row(
Modifier.fillMaxWidth().clickable { memberColorTarget = m.id to hex }.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(20.dp).clip(CircleShape).background(colorFromHex(hex)))
Text(m.displayName, modifier = Modifier.weight(1f).padding(start = 12.dp))
}
}
}
Spacer(Modifier.size(20.dp))
Button(onClick = {
val n = name.trim()
if (n.isEmpty()) return@Button
if (existing == null) {
vm.createGroup(n, icon, selected.toList()) { onSaved() }
} else {
vm.saveGroup(existing.id, n, icon, selected, existingMembers) { onSaved() }
}
}, enabled = name.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
Text(tr("event.save"))
}
if (existing != null) {
Spacer(Modifier.size(8.dp))
OutlinedButton(
onClick = { confirmDelete = true },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.size(8.dp))
Text(tr("groups.delete"), color = MaterialTheme.colorScheme.error)
}
}
}
}
memberColorTarget?.let { (userId, hex) ->
ColorPickerDialog(
initial = hex,
title = tr("groups.member_color"),
onDismiss = { memberColorTarget = null },
onConfirm = { picked ->
existing?.let { vm.setMemberColor(it.id, userId, picked) }
// reflect locally
detail = detail?.let { d -> d.copy(members = d.members.map { if (it.id == userId) it.copy(color = picked) else it }) }
memberColorTarget = null
},
)
}
if (confirmDelete && existing != null) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text(tr("groups.delete")) },
text = { Text(tr("groups.delete_confirm")) },
confirmButton = {
TextButton(onClick = { confirmDelete = false; vm.deleteGroup(existing.id) { onSaved() } }) {
Text(tr("groups.delete"), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text(tr("common.cancel")) } },
)
}
}

View File

@@ -0,0 +1,77 @@
package com.scarriffle.calendarr.ui.groups
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.domain.model.DirectoryUser
import com.scarriffle.calendarr.domain.model.Group
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class GroupsViewModel @Inject constructor(
private val repository: CalendarRepository,
) : ViewModel() {
var loading by mutableStateOf(true)
private set
var groups by mutableStateOf<List<Group>>(emptyList())
private set
var directory by mutableStateOf<List<DirectoryUser>>(emptyList())
private set
var error by mutableStateOf<String?>(null)
private set
val currentUserId: Int get() = repository.currentUserId
init { load() }
fun load() {
viewModelScope.launch {
loading = true
error = null
groups = runCatching { repository.getGroups() }.getOrDefault(emptyList())
directory = runCatching { repository.getUserDirectory() }.getOrDefault(emptyList())
loading = false
}
}
suspend fun groupDetail(id: Int): Group? = runCatching { repository.getGroup(id) }.getOrNull()
fun createGroup(name: String, icon: String, memberIds: List<Int>, onDone: () -> Unit) {
viewModelScope.launch {
runCatching { repository.createGroup(name, memberIds, icon) }
.onSuccess { load(); onDone() }
.onFailure { error = it.message }
}
}
/** Save name/icon, then reconcile membership against [existingMemberIds]. */
fun saveGroup(id: Int, name: String, icon: String, desired: Set<Int>, existing: Set<Int>, onDone: () -> Unit) {
viewModelScope.launch {
runCatching {
repository.updateGroup(id, name, icon)
for (uid in desired - existing) repository.addGroupMember(id, uid)
for (uid in existing - desired) repository.removeGroupMember(id, uid)
}
.onSuccess { load(); onDone() }
.onFailure { error = it.message }
}
}
fun setMemberColor(groupId: Int, userId: Int, color: String) {
viewModelScope.launch { runCatching { repository.setGroupMemberColor(groupId, userId, color) } }
}
fun deleteGroup(id: Int, onDone: () -> Unit) {
viewModelScope.launch {
runCatching { repository.deleteGroup(id) }
.onSuccess { load(); onDone() }
.onFailure { error = it.message }
}
}
}

View File

@@ -13,6 +13,7 @@ import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Logout import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
@@ -37,6 +38,7 @@ fun MenuSheet(
onProfile: () -> Unit, onProfile: () -> Unit,
onAppearance: () -> Unit, onAppearance: () -> Unit,
onAccounts: () -> Unit, onAccounts: () -> Unit,
onGroups: () -> Unit,
onSync: () -> Unit, onSync: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onSwitchServer: () -> Unit, onSwitchServer: () -> Unit,
@@ -52,6 +54,7 @@ fun MenuSheet(
MenuRow(Icons.Filled.AccountCircle, tr("menu.profile"), onProfile) MenuRow(Icons.Filled.AccountCircle, tr("menu.profile"), onProfile)
MenuRow(Icons.Filled.Palette, tr("menu.appearance"), onAppearance) MenuRow(Icons.Filled.Palette, tr("menu.appearance"), onAppearance)
MenuRow(Icons.Filled.CalendarMonth, tr("menu.accounts"), onAccounts) MenuRow(Icons.Filled.CalendarMonth, tr("menu.accounts"), onAccounts)
MenuRow(Icons.Filled.People, tr("menu.groups"), onGroups)
Divider(Modifier.padding(vertical = 4.dp)) Divider(Modifier.padding(vertical = 4.dp))
MenuRow(Icons.Filled.Sync, tr("menu.sync"), onSync) MenuRow(Icons.Filled.Sync, tr("menu.sync"), onSync)
Divider(Modifier.padding(vertical = 4.dp)) Divider(Modifier.padding(vertical = 4.dp))