Compare commits

..

6 Commits

Author SHA1 Message Date
Guido Schmit
6654012cbb feat: reminders data layer (Android) — model, settings, repository
CalEvent gains `reminders` (parsed from the server); AppSettings/SettingsStore
gain `defaultReminderMinutes` (null = off) and it's sent via updateSettings;
createLocalEvent/updateLocalEvent/eventBody and CalendarViewModel.saveEvent
thread `reminders` through. UI (editor + settings picker) and the local
notification scheduling follow next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:31:32 +02:00
Guido Schmit
807db6a57b feat: non-emoji group icons (Material icons) for consistent look (Android)
Group icons are semantic keys rendered as native Material icons (GroupIcons)
in the picker, group list, top-bar switcher and banner — mirroring iOS/web —
instead of OS emoji. Legacy emoji values render as a fallback. decorateGroup
fallback no longer prepends a glyph (server display_title is authoritative).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:24:48 +02:00
Guido Schmit
644b532104 feat: hide individual member calendars in the group view (Android)
The calendar filter, in group mode, lists the group's members (+ the shared
group calendar) with toggles to hide each individually. Filtering is client-side
via CalendarViewModel.hiddenGroupKeys (gm:<id> / gc), reset on group switch;
members + colours loaded from the group detail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:10:11 +02:00
Guido Schmit
87fc8df146 feat: render server display_title for group events (consistent across clients)
CalEvent parses display_title; decorateGroup uses it (group icon + owner prefix
from the server) instead of the hardcoded glyph, with a fallback for older
servers. Raw title kept for editing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 17:54:23 +02:00
Guido Schmit
7a9bdba557 fix: Android Gruppen-Umschalter sichtbar + Menü überlappt Navigationsleiste nicht
Top-Bar-Gruppenumschalter ist jetzt eine tonale Pille (Akzentfarbe im
Gruppenmodus) statt eines flachen Icons – klar als Button erkennbar; das
Dropdown markiert die aktive Auswahl. Gruppen-Verwaltung: Tipp auf eine Gruppe
öffnet direkt deren Ansicht, Verwalten liegt aufs Zahnrad. Menü-Bottom-Sheet
respektiert jetzt das Navigationsleisten-Inset (+ Scroll-Fallback), sodass
Abmelden/Server wechseln nicht mehr hinter den Android-Buttons liegen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 23:08:42 +02:00
Guido Schmit
3352aa3be7 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>
2026-05-31 22:36:26 +02:00
10 changed files with 706 additions and 23 deletions

View File

@@ -127,6 +127,8 @@ class CalendarRepository @Inject constructor(
"month_divider_color" to s.monthDividerColor, "month_divider_color" to s.monthDividerColor,
"month_label_color" to s.monthLabelColor, "month_label_color" to s.monthLabelColor,
"private_event_visibility" to s.privateEventVisibility, "private_event_visibility" to s.privateEventVisibility,
// Explicit JSON null clears it (off); jsonBody drops Kotlin nulls.
"default_reminder_minutes" to (s.defaultReminderMinutes ?: org.json.JSONObject.NULL),
) )
).ensureSuccess() ).ensureSuccess()
} }
@@ -298,18 +300,18 @@ class CalendarRepository @Inject constructor(
suspend fun createLocalEvent( suspend fun createLocalEvent(
calendarId: Int, title: String, start: Instant, end: Instant, calendarId: Int, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false, isPrivate: Boolean = false, reminders: List<Int>? = null,
) = guarded { ) = guarded {
api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate)) api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders))
.ensureSuccess() .ensureSuccess()
} }
suspend fun updateLocalEvent( suspend fun updateLocalEvent(
uid: String, title: String, start: Instant, end: Instant, uid: String, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false, isPrivate: Boolean = false, reminders: List<Int>? = null,
) = guarded { ) = guarded {
api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color, isPrivate)) api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color, isPrivate, reminders))
.ensureSuccess() .ensureSuccess()
} }
@@ -555,7 +557,7 @@ class CalendarRepository @Inject constructor(
private fun eventBody( private fun eventBody(
calendarId: Int?, title: String, start: Instant, end: Instant, calendarId: Int?, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false, isPrivate: Boolean = false, reminders: List<Int>? = null,
) = jsonBody( ) = jsonBody(
buildMap { buildMap {
calendarId?.let { put("calendar_id", it) } calendarId?.let { put("calendar_id", it) }
@@ -567,6 +569,7 @@ class CalendarRepository @Inject constructor(
put("description", description) put("description", description)
if (!color.isNullOrBlank()) put("color", color) if (!color.isNullOrBlank()) put("color", color)
put("private", isPrivate) put("private", isPrivate)
if (reminders != null) put("reminders", org.json.JSONArray(reminders))
} }
) )
} }

View File

@@ -34,6 +34,7 @@ class SettingsStore @Inject constructor(
language = prefs.getString(K_LANGUAGE, null) ?: "de", language = prefs.getString(K_LANGUAGE, null) ?: "de",
monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0", monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0",
monthLabelColor = prefs.getString(K_LABEL, null) ?: "#7090c0", monthLabelColor = prefs.getString(K_LABEL, null) ?: "#7090c0",
defaultReminderMinutes = prefs.getInt(K_DEFAULT_REMINDER, -1).takeIf { it >= 0 },
) )
fun saveSettings(s: AppSettings) { fun saveSettings(s: AppSettings) {
@@ -50,6 +51,7 @@ class SettingsStore @Inject constructor(
.putString(K_LANGUAGE, s.language) .putString(K_LANGUAGE, s.language)
.putString(K_DIVIDER, s.monthDividerColor) .putString(K_DIVIDER, s.monthDividerColor)
.putString(K_LABEL, s.monthLabelColor) .putString(K_LABEL, s.monthLabelColor)
.putInt(K_DEFAULT_REMINDER, s.defaultReminderMinutes ?: -1)
.apply() .apply()
} }
@@ -83,6 +85,7 @@ class SettingsStore @Inject constructor(
const val K_LANGUAGE = "language" const val K_LANGUAGE = "language"
const val K_DIVIDER = "month_divider_color" const val K_DIVIDER = "month_divider_color"
const val K_LABEL = "month_label_color" const val K_LABEL = "month_label_color"
const val K_DEFAULT_REMINDER = "default_reminder_minutes"
const val K_CACHE_MONTHS = "cache_months" const val K_CACHE_MONTHS = "cache_months"
const val K_HIDDEN = "hidden_calendar_keys" const val K_HIDDEN = "hidden_calendar_keys"
const val K_BANISHED = "banished_calendar_keys" const val K_BANISHED = "banished_calendar_keys"

View File

@@ -25,6 +25,8 @@ data class AppSettings(
// How this user's private events appear to other group members: 'hidden' | 'busy'. // How this user's private events appear to other group members: 'hidden' | 'busy'.
@Json(name = "private_event_visibility") val privateEventVisibility: String = "busy", @Json(name = "private_event_visibility") val privateEventVisibility: String = "busy",
@Json(name = "group_visible_calendar_id") val groupVisibleCalendarId: Int? = null, @Json(name = "group_visible_calendar_id") val groupVisibleCalendarId: Int? = null,
// Minutes-before-start applied to all events client-side; null = off.
@Json(name = "default_reminder_minutes") val defaultReminderMinutes: Int? = null,
) { ) {
val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday" val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday"
} }

View File

@@ -31,6 +31,11 @@ data class CalEvent(
val owner: EventPerson? = null, val owner: EventPerson? = null,
val isGroupEvent: Boolean = false, val isGroupEvent: Boolean = false,
val displayColor: String? = null, val displayColor: String? = null,
// Server-decorated title for the group combined view (group icon / owner
// prefix); rendered in group mode while `title` stays raw for editing.
val displayTitle: String? = null,
// Reminder offsets in minutes-before-start (0 = at start). Local events only.
val reminders: List<Int> = emptyList(),
) { ) {
/** /**
* Group view supplies a server-resolved colour (display_color); otherwise * Group view supplies a server-resolved colour (display_color); otherwise
@@ -117,6 +122,10 @@ data class CalEvent(
owner = personFrom(json, "owner"), owner = personFrom(json, "owner"),
isGroupEvent = json.optBoolean("is_group_event", false), isGroupEvent = json.optBoolean("is_group_event", false),
displayColor = json.strOrNull("display_color"), displayColor = json.strOrNull("display_color"),
displayTitle = json.strOrNull("display_title"),
reminders = json.optJSONArray("reminders")?.let { arr ->
(0 until arr.length()).mapNotNull { (arr.opt(it) as? Number)?.toInt() }
} ?: emptyList(),
) )
} }
} }

View File

@@ -36,6 +36,20 @@ fun CalendarFilterSheet(
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val state by vm.state.collectAsState() val state by vm.state.collectAsState()
val groupMode = state.activeGroup != null
// In group mode the filter lists members (+ the group calendar) so they can
// be hidden individually, Outlook-style; otherwise the normal calendars.
val groupEntries: List<CalendarFilterEntry> = if (groupMode) {
buildList {
state.activeGroupMembers.forEach { m ->
add(CalendarFilterEntry(groupMemberKey(m.id), m.displayName, m.color ?: "#4285f4"))
}
add(CalendarFilterEntry(GROUP_CALENDAR_KEY, tr("groups.calendar"), state.activeGroup?.groupCalendarColor ?: "#4285f4"))
}
} else emptyList()
val rows = if (groupMode) groupEntries else events
val hiddenSet = if (groupMode) state.hiddenGroupKeys else state.hiddenKeys
ModalBottomSheet(onDismissRequest = onDismiss) { ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
@@ -46,21 +60,24 @@ fun CalendarFilterSheet(
) { ) {
Text(tr("filter.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) Text(tr("filter.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Row { Row {
TextButton(onClick = { vm.setHiddenCalendars(emptySet()) }) { Text(tr("filter.show_all")) }
TextButton(onClick = { TextButton(onClick = {
vm.setHiddenCalendars(events.map { it.key }.toSet()) if (groupMode) vm.setHiddenGroupKeys(emptySet()) else vm.setHiddenCalendars(emptySet())
}) { Text(tr("filter.show_all")) }
TextButton(onClick = {
if (groupMode) vm.setHiddenGroupKeys(rows.map { it.key }.toSet())
else vm.setHiddenCalendars(rows.map { it.key }.toSet())
}) { Text(tr("filter.hide_all")) } }) { Text(tr("filter.hide_all")) }
} }
} }
if (events.isEmpty()) { if (rows.isEmpty()) {
Text( Text(
tr("filter.empty"), tr("filter.empty"),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 24.dp), modifier = Modifier.padding(vertical = 24.dp),
) )
} }
events.forEach { entry -> rows.forEach { entry ->
val visible = entry.key !in state.hiddenKeys val visible = entry.key !in hiddenSet
Row( Row(
Modifier.fillMaxWidth().padding(vertical = 8.dp), Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -73,7 +90,10 @@ fun CalendarFilterSheet(
) )
Switch( Switch(
checked = visible, checked = visible,
onCheckedChange = { vm.setCalendarHidden(entry.key, hidden = !it) }, onCheckedChange = {
if (groupMode) vm.setGroupKeyHidden(entry.key, hidden = !it)
else vm.setCalendarHidden(entry.key, hidden = !it)
},
) )
} }
} }

View File

@@ -5,6 +5,8 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -16,10 +18,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ChevronLeft 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
@@ -35,6 +39,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -44,6 +49,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -55,15 +61,18 @@ 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.GroupIcon
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 +122,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 +148,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 +177,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 +246,11 @@ 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() },
onOpenGroupView = { g -> overlay = Overlay.NONE; vm.switchGroup(g) },
)
Overlay.NONE -> Unit Overlay.NONE -> Unit
} }
} }
@@ -296,6 +317,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 +355,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 +387,70 @@ private fun CompactIcon(
} }
} }
/**
* Top-bar switcher: "My calendar" + each group; flips the calendar into the
* group overlay. Rendered as a tonal pill so it stands out from the flat icons
* (filled in the accent colour while a group overlay is active).
*/
@Composable
private fun GroupSwitcher(groups: List<Group>, activeGroup: Group?, onSwitchGroup: (Group?) -> Unit) {
var open by remember { mutableStateOf(false) }
val active = activeGroup != null
Box {
Box(
Modifier
.padding(horizontal = 2.dp)
.size(38.dp)
.clip(CircleShape)
.background(if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant)
.clickable { open = true },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.People,
contentDescription = tr("groups.title"),
modifier = Modifier.size(21.dp),
tint = if (active) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
DropdownMenuItem(
text = { Text(tr("group.switch.personal")) },
trailingIcon = { if (!active) Icon(Icons.Filled.Check, contentDescription = null) },
onClick = { open = false; onSwitchGroup(null) },
)
groups.forEach { g ->
DropdownMenuItem(
text = { Text(g.name) },
leadingIcon = { GroupIcon(g.icon) },
trailingIcon = { if (activeGroup?.id == g.id) Icon(Icons.Filled.Check, contentDescription = null) },
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,
) {
GroupIcon(group.icon, modifier = Modifier.padding(end = 6.dp))
Text(
"${tr("groups.view")}: ${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,8 @@ 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.GroupMember
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,8 +40,18 @@ 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,
// Group overlay: full member list (for the filter) + per-member / group-cal
// hidden keys ("gm:<userId>" / "gc"). In-memory; reset when switching group.
val activeGroupMembers: List<GroupMember> = emptyList(),
val hiddenGroupKeys: Set<String> = emptySet(),
) )
fun groupMemberKey(ownerId: Int): String = "gm:$ownerId"
const val GROUP_CALENDAR_KEY = "gc"
@HiltViewModel @HiltViewModel
class CalendarViewModel @Inject constructor( class CalendarViewModel @Inject constructor(
private val repository: CalendarRepository, private val repository: CalendarRepository,
@@ -68,6 +80,7 @@ class CalendarViewModel @Inject constructor(
init { init {
loadWritableCalendars() loadWritableCalendars()
loadGroups()
initialLoad() initialLoad()
} }
@@ -193,15 +206,38 @@ 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 ->
// Prefer the server-decorated title (group icon + owner prefix) so
// web, iOS and Android render identically; fall back for old servers.
val serverTitle = ev.displayTitle?.takeIf { it.isNotEmpty() }
if (serverTitle != null) return@map ev.copy(title = serverTitle)
val prefix = when {
ev.isGroupEvent && ev.creator != null && ev.creator.id != me -> "${firstName(ev.creator.displayName)}: "
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,11 +258,26 @@ 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: server scopes/filters by privacy; locally honour the
val visible = allCachedEvents.filter { ev -> // per-member / group-calendar hide toggles (hiddenGroupKeys).
val key = calendarKey(ev.source, ev.calendarId) val visible = if (st.activeGroup != null) {
key !in hidden && key !in banished val hg = st.hiddenGroupKeys
if (hg.isEmpty()) allCachedEvents
else allCachedEvents.filter { ev ->
when {
ev.isGroupEvent -> GROUP_CALENDAR_KEY !in hg
ev.owner != null -> groupMemberKey(ev.owner.id ?: -1) !in hg
else -> true
}
}
} else {
val hidden = st.hiddenKeys
val banished = st.banishedKeys
allCachedEvents.filter { ev ->
val key = calendarKey(ev.source, ev.calendarId)
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 +291,7 @@ class CalendarViewModel @Inject constructor(
fun syncWithServer() { fun syncWithServer() {
invalidateCache() invalidateCache()
loadGroups()
initialLoad() initialLoad()
} }
@@ -275,6 +327,53 @@ 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, hiddenGroupKeys = emptySet(), activeGroupMembers = emptyList())
}
invalidateCache()
initialLoad()
// Load the full member list (with server colours) for the filter sheet.
if (group != null) {
viewModelScope.launch {
runCatching { repository.getGroup(group.id) }
.onSuccess { g -> _state.update { it.copy(activeGroupMembers = g.members) } }
}
}
}
/** Toggle a single member's calendar / the group calendar in the overlay. */
fun setGroupKeyHidden(key: String, hidden: Boolean) {
_state.update {
val next = it.hiddenGroupKeys.toMutableSet().apply { if (hidden) add(key) else remove(key) }
it.copy(hiddenGroupKeys = next)
}
refreshFromCache()
}
/** Replace the group-overlay hidden set (bulk show/hide all). */
fun setHiddenGroupKeys(keys: Set<String>) {
_state.update { it.copy(hiddenGroupKeys = keys) }
refreshFromCache()
}
// ---- Writable calendars ---- // ---- Writable calendars ----
fun loadWritableCalendars() { fun loadWritableCalendars() {
@@ -302,20 +401,21 @@ class CalendarViewModel @Inject constructor(
description: String, description: String,
color: String?, color: String?,
isPrivate: Boolean, isPrivate: Boolean,
reminders: List<Int> = emptyList(),
onResult: (String?) -> Unit, onResult: (String?) -> Unit,
) { ) {
viewModelScope.launch { viewModelScope.launch {
val result = runCatching { val result = runCatching {
if (existing != null && existing.source == calendar.source) { if (existing != null && existing.source == calendar.source) {
when (existing.source) { when (existing.source) {
"local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color, isPrivate) "local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
"caldav" -> repository.updateCalDAVEvent(existing.id, existing.url, calendar.numericId, title, start, end, isAllDay, location, description, color) "caldav" -> repository.updateCalDAVEvent(existing.id, existing.url, calendar.numericId, title, start, end, isAllDay, location, description, color)
"homeassistant" -> repository.updateHAEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description) "homeassistant" -> repository.updateHAEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description)
"google" -> repository.updateGoogleEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description) "google" -> repository.updateGoogleEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description)
else -> createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate) else -> createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
} }
} else { } else {
createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate) createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
} }
} }
result.onSuccess { afterMutation(); onResult(null) } result.onSuccess { afterMutation(); onResult(null) }
@@ -326,9 +426,10 @@ class CalendarViewModel @Inject constructor(
private suspend fun createForSource( private suspend fun createForSource(
calendar: WritableCalendar, title: String, start: Instant, end: Instant, calendar: WritableCalendar, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean, isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean,
reminders: List<Int> = emptyList(),
) { ) {
when (calendar.source) { when (calendar.source) {
"local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate) "local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
"caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color) "caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color)
"google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description) "google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description)
"homeassistant" -> repository.createHAEvent(calendar.numericId, title, start, end, isAllDay, location, description) "homeassistant" -> repository.createHAEvent(calendar.numericId, title, start, end, isAllDay, location, description)

View File

@@ -0,0 +1,364 @@
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.Celebration
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DirectionsRun
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Flight
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Pets
import androidx.compose.material.icons.filled.Restaurant
import androidx.compose.material.icons.filled.School
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Work
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.graphics.vector.ImageVector
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
/**
* Cross-platform group-icon keys (stored server-side) rendered as native
* Material icons — consistent everywhere instead of OS-emoji that vary by
* platform. Mirrors iOS GroupIcons / the web SVG set.
*/
object GroupIcons {
val keys = listOf(
"people", "home", "heart", "work", "school", "sports",
"party", "pet", "travel", "music", "food", "star",
)
fun vector(key: String?): ImageVector = when (key) {
"people" -> Icons.Filled.People
"home" -> Icons.Filled.Home
"heart" -> Icons.Filled.Favorite
"work" -> Icons.Filled.Work
"school" -> Icons.Filled.School
"sports" -> Icons.Filled.DirectionsRun
"party" -> Icons.Filled.Celebration
"pet" -> Icons.Filled.Pets
"travel" -> Icons.Filled.Flight
"music" -> Icons.Filled.MusicNote
"food" -> Icons.Filled.Restaurant
"star" -> Icons.Filled.Star
else -> Icons.Filled.People
}
fun isKey(s: String?): Boolean = s != null && s in keys
}
/** Render a group's icon: native Material icon for keys, legacy emoji fallback. */
@Composable
fun GroupIcon(icon: String?, modifier: Modifier = Modifier, tint: androidx.compose.ui.graphics.Color? = null) {
if (GroupIcons.isKey(icon)) {
Icon(GroupIcons.vector(icon), contentDescription = null, modifier = modifier,
tint = tint ?: androidx.compose.material3.LocalContentColor.current)
} else if (!icon.isNullOrEmpty()) {
Text(icon, modifier = modifier)
} else {
Icon(Icons.Filled.People, contentDescription = null, modifier = modifier,
tint = tint ?: androidx.compose.material3.LocalContentColor.current)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroupsScreen(
onClose: () -> Unit,
onChanged: () -> Unit,
onOpenGroupView: (Group) -> 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 { onOpenGroupView(g) }.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GroupIcon(g.icon, tint = MaterialTheme.colorScheme.onSurface)
Column(Modifier.weight(1f).padding(start = 12.dp)) {
Text(g.name, style = MaterialTheme.typography.bodyLarge)
Text(
tr("groups.member_count", g.memberCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { manageId = g.id }) {
Icon(Icons.Filled.Tune, contentDescription = tr("groups.manage"), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
Icon(Icons.Filled.ChevronRight, contentDescription = tr("groups.view"), tint = 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(if (GroupIcons.isKey(existing?.icon)) existing!!.icon!! else "people") }
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 = if (GroupIcons.isKey(g.icon)) g.icon!! else "people"
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)) {
GroupIcons.keys.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,
) {
Icon(
GroupIcons.vector(ic),
contentDescription = ic,
tint = if (sel) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
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

@@ -6,13 +6,17 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle 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,12 +41,20 @@ 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,
) { ) {
ModalBottomSheet(onDismissRequest = onDismiss) { ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.fillMaxWidth().padding(bottom = 24.dp)) { // Inset for the system navigation bar so the last rows aren't hidden
// behind the Android nav buttons; scroll as a fallback on short screens.
Column(
Modifier.fillMaxWidth()
.verticalScroll(rememberScrollState())
.navigationBarsPadding()
.padding(bottom = 12.dp),
) {
Text( Text(
"Calendarr", "Calendarr",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
@@ -52,6 +64,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))