diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailApi.kt b/app/src/main/java/com/gatecontrol/android/mail/MailApi.kt index 26350d8e..c894e4f6 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailApi.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailApi.kt @@ -408,6 +408,53 @@ class MailApi @Inject constructor( suspend fun dismissInvitation(id: Long, decline: Boolean): MResult = post("invitations/$id/dismiss", MailPimJson.dismissBody(decline), timeoutMs = 60_000L, parse = unit) + // ─── free/busy, sharing, resources (since Mail 1.9.0) ─────────── + + /** `POST freebusy`: the day's free/busy of the person, [attendees] and [resources] (never titles). */ + suspend fun freeBusy(from: Long, to: Long, attendees: List, resources: List): MResult = + post("freebusy", MailPimJson.freeBusyBody(from, to, attendees, resources), parse = MailPimJson::freeBusy) + + /** `POST freebusy/suggest`: up to [limit] slots of [duration] minutes from [from] when everybody known is free. */ + suspend fun suggest(duration: Int, from: Long?, attendees: List, resources: List, limit: Int = 4): MResult = + post("freebusy/suggest", MailPimJson.suggestBody(duration, from, attendees, resources, limit), parse = MailPimJson::suggestions) + + suspend fun shares(calendarId: Long): MResult = get("calendars/$calendarId/shares", parse = MailPimJson::shares) + + suspend fun addShare(calendarId: Long, type: String, principalId: Long, level: String): MResult = + post("calendars/$calendarId/shares", MailPimJson.shareBody(type, principalId, level), parse = MailPimJson::shareAnswer) + + suspend fun patchShare(calendarId: Long, shareId: Long, level: String): MResult = + patch("calendars/$calendarId/shares/$shareId", MailPimJson.levelBody(level), parse = MailPimJson::shareAnswer) + + suspend fun deleteShare(calendarId: Long, shareId: Long): MResult = del("calendars/$calendarId/shares/$shareId", unit) + + suspend fun shared(): MResult = get("shared", parse = MailPimJson::sharedList) + + suspend fun patchShared(calendarId: Long, visible: Boolean? = null, color: String? = null): MResult = + patch("shared/$calendarId", MailPimJson.sharedPatch(visible, color), parse = MailPimJson::sharedAnswer) + + /** People (who may use Mail) and groups by name, to share with ([q] ≥ 2 characters). */ + suspend fun principals(q: String): MResult> = get("principals", mapOf("q" to q.trim().take(60)), MailPimJson::principals) + + /** `GET resources` (with [from]/[to]: each one's taken times in it, ≤ 31 days). */ + suspend fun resources(from: Long? = null, to: Long? = null): MResult = + get("resources", buildMap { + if (from != null && to != null) { + put("from", from.toString()) + put("to", to.toString()) + } + }, MailPimJson::resources) + + suspend fun book(resourceId: Long, body: JsonObject): MResult = post("resources/$resourceId/book", body, timeoutMs = 60_000L, parse = MailPimJson::eventSaved) + + /** `GET bookings?state=open|done|mine`. */ + suspend fun bookings(state: String): MResult = get("bookings", mapOf("state" to state), MailPimJson::bookings) + + suspend fun decideBooking(id: Long, approve: Boolean, override: Boolean = false, comment: String? = null): MResult = + post("bookings/$id/decide", MailPimJson.decideBody(approve, override, comment), timeoutMs = 60_000L, parse = MailPimJson::bookingAnswer) + + suspend fun cancelBooking(id: Long): MResult = del("bookings/$id", unit) + // ─── contacts (since Mail 1.2.0, path `cards`) ────────────────── suspend fun addressbooks(): MResult> = get("addressbooks") { o -> MailPimJson.books(o)?.let { it to o } } diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailCalendarModels.kt b/app/src/main/java/com/gatecontrol/android/mail/MailCalendarModels.kt index 56bd5f1a..880c2434 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailCalendarModels.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailCalendarModels.kt @@ -49,6 +49,8 @@ data class MCalendar( val errorCode: String?, val syncedAt: Long?, val version: Long, + /** Since Mail 1.9.0: a Microsoft 365 calendar of another mailbox the account sees (its owner's address). */ + val sharedBy: String? = null, ) { val local: Boolean get() = source == SOURCE_LOCAL @@ -77,6 +79,10 @@ data class MCalPrefs( val defaultReminder: Int?, /** Minutes before midnight of an all-day event (360 → 18:00 the day before, 1080 → 06:00 the day before), null: none. */ val allDayReminder: Int?, + /** Since Mail 1.9.0: „Frei/Gebucht für alle in GateControl“ (default on). */ + val freeBusy: Boolean = true, + /** Since Mail 1.9.0: the working hours (free/busy's window and the suggestions). */ + val workHours: MWorkHours = MWorkHours.DEFAULT, ) { companion object { const val VIEW_DAY = "day" @@ -92,9 +98,17 @@ data class MCalPrefs( /** A Microsoft account whose calendar needs the person to sign in again. */ data class MConsent(val accountId: Long, val missing: List) -/** `GET calendars`. */ -data class MCalendars(val calendars: List, val overlays: List, val prefs: MCalPrefs, val consent: List) { +/** `GET calendars` (since Mail 1.9.0 also the GateControl calendars shared with the person, [shared]). */ +data class MCalendars( + val calendars: List, + val overlays: List, + val prefs: MCalPrefs, + val consent: List, + val shared: List = emptyList(), +) { fun calendar(id: Long?): MCalendar? = id?.let { c -> calendars.firstOrNull { it.id == c } } + + fun sharedCalendar(id: Long?): MSharedCalendar? = id?.let { c -> shared.firstOrNull { it.id == c } } } data class MOnline(val url: String, /** teams | zoom | meet | webex | other */ val provider: String) @@ -127,8 +141,15 @@ data class MOccurrence( val attendees: Int, val private: Boolean, val reminder: Int?, + /** + * Since Mail 1.9.0: an entry of a calendar shared with the person — `freebusy` | `read` | `write`; null: an own one. + * Free/busy and private entries come as „Gebucht“ / „Vorläufig“ without an event ([eventId] 0, never opened). + */ + val shared: String? = null, ) { val cancelled: Boolean get() = status == STATUS_CANCELLED + /** Only a time („Gebucht“ of a shared calendar): nothing to open. */ + val busyOnly: Boolean get() = eventId <= 0L /** An invitation not answered yet (mockup: dashed). */ val open: Boolean get() = myResponse == RESPONSE_NEEDS_ACTION @@ -258,8 +279,8 @@ data class MSeries(val start: Long, val end: Long, val allDay: Boolean, val star /** The new times of a series moved from one of its occurrences ([MailPimLogic.shiftSeries]). */ data class MShift(val start: Long? = null, val end: Long? = null, val startDate: String? = null, val endDate: String? = null) -/** `POST/PATCH events` → `{ event, sent }`. */ -data class MEventSaved(val event: MEvent, val sentMail: Int, val sentInternal: Int) +/** `POST/PATCH events` → `{ event, sent }` (since Mail 1.9.0 with resources among the attendees also their [bookings]). */ +data class MEventSaved(val event: MEvent, val sentMail: Int, val sentInternal: Int, val bookings: List = emptyList()) data class MInviteCounts(val accepted: Int, val tentative: Int, val declined: Int, val needsAction: Int) { val total: Int get() = accepted + tentative + declined + needsAction @@ -365,11 +386,15 @@ data class MCalChanged( val invitationId: Long? = null, val messageId: Long? = null, val eventId: Long? = null, + /** Since Mail 1.9.0: a change in a calendar shared with the person. */ + val shared: Boolean = false, ) { companion object { const val CHANGED = "changed" const val CALENDARS = "calendars" const val INVITATION = "invitation" const val CONFLICT = "conflict" + /** Since Mail 1.9.0: the shares of a calendar changed (its owner and the people it is shared with). */ + const val SHARES = "shares" } } diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt b/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt index 9211636a..d49b44eb 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt @@ -48,6 +48,11 @@ data class MMe( /** Since Mail 1.2.0: calendars and contacts (the administrator switched them on). */ const val FEATURE_CALENDAR = "calendar" const val FEATURE_CONTACTS = "contacts" + /** Since Mail 1.9.0: free/busy in the event form, sharing GateControl calendars, resources, deciding requests. */ + const val FEATURE_FREE_BUSY = "freeBusy" + const val FEATURE_SHARING = "calendarSharing" + const val FEATURE_RESOURCES = "resources" + const val FEATURE_BOOKING_REQUESTS = "bookingRequests" } } diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailPimJson.kt b/app/src/main/java/com/gatecontrol/android/mail/MailPimJson.kt index 2a72c964..371c5026 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailPimJson.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailPimJson.kt @@ -112,6 +112,7 @@ object MailPimJson { errorCode = o.obj("error")?.str("code"), syncedAt = o.long("syncedAt"), version = o.long("version") ?: 0L, + sharedBy = o.obj("sharedBy")?.text("email")?.takeIf { it.length <= 320 }, ) } @@ -132,6 +133,8 @@ object MailPimJson { reminders = o.bool("reminders") ?: d.reminders, defaultReminder = if (o.has("defaultReminder")) o.int("defaultReminder")?.coerceAtLeast(0) else d.defaultReminder, allDayReminder = if (o.has("allDayReminder")) o.int("allDayReminder")?.coerceAtLeast(0) else d.allDayReminder, + freeBusy = o.bool("freeBusy") ?: d.freeBusy, + workHours = o.obj("workHours")?.let(::workHours) ?: d.workHours, ) } @@ -145,6 +148,7 @@ object MailPimJson { consent = o.arr("consent")?.objects()?.mapNotNull { c -> MConsent(c.id("accountId") ?: return@mapNotNull null, c.arr("missing")?.strings().orEmpty()) }.orEmpty(), + shared = o.arr("shared")?.objects()?.mapNotNull(::sharedCalendar).orEmpty(), ) } @@ -163,13 +167,15 @@ object MailPimJson { } fun occurrence(o: JsonObject): MOccurrence? { - val eventId = o.id("eventId") ?: return null + val shared = o.str("shared")?.takeIf { it in MShare.LEVELS } + // a free/busy entry of a shared calendar has no event (only its time) + val eventId = o.id("eventId") ?: if (shared != null && o.el("eventId") == null) 0L else return null val start = o.long("start") ?: return null val end = (o.long("end") ?: start).coerceAtLeast(start) val occ = o.str("occurrence")?.takeIf { it.isNotEmpty() && it.length <= 40 } val allDay = o.bool("allDay") ?: false return MOccurrence( - key = o.str("key")?.takeIf { it.isNotEmpty() } ?: (if (occ != null) "$eventId:$occ" else "$eventId"), + key = o.str("key")?.takeIf { it.isNotEmpty() } ?: (if (occ != null) "$eventId:$occ" else if (eventId > 0) "$eventId" else return null), eventId = eventId, calendarId = o.id("calendarId") ?: 0L, occurrence = occ, @@ -190,6 +196,7 @@ object MailPimJson { attendees = o.int("attendees")?.coerceAtLeast(0) ?: o.arr("attendees")?.size() ?: 0, private = o.bool("private") ?: false, reminder = o.int("reminder")?.takeIf { it >= 0 }, + shared = shared, ) } @@ -299,7 +306,10 @@ object MailPimJson { fun eventSaved(o: JsonObject): MEventSaved? { val ev = o.obj("event")?.let(::event) ?: return null val sent = o.obj("sent") - return MEventSaved(ev, sent?.int("mail")?.coerceAtLeast(0) ?: 0, sent?.int("internal")?.coerceAtLeast(0) ?: 0) + return MEventSaved( + ev, sent?.int("mail")?.coerceAtLeast(0) ?: 0, sent?.int("internal")?.coerceAtLeast(0) ?: 0, + bookings = o.arr("bookings")?.objects()?.mapNotNull(::booking).orEmpty(), + ) } /** The newer state a `409 conflict` carries (`error.current`): an Event. */ @@ -725,14 +735,298 @@ object MailPimJson { return MPhoto(mime, bytes) } + // ─── free/busy, sharing, resources (Mail 1.9.0) ───────────────── + + private val HHMM = Regex("^([01]\\d|2[0-3]):[0-5]\\d$|^24:00$") + + /** Working hours: days 1…7, `HH:MM` with from < to; null when not readable. */ + fun workHours(o: JsonObject): MWorkHours? { + val days = o.arr("days")?.ints()?.filter { it in 1..7 }?.distinct()?.sorted() ?: return null + val from = o.str("from")?.takeIf { HHMM.matches(it) && it != "24:00" } ?: return null + val to = o.str("to")?.takeIf { HHMM.matches(it) } ?: return null + if (to <= from) return null + return MWorkHours(days, from, to) + } + + private fun block(o: JsonObject): MBusyBlock? { + val start = o.long("start") ?: return null + val end = o.long("end")?.takeIf { it > start } ?: return null + val status = when (o.str("status")) { + MBusyBlock.TENTATIVE -> MBusyBlock.TENTATIVE + MBusyBlock.PENDING -> MBusyBlock.PENDING + else -> MBusyBlock.BUSY + } + return MBusyBlock(start, end, status, o.bool("mine") ?: false) + } + + private fun slot(o: JsonObject): MSlot? { + val start = o.long("start") ?: return null + return MSlot(start, o.long("end")?.takeIf { it >= start } ?: return null) + } + + fun freeBusyRow(o: JsonObject): MFreeBusyRow? { + val kind = o.str("kind")?.takeIf { it in setOf(MFreeBusyRow.SELF, MFreeBusyRow.PERSON, MFreeBusyRow.MICROSOFT, MFreeBusyRow.RESOURCE, MFreeBusyRow.EXTERNAL) } ?: MFreeBusyRow.EXTERNAL + val email = o.text("email") + val key = o.text("key") ?: email ?: o.id("resourceId")?.let { "r:$it" } ?: if (kind == MFreeBusyRow.SELF) "self" else return null + val known = o.str("state") == "ok" + return MFreeBusyRow( + key = key, + kind = kind, + email = email, + resourceId = o.id("resourceId"), + userId = o.id("userId"), + name = o.str("name")?.trim().orEmpty(), + known = known, + blocks = if (known) o.arr("blocks")?.objects()?.mapNotNull(::block)?.map { if (it.status == MBusyBlock.PENDING) it.copy(status = MBusyBlock.TENTATIVE) else it }.orEmpty() else emptyList(), + ) + } + + /** `POST freebusy`. */ + fun freeBusy(o: JsonObject): MFreeBusy? { + val items = o.arr("items") ?: return null + return MFreeBusy(o.long("from") ?: 0L, o.long("to") ?: 0L, items.objects().mapNotNull(::freeBusyRow)) + } + + /** `POST freebusy/suggest`. */ + fun suggestions(o: JsonObject): MSuggestions? { + val items = o.arr("items") ?: return null + return MSuggestions( + items = items.objects().mapNotNull(::slot), + unknown = o.arr("unknown")?.strings().orEmpty(), + workHours = o.obj("workHours")?.let(::workHours), + timeZone = o.zone("timeZone"), + ) + } + + fun sharedCalendar(o: JsonObject): MSharedCalendar? { + val id = o.id("id") ?: return null + val owner = o.obj("owner") + return MSharedCalendar( + id = id, + name = o.text("name") ?: return null, + color = o.color("color") ?: "#8C96A5", + ownerId = owner?.id("userId"), + ownerName = owner?.str("name")?.trim().orEmpty(), + level = o.str("level")?.takeIf { it in MShare.LEVELS } ?: MShare.FREEBUSY, + via = if (o.str("via") == MShare.GROUP) MShare.GROUP else MShare.USER, + group = o.text("group"), + visible = o.bool("visible") ?: false, + accepted = o.bool("accepted") ?: false, + writable = o.bool("writable") ?: false, + ) + } + + /** `GET shared`. */ + fun sharedList(o: JsonObject): MSharedList? { + val items = o.arr("items") ?: return null + return MSharedList( + items = items.objects().mapNotNull(::sharedCalendar), + accounts = o.arr("accounts")?.objects()?.mapNotNull { a -> + MSharedAccountCalendar( + calendarId = a.id("calendarId") ?: return@mapNotNull null, + accountId = a.id("accountId"), + name = a.text("name") ?: return@mapNotNull null, + color = a.color("color") ?: FALLBACK_COLOR, + ownerEmail = a.obj("owner")?.text("email").orEmpty(), + writable = a.bool("writable") ?: false, + visible = a.bool("visible") ?: true, + ) + }.orEmpty(), + ) + } + + /** `{ item }` of `PATCH shared/`. */ + fun sharedAnswer(o: JsonObject): MSharedCalendar? = o.obj("item")?.let(::sharedCalendar) + + fun share(o: JsonObject): MShare? { + val id = o.id("id") ?: return null + val type = if (o.str("type") == MShare.GROUP) MShare.GROUP else MShare.USER + return MShare( + id = id, + type = type, + principalId = o.id("principalId") ?: 0L, + name = o.str("name")?.trim().orEmpty(), + level = o.str("level")?.takeIf { it in MShare.LEVELS } ?: MShare.READ, + state = o.str("state") ?: (if (type == MShare.GROUP) MShare.GROUP else MShare.STATE_PENDING), + members = o.int("members")?.coerceAtLeast(0), + ) + } + + /** `GET calendars//shares`. */ + fun shares(o: JsonObject): MShares? { + val items = o.arr("items") ?: return null + return MShares(items.objects().mapNotNull(::share), o.bool("freeBusy")) + } + + /** `{ share }`. */ + fun shareAnswer(o: JsonObject): MShare? = o.obj("share")?.let(::share) + + /** `GET principals`. */ + fun principals(o: JsonObject): List? = o.arr("items")?.objects()?.mapNotNull { p -> + val type = p.str("type")?.takeIf { it == MShare.USER || it == MShare.GROUP } ?: return@mapNotNull null + MPrincipal( + type = type, + id = p.id("id") ?: return@mapNotNull null, + name = p.text("name") ?: return@mapNotNull null, + initials = p.text("initials")?.take(3), + color = p.color("color"), + sub = p.text("sub"), + members = p.int("members")?.coerceAtLeast(0), + ) + } + + fun resource(o: JsonObject): MResource? { + val id = o.id("id") ?: return null + val rules = o.obj("rules") + return MResource( + id = id, + name = o.text("name") ?: return null, + kind = o.str("kind")?.takeIf { it in MResource.KINDS } ?: MResource.OTHER, + location = o.text("location"), + seats = o.int("seats")?.takeIf { it > 0 }, + equipment = o.text("equipment"), + color = o.color("color") ?: "#8C96A5", + approval = if (o.str("approval") == MResource.APPROVAL) MResource.APPROVAL else MResource.AUTO, + approvers = o.arr("approvers")?.strings()?.map { it.trim() }?.filter { it.isNotEmpty() }.orEmpty(), + rules = MResourceRules( + maxMinutes = rules?.int("maxMinutes")?.takeIf { it > 0 }, + horizonDays = rules?.int("horizonDays")?.takeIf { it > 0 }, + hours = rules?.obj("hours")?.let(::workHours), + ), + timeZone = o.zone("timeZone"), + address = o.text("address") ?: return null, + busy = o.arr("busy")?.objects()?.mapNotNull(::block).orEmpty(), + ) + } + + /** `GET resources`. */ + fun resources(o: JsonObject): MResources? { + val items = o.arr("items") ?: return null + return MResources(items.objects().mapNotNull(::resource), o.arr("kinds")?.strings()?.filter { it in MResource.KINDS } ?: MResource.KINDS) + } + + fun violation(o: JsonObject): MViolation? { + val rule = o.str("rule")?.takeIf { it in setOf(MViolation.MAX_DURATION, MViolation.HORIZON, MViolation.HOURS) } ?: return null + return MViolation(rule, o.int("limit"), o.arr("days")?.ints()?.filter { it in 1..7 }.orEmpty(), o.str("from")?.takeIf { HHMM.matches(it) }, o.str("to")?.takeIf { HHMM.matches(it) }) + } + + /** The rules of a refusal (`409 rule_violation { violations }`). */ + fun violations(error: JsonObject?): List = + (error?.obj("error") ?: error)?.arr("violations")?.objects()?.mapNotNull(::violation).orEmpty() + + /** Who decided first (`409 already_decided { decidedBy }`). */ + fun decidedBy(error: JsonObject?): String? = (error?.obj("error") ?: error)?.obj("decidedBy")?.text("name") + + fun booking(o: JsonObject): MBooking? { + val id = o.id("id") ?: return null + val start = o.long("start") ?: return null + val r = o.obj("resource") + val allDay = o.bool("allDay") ?: false + return MBooking( + id = id, + resourceId = o.id("resourceId") ?: r?.id("id") ?: return null, + resourceName = r?.text("name").orEmpty(), + resourceKind = r?.str("kind")?.takeIf { it in MResource.KINDS } ?: MResource.OTHER, + resourceColor = r?.color("color"), + resourceLocation = r?.text("location"), + title = o.str("title")?.trim().orEmpty(), + start = start, + end = (o.long("end") ?: start).coerceAtLeast(start), + allDay = allDay, + startDate = o.date("startDate"), + endDate = o.date("endDate") ?: o.date("startDate"), + state = o.str("state")?.takeIf { it in setOf(MBooking.PENDING, MBooking.CONFIRMED, MBooking.DECLINED, MBooking.CANCELLED) } ?: MBooking.PENDING, + bookerName = o.obj("booker")?.str("name")?.trim().orEmpty(), + eventId = o.id("eventId"), + note = o.text("note"), + violations = o.arr("violations")?.objects()?.mapNotNull(::violation).orEmpty(), + conflicts = o.arr("conflicts")?.objects()?.mapNotNull(::slot).orEmpty(), + decidedBy = o.obj("decidedBy")?.text("name"), + decidedAt = o.long("decidedAt"), + override = o.bool("override") ?: false, + comment = o.text("comment"), + canDecide = o.bool("canDecide") ?: false, + day = o.arr("day")?.objects()?.mapNotNull(::block).orEmpty(), + ) + } + + /** `GET bookings`. */ + fun bookings(o: JsonObject): MBookings? { + val items = o.arr("items") ?: return null + return MBookings(items.objects().mapNotNull(::booking), o.obj("counts")?.int("open")?.coerceAtLeast(0) ?: 0) + } + + /** `{ booking }` of `POST bookings//decide`. */ + fun bookingAnswer(o: JsonObject): MBooking? = o.obj("booking")?.let(::booking) + + fun freeBusyBody(from: Long, to: Long, attendees: List, resources: List): JsonObject = JsonObject().apply { + addProperty("from", from) + addProperty("to", to) + add("attendees", JsonArray().apply { attendees.map { it.trim().lowercase() }.filter { it.isNotEmpty() }.distinct().take(50).forEach { add(it) } }) + if (resources.isNotEmpty()) add("resources", JsonArray().apply { resources.distinct().take(20).forEach { add(it) } }) + } + + fun suggestBody(duration: Int, from: Long?, attendees: List, resources: List, limit: Int = 4): JsonObject = JsonObject().apply { + addProperty("duration", duration.coerceIn(5, 1440)) + from?.let { addProperty("from", it) } + add("attendees", JsonArray().apply { attendees.map { it.trim().lowercase() }.filter { it.isNotEmpty() }.distinct().take(50).forEach { add(it) } }) + if (resources.isNotEmpty()) add("resources", JsonArray().apply { resources.distinct().take(20).forEach { add(it) } }) + addProperty("limit", limit.coerceIn(1, 10)) + } + + fun shareBody(type: String, id: Long, level: String): JsonObject = JsonObject().apply { + addProperty("type", if (type == MShare.GROUP) MShare.GROUP else MShare.USER) + addProperty("id", id) + addProperty("level", level.takeIf { it in MShare.LEVELS } ?: MShare.READ) + } + + fun levelBody(level: String): JsonObject = JsonObject().apply { addProperty("level", level.takeIf { it in MShare.LEVELS } ?: MShare.READ) } + + fun sharedPatch(visible: Boolean? = null, color: String? = null): JsonObject = JsonObject().apply { + visible?.let { addProperty("visible", it) } + color?.takeIf { COLOR.matches(it) }?.let { addProperty("color", it.uppercase()) } + } + + /** `POST resources//book`: a time (or days), the title, the zone. */ + fun bookBody(title: String?, start: Long?, end: Long?, startDate: String? = null, endDate: String? = null, timeZone: String?, note: String? = null): JsonObject = JsonObject().apply { + title?.trim()?.takeIf { it.isNotEmpty() }?.let { addProperty("title", it.take(255)) } + if (startDate != null) { + addProperty("allDay", true) + addProperty("startDate", startDate) + addProperty("endDate", endDate ?: startDate) + } else { + start?.let { addProperty("start", it) } + end?.let { addProperty("end", it) } + } + timeZone?.let { addProperty("timeZone", it) } + note?.trim()?.takeIf { it.isNotEmpty() }?.let { addProperty("note", it.take(500)) } + } + + fun decideBody(approve: Boolean, override: Boolean = false, comment: String? = null): JsonObject = JsonObject().apply { + addProperty("decision", if (approve) "approve" else "decline") + if (override) addProperty("override", true) + comment?.trim()?.takeIf { it.isNotEmpty() }?.let { addProperty("comment", it.take(1000)) } + } + // ─── live ─────────────────────────────────────────────────────── /** Realtime event `calendar` (or the plugin's data under `calendar`). */ fun calChanged(data: JsonElement?): MCalChanged? { val o = data?.takeIf { it.isJsonObject }?.asJsonObject ?: return null val root = o.obj("calendar") ?: o - val kind = root.str("kind")?.takeIf { it in setOf(MCalChanged.CHANGED, MCalChanged.CALENDARS, MCalChanged.INVITATION, MCalChanged.CONFLICT) } ?: return null - return MCalChanged(kind, root.id("calendarId"), root.id("accountId"), root.long("from"), root.long("to"), root.id("invitationId"), root.id("messageId"), root.id("eventId")) + val kind = root.str("kind")?.takeIf { it in setOf(MCalChanged.CHANGED, MCalChanged.CALENDARS, MCalChanged.INVITATION, MCalChanged.CONFLICT, MCalChanged.SHARES) } ?: return null + return MCalChanged( + kind, root.id("calendarId"), root.id("accountId"), root.long("from"), root.long("to"), root.id("invitationId"), root.id("messageId"), root.id("eventId"), + shared = root.bool("shared") ?: false, + ) + } + + /** Realtime event `resources` (Mail 1.9.0, or the plugin's data under `resources`). */ + fun resChanged(data: JsonElement?): MResChanged? { + val o = data?.takeIf { it.isJsonObject }?.asJsonObject ?: return null + val root = o.obj("resources") ?: o + val kind = root.str("kind")?.takeIf { it in setOf(MResChanged.REQUEST, MResChanged.BOOKING, MResChanged.RESOURCES) } ?: return null + return MResChanged(kind, root.id("bookingId"), root.id("resourceId"), root.str("state")) } /** Realtime event `contacts` (or the plugin's data under `contacts`). */ @@ -767,11 +1061,12 @@ object MailPimJson { name?.trim()?.takeIf { it.isNotEmpty() }?.let { addProperty("name", it) } } - fun calSettingsBody(view: String? = null, timeZone: String? = null, defaultCalendarId: Long? = null, workWeek: Boolean? = null): JsonObject = JsonObject().apply { + fun calSettingsBody(view: String? = null, timeZone: String? = null, defaultCalendarId: Long? = null, workWeek: Boolean? = null, freeBusy: Boolean? = null): JsonObject = JsonObject().apply { view?.takeIf { it in MCalPrefs.VIEWS }?.let { addProperty("view", it) } timeZone?.let { addProperty("timeZone", it) } defaultCalendarId?.let { addProperty("defaultCalendarId", it) } workWeek?.let { addProperty("workWeek", it) } + freeBusy?.let { addProperty("freeBusy", it) } } fun ruleBody(r: MRule): JsonObject = JsonObject().apply { diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailPimRepository.kt b/app/src/main/java/com/gatecontrol/android/mail/MailPimRepository.kt index ab23e7dc..c231987b 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailPimRepository.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailPimRepository.kt @@ -51,6 +51,14 @@ class MailPimRepository @Inject constructor( private val _calChanged = MutableSharedFlow(extraBufferCapacity = 32, onBufferOverflow = BufferOverflow.DROP_OLDEST) val calChanged: SharedFlow = _calChanged.asSharedFlow() + /** Live event `resources` (Mail 1.9.0): requests, the own bookings, the list of resources. */ + private val _resChanged = MutableSharedFlow(extraBufferCapacity = 32, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val resChanged: SharedFlow = _resChanged.asSharedFlow() + + /** The open booking requests the person decides on („Anfragen · n“, Mail 1.9.0); null: not asked (never kept offline). */ + private val _openRequests = MutableStateFlow(null) + val openRequests: StateFlow = _openRequests.asStateFlow() + private val _contactsChanged = MutableSharedFlow(extraBufferCapacity = 32, onBufferOverflow = BufferOverflow.DROP_OLDEST) val contactsChanged: SharedFlow = _contactsChanged.asSharedFlow() @@ -144,6 +152,11 @@ class MailPimRepository @Inject constructor( _calendars.update { cur -> cur?.copy(overlays = cur.overlays.map { if (it.id == o.id) o else it }) } } + /** A shared calendar changed here (Mail 1.9.0). */ + fun putShared(c: MSharedCalendar) { + _calendars.update { cur -> cur?.copy(shared = cur.shared.map { if (it.id == c.id) c else it }) } + } + fun putPrefs(p: MCalPrefs) { _calendars.update { cur -> cur?.copy(prefs = p) } } @@ -189,6 +202,23 @@ class MailPimRepository @Inject constructor( else -> r.notOk() } + /** `GET bookings?state=open` for the count of „Anfragen · n“ (only for people who decide on requests). */ + suspend fun refreshOpenRequests(): Int? { + api.bookings("open").valueOrNull()?.let { _openRequests.value = it.open } + return _openRequests.value + } + + private var requestsJob: Job? = null + + fun refreshOpenRequestsSoon() { + if (requestsJob?.isActive == true) return + requestsJob = scope.launch { refreshOpenRequests() } + } + + fun putOpenRequests(n: Int) { + _openRequests.value = n.coerceAtLeast(0) + } + // ─── contacts ─────────────────────────────────────────────────── suspend fun refreshBooks(): MResult { @@ -379,7 +409,18 @@ class MailPimRepository @Inject constructor( if (_upcoming.value != null || _startPins.value.agenda) refreshUpcomingSoon() c ?: return true _calChanged.tryEmit(c) - if (c.kind == MCalChanged.CALENDARS || c.kind == MCalChanged.CHANGED) refreshCalendarsSoon() + // Mail 1.9.0: a share changed (the shared calendars of `GET calendars`) + if (c.kind == MCalChanged.CALENDARS || c.kind == MCalChanged.CHANGED || c.kind == MCalChanged.SHARES) refreshCalendarsSoon() + return true + } + "resources" -> { + val r = MailPimJson.resChanged(data) ?: return true + _resChanged.tryEmit(r) + when (r.kind) { + MResChanged.REQUEST -> if (_openRequests.value != null) refreshOpenRequestsSoon() + // the person's own booking was answered: its event in the calendar changed + MResChanged.BOOKING -> _calChanged.tryEmit(MCalChanged(MCalChanged.CHANGED)) + } return true } "contacts" -> { @@ -421,5 +462,6 @@ class MailPimRepository @Inject constructor( _favoritesSupported.value = null _upcomingSupported.value = null _startPins.value = MailStartPins() + _openRequests.value = null } } diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailPlanLogic.kt b/app/src/main/java/com/gatecontrol/android/mail/MailPlanLogic.kt new file mode 100644 index 00000000..56140cf4 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/mail/MailPlanLogic.kt @@ -0,0 +1,206 @@ +package com.gatecontrol.android.mail + +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import kotlin.math.roundToInt + +/** + * The pure parts of free/busy, sharing and resources (Mail 1.9.0, unit-tested): the bars of a day, its window (the + * working hours, widened to the chosen time), who cannot at a time, the labels of rows and resources, the state of a + * booking and what may be done with it. The texts come from the screens. + */ +object MailPlanLogic { + + const val FREE = "free" + const val BUSY = "busy" + const val TENTATIVE = "tentative" + const val UNKNOWN = "unknown" + /** A request in a booking's bar (the time asked for). */ + const val ASK = "ask" + + /** The pills of a booking. */ + const val PILL_WAITING = "waiting" + const val PILL_CONFLICT = "conflict" + const val PILL_CONFIRMED = "confirmed" + const val PILL_DECLINED = "declined" + const val PILL_CANCELLED = "cancelled" + + private const val HOUR_MS = 3_600_000L + private val RESOURCE_ADDRESS = Regex("^resource-(\\d{1,15})@resources\\.gatecontrol\\.invalid$", RegexOption.IGNORE_CASE) + + /** A part of a bar: [status] over [start, end), [fraction] of the whole bar. */ + data class Segment(val status: String, val start: Long, val end: Long, val fraction: Float) + + /** + * The bar of [from, to): the blocks cut to it and sorted, the gaps between them [gap] (free, or unknown); where + * blocks overlap the earlier one keeps its part. + */ + fun bar(blocks: List, from: Long, to: Long, gap: String = FREE): List { + val len = to - from + if (len <= 0) return emptyList() + val list = blocks.map { MBusyBlock(maxOf(from, it.start), minOf(to, it.end), it.status) }.filter { it.end > it.start }.sortedBy { it.start } + val out = ArrayList() + fun push(status: String, a: Long, z: Long) { + if (z > a) out += Segment(status, a, z, (z - a).toFloat() / len) + } + var t = from + for (b in list) { + if (b.start > t) push(gap, t, b.start) + push(statusOf(b.status), maxOf(t, b.start), b.end) + t = maxOf(t, b.end) + } + push(gap, t, to) + return out + } + + /** `pending` (a resource's request) shows like tentative. */ + private fun statusOf(s: String): String = when (s) { + MBusyBlock.PENDING, MBusyBlock.TENTATIVE -> TENTATIVE + ASK -> ASK + else -> BUSY + } + + /** `HH:MM` → minutes of the day (`24:00` → 1440), null when not such a time. */ + fun minutes(hhmm: String?): Int? { + val m = Regex("^(\\d{1,2}):(\\d{2})$").matchEntire(hhmm?.trim() ?: return null) ?: return null + val h = m.groupValues[1].toInt() + val min = m.groupValues[2].toInt() + if (min > 59 || h > 24 || (h == 24 && min != 0)) return null + return h * 60 + min + } + + private fun at(day: LocalDate, minute: Int, zone: ZoneId): Long = day.atStartOfDay(zone).plusMinutes(minute.toLong()).toInstant().toEpochMilli() + + /** + * The window of a day's bars: the working hours ([hours], else 08:00–18:00), widened to the chosen time + * [start, end) when it begins that day (from its full hour, to its end, at most to midnight). + */ + fun dayWindow(day: LocalDate, hours: MWorkHours?, start: Long?, end: Long?, zone: ZoneId): Pair { + val a = minutes(hours?.from) ?: 8 * 60 + val z = (minutes(hours?.to) ?: 18 * 60).takeIf { it > a } ?: (a + 60).coerceAtMost(1440) + var from = at(day, a, zone) + var to = at(day, z, zone) + if (start != null && end != null && end > start && Instant.ofEpochMilli(start).atZone(zone).toLocalDate() == day) { + val s = Instant.ofEpochMilli(start).atZone(zone) + from = minOf(from, s.withMinute(0).withSecond(0).withNano(0).toInstant().toEpochMilli()) + to = maxOf(to, minOf(day.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli(), end)) + } + return from to to + } + + /** The scale below a bar: full hours (every 2nd, every 4th in a long window) with their place (0…1). */ + fun scale(from: Long, to: Long, zone: ZoneId): List> { + if (to <= from) return emptyList() + val first = Instant.ofEpochMilli(from).atZone(zone).let { z -> if (z.minute == 0 && z.second == 0) z else z.plusHours(1).withMinute(0).withSecond(0).withNano(0) } + val hours = ((to - from) / HOUR_MS).toInt() + val step = if (hours > 12) 4 else 2 + val out = ArrayList>() + var t = first + while (t.toInstant().toEpochMilli() <= to) { + val ms = t.toInstant().toEpochMilli() + out += t.hour to ((ms - from).toFloat() / (to - from)) + t = t.plusHours(step.toLong()) + } + return out + } + + /** Where [start, end) sits in the window [from, to): its left edge and width (0…1), null when outside. */ + fun frame(start: Long, end: Long, from: Long, to: Long): Pair? { + if (to <= from || end <= start || end <= from || start >= to) return null + val a = maxOf(from, start) + val z = minOf(to, end) + return (a - from).toFloat() / (to - from) to (z - a).toFloat() / (to - from) + } + + /** Somebody who cannot at a time: busy or only tentative. */ + data class Clash(val key: String, val name: String, val status: String, val self: Boolean) + + /** Who of the known rows is taken in [start, end) — the busy ones first. */ + fun clashes(rows: List, start: Long, end: Long): List = rows.filter { it.known }.mapNotNull { r -> + var st: String? = null + r.blocks.forEach { b -> if (b.start < end && b.end > start) st = if (b.status == MBusyBlock.BUSY || st == BUSY) BUSY else TENTATIVE } + st?.let { Clash(r.key, label(r), it, r.kind == MFreeBusyRow.SELF) } + }.sortedBy { if (it.status == BUSY) 0 else 1 } + + /** The rows whose free/busy is unknown (grey). */ + fun unknown(rows: List): List = rows.filter { !it.known } + + /** + * The short name of a row: a person's first name („Sabine K.“ → „Sabine“), a resource's name, else the address + * before its `@`. The person asking („Du“) is the screen's. + */ + fun label(r: MFreeBusyRow): String { + val name = r.name.trim() + return when { + r.kind == MFreeBusyRow.RESOURCE && name.isNotEmpty() -> name + (r.kind == MFreeBusyRow.PERSON || r.kind == MFreeBusyRow.MICROSOFT) && name.isNotEmpty() -> name.substringBefore(' ') + name.isNotEmpty() && !name.contains('@') -> name.substringBefore(' ') + else -> r.email?.substringBefore('@')?.takeIf { it.isNotEmpty() } ?: name + } + } + + /** Two letters for an avatar: of a name's first two words, else of the address. */ + fun initials(name: String): String { + val words = name.trim().substringBefore('@').split(Regex("[\\s._-]+")).filter { it.isNotEmpty() && it[0].isLetterOrDigit() } + return when { + words.size >= 2 -> "${words[0][0]}${words[1][0]}".uppercase() + words.size == 1 -> words[0].take(2).uppercase() + else -> "?" + } + } + + /** A resource as an attendee (`resource-@resources.gatecontrol.invalid` or the role). */ + fun isResource(email: String, role: String? = null): Boolean = role == MAttendee.ROLE_RESOURCE || RESOURCE_ADDRESS.matches(email.trim()) + + /** The resource's id of its address, null for anybody else. */ + fun resourceId(email: String): Long? = RESOURCE_ADDRESS.matchEntire(email.trim())?.groupValues?.get(1)?.toLongOrNull()?.takeIf { it > 0 } + + /** The people of the attendees (addresses, without resources) and the resources' ids. */ + fun split(attendees: List): Pair, List> { + val people = attendees.filter { !isResource(it.email, it.role) }.map { it.email.trim().lowercase() }.filter { it.contains('@') }.distinct() + val res = attendees.mapNotNull { resourceId(it.email) }.distinct() + return people to res + } + + /** „max. 1 Tag“ / „3 Tage“ / „2,5 Std.“: ("day", 1) | ("days", n) | ("hours", n). */ + fun maxLimit(minutes: Int): Pair = when { + minutes % 1440 == 0 && minutes == 1440 -> "day" to 1.0 + minutes % 1440 == 0 -> "days" to (minutes / 1440).toDouble() + else -> "hours" to ((minutes / 6.0).roundToInt() / 10.0) + } + + /** The pill of a booking: waiting | conflict (a rule broken or a booking in the way) | confirmed | declined | cancelled. */ + fun pill(b: MBooking): String = when (b.state) { + MBooking.PENDING -> if (b.violations.isNotEmpty() || b.conflicts.isNotEmpty()) PILL_CONFLICT else PILL_WAITING + MBooking.CONFIRMED -> PILL_CONFIRMED + MBooking.DECLINED -> PILL_DECLINED + else -> PILL_CANCELLED + } + + enum class Approve { NONE, APPROVE, OVERRIDE } + + /** What „Freigeben“ is: nothing with a booking in the way, „Trotzdem freigeben“ with broken rules. */ + fun approve(b: MBooking): Approve = when { + !b.canDecide || b.state != MBooking.PENDING -> Approve.NONE + b.conflicts.isNotEmpty() -> Approve.NONE + b.violations.isNotEmpty() -> Approve.OVERRIDE + else -> Approve.APPROVE + } + + /** May the booker call it off („Absagen“): waiting or confirmed and not over. */ + fun canCancel(b: MBooking, now: Long): Boolean = (b.state == MBooking.PENDING || b.state == MBooking.CONFIRMED) && b.end > now + + /** A pending booking's bar: the resource's day and the time asked for. */ + fun bookingBlocks(b: MBooking): List = b.day + MBusyBlock(b.start, b.end, ASK) + + /** The kinds of the chips: rooms, vehicles, devices always, „Sonstiges“ only when there is one. */ + fun kindChips(list: List): List = + listOf(MResource.ROOM, MResource.VEHICLE, MResource.DEVICE) + (if (list.any { it.kind == MResource.OTHER }) listOf(MResource.OTHER) else emptyList()) + + /** The length of an event in minutes for the suggestions (all-day: an hour, at least a quarter). */ + fun duration(start: Long, end: Long, allDay: Boolean): Int = if (allDay) 60 else ((end - start) / 60_000L).toInt().coerceIn(15, 1440) + + /** Whether [ms] is on [day] in [zone] (a suggestion that day shows „Do 15:00“, else with its date). */ + fun sameDay(ms: Long, day: LocalDate, zone: ZoneId): Boolean = Instant.ofEpochMilli(ms).atZone(zone).toLocalDate() == day +} diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailPlanModels.kt b/app/src/main/java/com/gatecontrol/android/mail/MailPlanModels.kt new file mode 100644 index 00000000..2524845d --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/mail/MailPlanModels.kt @@ -0,0 +1,227 @@ +package com.gatecontrol.android.mail + +/** + * Free/busy, shared calendars and resources of the Mail plugin (since Mail 1.9.0, phase 3: mockups Planer, Teilen, + * Ressource, Anfragen). Read by [MailPimJson] from the Gson tree, never by reflection. The person only serves themselves + * here: resources are kept on the plugin's page in GateControl, rights are checked by the server. + */ + +/** Working hours (`CalPrefs.workHours`, `Resource.rules.hours`): [days] 1 = Monday … 7 = Sunday, `HH:MM` (`to` up to `24:00`). */ +data class MWorkHours(val days: List, val from: String, val to: String) { + companion object { + val DEFAULT = MWorkHours(listOf(1, 2, 3, 4, 5), "08:00", "18:00") + } +} + +/** A taken time without a title: `busy` | `tentative` (free/busy) or `busy` | `pending` (a resource's day). */ +data class MBusyBlock(val start: Long, val end: Long, val status: String, val mine: Boolean = false) { + companion object { + const val BUSY = "busy" + const val TENTATIVE = "tentative" + const val PENDING = "pending" + } +} + +/** A row of `POST freebusy`: the person asking (`self`, always first), a person, a resource, someone else. */ +data class MFreeBusyRow( + val key: String, + /** self | person | microsoft | resource | external */ + val kind: String, + val email: String?, + val resourceId: Long?, + val userId: Long?, + val name: String, + /** `state: 'ok'`; false: unknown (the whole row grey). */ + val known: Boolean, + val blocks: List, +) { + companion object { + const val SELF = "self" + const val PERSON = "person" + const val MICROSOFT = "microsoft" + const val RESOURCE = "resource" + const val EXTERNAL = "external" + } +} + +data class MFreeBusy(val from: Long, val to: Long, val items: List) + +/** `POST freebusy/suggest`: slots where everybody known is free, in the asker's working hours. */ +data class MSuggestions(val items: List, val unknown: List, val workHours: MWorkHours?, val timeZone: String?) + +data class MSlot(val start: Long, val end: Long) + +/** A GateControl calendar somebody shares with the person („Mit mir geteilt“). */ +data class MSharedCalendar( + val id: Long, + val name: String, + val color: String, + val ownerId: Long?, + val ownerName: String, + /** freebusy | read | write */ + val level: String, + /** user | group */ + val via: String, + val group: String?, + val visible: Boolean, + /** Shown once (the owner reads „hat angenommen“); false: „neu“. */ + val accepted: Boolean, + val writable: Boolean, +) + +/** A calendar of an account that belongs to another mailbox (`GET shared` → `accounts`): shown like the account's own. */ +data class MSharedAccountCalendar( + val calendarId: Long, + val accountId: Long?, + val name: String, + val color: String, + val ownerEmail: String, + val writable: Boolean, + val visible: Boolean, +) + +/** `GET shared`. */ +data class MSharedList(val items: List, val accounts: List) + +/** A share of an own calendar („Geteilt mit“). */ +data class MShare( + val id: Long, + /** user | group */ + val type: String, + val principalId: Long, + val name: String, + /** freebusy | read | write */ + val level: String, + /** accepted | pending | group */ + val state: String, + val members: Int?, +) { + companion object { + const val USER = "user" + const val GROUP = "group" + const val FREEBUSY = "freebusy" + const val READ = "read" + const val WRITE = "write" + val LEVELS = listOf(FREEBUSY, READ, WRITE) + const val STATE_ACCEPTED = "accepted" + const val STATE_PENDING = "pending" + } +} + +/** `GET calendars//shares` (with the person's „Frei/Gebucht für alle“). */ +data class MShares(val items: List, val freeBusy: Boolean?) + +/** A person or a group to share with (`GET principals`; never addresses). */ +data class MPrincipal( + /** user | group */ + val type: String, + val id: Long, + val name: String, + val initials: String?, + val color: String?, + /** A group's path. */ + val sub: String?, + val members: Int?, +) + +/** A resource's rules: at most [maxMinutes] long, at most [horizonDays] ahead, only in [hours]. */ +data class MResourceRules(val maxMinutes: Int?, val horizonDays: Int?, val hours: MWorkHours?) + +/** A resource the person may book (`GET resources`, with [busy] for a range). */ +data class MResource( + val id: Long, + val name: String, + /** room | vehicle | device | other */ + val kind: String, + val location: String?, + val seats: Int?, + val equipment: String?, + val color: String, + /** auto | approval */ + val approval: String, + /** Who approves, as labels („Fuhrpark“). */ + val approvers: List, + val rules: MResourceRules, + val timeZone: String?, + /** The resource as an attendee (never shown). */ + val address: String, + val busy: List, +) { + val needsApproval: Boolean get() = approval == APPROVAL + + companion object { + const val ROOM = "room" + const val VEHICLE = "vehicle" + const val DEVICE = "device" + const val OTHER = "other" + val KINDS = listOf(ROOM, VEHICLE, DEVICE, OTHER) + const val AUTO = "auto" + const val APPROVAL = "approval" + } +} + +/** `GET resources`. */ +data class MResources(val items: List, val kinds: List) + +/** A rule a booking breaks: `max_duration` ([limit] minutes), `horizon` ([limit] days), `hours` ([days], [from]–[to]). */ +data class MViolation(val rule: String, val limit: Int?, val days: List, val from: String?, val to: String?) { + companion object { + const val MAX_DURATION = "max_duration" + const val HORIZON = "horizon" + const val HOURS = "hours" + } +} + +/** A booking of a resource (a request, the person's own one, a decided one). */ +data class MBooking( + val id: Long, + val resourceId: Long, + val resourceName: String, + val resourceKind: String, + val resourceColor: String?, + val resourceLocation: String?, + val title: String, + val start: Long, + val end: Long, + val allDay: Boolean, + val startDate: String?, + val endDate: String?, + /** pending | confirmed | declined | cancelled */ + val state: String, + val bookerName: String, + /** The own event (own bookings only). */ + val eventId: Long?, + val note: String?, + val violations: List, + /** Confirmed bookings in the way (pending only). */ + val conflicts: List, + val decidedBy: String?, + val decidedAt: Long?, + val override: Boolean, + val comment: String?, + val canDecide: Boolean, + /** The resource's day around it. */ + val day: List, +) { + companion object { + const val PENDING = "pending" + const val CONFIRMED = "confirmed" + const val DECLINED = "declined" + const val CANCELLED = "cancelled" + } +} + +/** `GET bookings?state=…`. */ +data class MBookings(val items: List, val open: Int) + +/** + * Realtime event `resources`: `request` (the person approves: the list and the count), `booking` (the person's own + * booking: the calendar), `resources` (the list of resources). + */ +data class MResChanged(val kind: String, val bookingId: Long? = null, val resourceId: Long? = null, val state: String? = null) { + companion object { + const val REQUEST = "request" + const val BOOKING = "booking" + const val RESOURCES = "resources" + } +} diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailPush.kt b/app/src/main/java/com/gatecontrol/android/mail/MailPush.kt index 72527c63..f0b600ae 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailPush.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailPush.kt @@ -16,6 +16,14 @@ object MailPush { /** Since Mail 1.2.0: a reminder (`data.calendar = { eventId, occurrence }`) and an invitation (`{ invitationId }`). */ const val TOPIC_REMINDER = TOPIC_PREFIX + "calendar_reminder" const val TOPIC_INVITE = TOPIC_PREFIX + "calendar_invite" + /** + * Since Mail 1.9.0: a calendar shared with the person (`data.calendar = { sharedCalendarId }`), a booking request + * to decide (`data.resources = { bookingId, resourceId }`), the answer to the person's own booking (also + * `data.calendar = { eventId }`). + */ + const val TOPIC_SHARE = TOPIC_PREFIX + "calendar_share" + const val TOPIC_RES_REQUEST = TOPIC_PREFIX + "resource_request" + const val TOPIC_RES_BOOKING = TOPIC_PREFIX + "resource_booking" fun isMailMessage(message: PushMessage): Boolean = message.topic.startsWith(TOPIC_PREFIX) @@ -23,14 +31,18 @@ object MailPush { fun isMailTopic(topic: String): Boolean = topic == TOPIC_MAIL /** Whether a channel (by its topic) is the calendar's: shown as "Kalender". */ - fun isCalendarTopic(topic: String): Boolean = topic == TOPIC_REMINDER || topic == TOPIC_INVITE + fun isCalendarTopic(topic: String): Boolean = topic == TOPIC_REMINDER || topic == TOPIC_INVITE || topic == TOPIC_SHARE || topic == TOPIC_RES_REQUEST || topic == TOPIC_RES_BOOKING - /** `message:` | `outbox:` | `outbox` | `event:[:]` | `invitation:`; null for other messages. */ + /** + * `message:` | `outbox:` | `outbox` | `event:[:]` | `invitation:` | `request:` + * | `booking:` | `shared[:]`; null for other messages. + */ fun destination(message: PushMessage): String? { if (!isMailMessage(message)) return null val data = message.dataJson?.let { raw -> runCatching { JsonParser.parseString(raw).asJsonObject }.getOrNull() } val mail = data?.get("mail")?.takeIf { it.isJsonObject }?.asJsonObject val calendar = data?.get("calendar")?.takeIf { it.isJsonObject }?.asJsonObject + val resources = data?.get("resources")?.takeIf { it.isJsonObject }?.asJsonObject return when (message.topic) { TOPIC_MAIL -> mail?.id("messageId")?.let { "message:$it" } TOPIC_FAILED -> mail?.id("outboxId")?.let { "outbox:$it" } ?: "outbox" @@ -39,6 +51,11 @@ object MailPush { "event:$id" + (occ?.let { ":$it" } ?: "") } TOPIC_INVITE -> calendar?.id("invitationId")?.let { "invitation:$it" } + // the requests (tab „Offen“) at its card + TOPIC_RES_REQUEST -> resources?.id("bookingId")?.let { "request:$it" } ?: "request" + // the answer: the own event, else the own bookings + TOPIC_RES_BOOKING -> calendar?.id("eventId")?.let { "event:$it" } ?: resources?.id("bookingId")?.let { "booking:$it" } ?: "booking" + TOPIC_SHARE -> "shared" + (calendar?.id("sharedCalendarId")?.let { ":$it" } ?: "") else -> null } } @@ -60,6 +77,18 @@ object MailPush { private fun JsonObject.id(key: String): Long? = get(key)?.takeIf { it.isJsonPrimitive }?.let { runCatching { it.asString.trim().toLong() }.getOrNull() }?.takeIf { it > 0 } + /** A destination of [destination] → the requests to open: (tab `open` | `mine`, the booking to show) or null. */ + fun bookingsOf(dest: String): Pair? = when { + dest == "request" -> "open" to null + dest.startsWith("request:") -> "open" to dest.removePrefix("request:").toLongOrNull()?.takeIf { it > 0 } + dest == "booking" -> "mine" to null + dest.startsWith("booking:") -> "mine" to dest.removePrefix("booking:").toLongOrNull()?.takeIf { it > 0 } + else -> null + } + + /** Whether a destination opens „Mit mir geteilt“. */ + fun isShared(dest: String): Boolean = dest == "shared" || dest.startsWith("shared:") + /** A destination of [destination] → the mail's id, or null. */ fun messageOf(dest: String): Long? = dest.takeIf { it.startsWith("message:") }?.removePrefix("message:")?.toLongOrNull()?.takeIf { it > 0 } diff --git a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt index 740077ae..cb875ea0 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt @@ -1325,6 +1325,16 @@ private fun openPushTarget( navigateToTab(Screen.Mail.route) navController.navigate(Screen.MailInvitation.create(com.gatecontrol.android.mail.MailPush.invitationOf(target.dest)!!)) { launchSingleTop = true } } + // Mail 1.9.0: a booking request (the requests at its card), the own bookings, „Mit mir geteilt“ + mail && com.gatecontrol.android.mail.MailPush.bookingsOf(target.dest) != null -> { + val (tab, focus) = com.gatecontrol.android.mail.MailPush.bookingsOf(target.dest)!! + navigateToTab(Screen.Mail.route) + navController.navigate(Screen.MailBookings.create(tab, focus)) { launchSingleTop = true } + } + mail && com.gatecontrol.android.mail.MailPush.isShared(target.dest) -> { + navigateToTab(Screen.Mail.route) + navController.navigate(Screen.MailShareCalendar.create()) { launchSingleTop = true } + } target.messageId != null -> navController.navigate(Screen.NotificationDetail.create(target.messageId)) else -> navigateToTab(Screen.Vpn.route) } diff --git a/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt b/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt index e7f8ffd2..d223ff94 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt @@ -20,11 +20,14 @@ import com.gatecontrol.android.ui.mail.MailSearchScreen import com.gatecontrol.android.ui.mail.MailSenderNav import com.gatecontrol.android.ui.mail.MailSenderScreen import com.gatecontrol.android.ui.mail.MailSettingsScreen +import com.gatecontrol.android.ui.mail.calendar.BookingRequestsScreen import com.gatecontrol.android.ui.mail.calendar.EventNav import com.gatecontrol.android.ui.mail.calendar.InviteNav import com.gatecontrol.android.ui.mail.calendar.MailEventEditScreen import com.gatecontrol.android.ui.mail.calendar.MailEventScreen import com.gatecontrol.android.ui.mail.calendar.MailInvitationScreen +import com.gatecontrol.android.ui.mail.calendar.ResourceBookingScreen +import com.gatecontrol.android.ui.mail.calendar.ShareCalendarScreen import com.gatecontrol.android.ui.mail.contacts.ContactNav import com.gatecontrol.android.ui.mail.contacts.MailContactEditScreen import com.gatecontrol.android.ui.mail.contacts.MailContactScreen @@ -56,6 +59,9 @@ private class MailNavigation(private val navController: NavHostController) { group = { go(Screen.MailGroup.create(it)) }, duplicates = { go(Screen.MailContactDuplicates.route) }, relogin = { go(Screen.MailAddAccount.create(it)) }, + resources = { start, end -> go(Screen.MailResources.create(start, end)) }, + bookings = { go(Screen.MailBookings.create()) }, + share = { go(Screen.MailShareCalendar.create(it)) }, ) } @@ -241,4 +247,14 @@ internal fun NavGraphBuilder.mailRoutes( composable(Screen.MailGroup.route, arguments = listOf(required(Screen.MailGroup.ARG_ID))) { GcContentWidth { MailGroupScreen(onBack = back, onContact = nav.nav.contact, mailTo = mailTo) } } + // ─── free/busy, sharing, resources (since Mail 1.9.0) ─── + composable(Screen.MailResources.route, arguments = listOf(optional(Screen.MailResources.ARG_START), optional(Screen.MailResources.ARG_END))) { + GcContentWidth { ResourceBookingScreen(onBack = back) } + } + composable(Screen.MailBookings.route, arguments = listOf(optional(Screen.MailBookings.ARG_TAB), optional(Screen.MailBookings.ARG_FOCUS))) { + GcContentWidth { BookingRequestsScreen(onBack = back, onEvent = { nav.nav.event(it, null) }) } + } + composable(Screen.MailShareCalendar.route, arguments = listOf(optional(Screen.MailShareCalendar.ARG_CAL))) { + GcContentWidth { ShareCalendarScreen(onBack = back) } + } } diff --git a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt index f7e1ac04..31cafd6e 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt @@ -251,6 +251,23 @@ sealed class Screen(val route: String) { const val ARG_ID = "id" fun create(id: Long) = "mail/group/$id" } + /** „Ressource buchen“ (Mail 1.9.0): the day and time of `start`/`end` (else the next hour). */ + data object MailResources : Screen("mail/resources?start={start}&end={end}") { + const val ARG_START = "start" + const val ARG_END = "end" + fun create(start: Long? = null, end: Long? = null): String = MailRoutes.build("mail/resources", "start" to start?.toString(), "end" to end?.toString()) + } + /** „Buchungsanfragen“ / „Meine Buchungen“ (Mail 1.9.0): the tab `open` | `done` | `mine`, the booking `focus` shown first. */ + data object MailBookings : Screen("mail/bookings?tab={tab}&focus={focus}") { + const val ARG_TAB = "tab" + const val ARG_FOCUS = "focus" + fun create(tab: String? = null, focus: Long? = null): String = MailRoutes.build("mail/bookings", "tab" to tab, "focus" to focus?.toString()) + } + /** „Kalender teilen“ of the own GateControl calendar `cal` (Mail 1.9.0); without it only „Mit mir geteilt“. */ + data object MailShareCalendar : Screen("mail/share?cal={cal}") { + const val ARG_CAL = "cal" + fun create(calendar: Long? = null): String = MailRoutes.build("mail/share", "cal" to calendar?.toString()) + } /** * Tab "Drive" (plugin gatecontrol-drive, only when the server lists it): * the areas and the newest files. A folder, the trash and the search are diff --git a/app/src/main/java/com/gatecontrol/android/push/PushNotifier.kt b/app/src/main/java/com/gatecontrol/android/push/PushNotifier.kt index a6f41570..418b06db 100644 --- a/app/src/main/java/com/gatecontrol/android/push/PushNotifier.kt +++ b/app/src/main/java/com/gatecontrol/android/push/PushNotifier.kt @@ -101,6 +101,10 @@ class PushNotifier @Inject constructor( // the calendar's reminders and invitations (Mail 1.2.0): "Kalender", whatever the server calls the topics ?: ctx.getString(R.string.mail_calendar_channel).takeIf { spec.id == PushChannels.topicChannelId(com.gatecontrol.android.mail.MailPush.TOPIC_REMINDER) } ?: ctx.getString(R.string.mail_invite_channel).takeIf { spec.id == PushChannels.topicChannelId(com.gatecontrol.android.mail.MailPush.TOPIC_INVITE) } + // Mail 1.9.0: shared calendars, booking requests and the answers to a booking (the calendar's channels) + ?: ctx.getString(R.string.mail_share_channel).takeIf { spec.id == PushChannels.topicChannelId(com.gatecontrol.android.mail.MailPush.TOPIC_SHARE) } + ?: ctx.getString(R.string.mail_requests_channel).takeIf { spec.id == PushChannels.topicChannelId(com.gatecontrol.android.mail.MailPush.TOPIC_RES_REQUEST) } + ?: ctx.getString(R.string.mail_bookings_channel).takeIf { spec.id == PushChannels.topicChannelId(com.gatecontrol.android.mail.MailPush.TOPIC_RES_BOOKING) } ?: spec.label ?: PushText.fallbackLabel(spec.id.removePrefix(PushChannels.topicChannelId(""))) diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt index b65af8f6..504fdd33 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt @@ -437,6 +437,14 @@ internal fun mailText(context: Context, code: String?): String = scanCodeText(co "exists" -> R.string.mail_err_exists "links_disabled" -> R.string.mail_err_links_disabled "busy" -> R.string.mail_err_busy + // Mail 1.9.0: free/busy, sharing, resources + "feature_off" -> R.string.mail_err_feature_off + "not_bookable" -> R.string.mail_err_not_bookable + "resource_busy" -> R.string.mail_err_resource_busy + "rule_violation" -> R.string.mail_err_rule_violation + "already_decided" -> R.string.mail_err_already_decided + "read_only" -> R.string.mail_err_read_only + "not_supported" -> R.string.mail_err_not_supported else -> R.string.mail_err_failed }, ) diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt index 1b7fb1b5..8dcc5d62 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt @@ -115,6 +115,10 @@ data class MailNav( val duplicates: () -> Unit = {}, /** "Neu anmelden" of a Microsoft account (calendar and contacts rights). */ val relogin: (Long) -> Unit = {}, + /** Mail 1.9.0: „Ressource buchen“, the booking requests / own bookings, „Kalender teilen“ of a calendar. */ + val resources: (start: Long?, end: Long?) -> Unit = { _, _ -> }, + val bookings: () -> Unit = {}, + val share: (calendarId: Long) -> Unit = {}, ) /** @@ -236,7 +240,7 @@ fun MailHomeScreen( val openMenu: () -> Unit = { scope.launch { drawer.open() } } when { area == MailDrawerFoot.CALENDAR && calendarVm != null -> MailCalendarContent( - CalendarNav(menu = openMenu, event = nav.event, newEvent = nav.newEvent, relogin = nav.relogin), + CalendarNav(menu = openMenu, event = nav.event, newEvent = nav.newEvent, relogin = nav.relogin, resources = nav.resources, bookings = nav.bookings), calendarVm, ) area == MailDrawerFoot.CONTACTS && contactsVm != null -> MailContactsContent( @@ -246,7 +250,12 @@ fun MailHomeScreen( else -> MailArea(nav, viewModel, me, meError, folders, box, tab, list, undo, accounts, account, inbox, folder, openMenu) { actionsFor = it } } } - calendarVm?.let { CalendarMenuDialogs(calendarMenu, it, it.calendars.collectAsStateWithLifecycle().value) } + calendarVm?.let { + CalendarMenuDialogs( + calendarMenu, it, it.calendars.collectAsStateWithLifecycle().value, + onShare = if (me?.has(com.gatecontrol.android.mail.MMe.FEATURE_SHARING) == true) { c -> nav.share(c.id) } else null, + ) + } actionsFor?.let { item -> RowActions( @@ -759,7 +768,7 @@ private fun MailDrawer( val label: @Composable (String) -> Unit = { DrawerLabel(it) } LazyColumn(Modifier.weight(1f)) { if (area == MailDrawerFoot.CALENDAR && calendarVm != null && calendarMenu != null) { - calendarDrawerItems(me, cals, calendarMenu, inboxRow, label, onToggle = calendarVm::toggleCalendar, onToggleOverlay = calendarVm::toggleOverlay) + calendarDrawerItems(me, cals, calendarMenu, inboxRow, label, onToggle = calendarVm::toggleCalendar, onToggleOverlay = calendarVm::toggleOverlay, onToggleShared = calendarVm::toggleShared) } else if (area == MailDrawerFoot.CONTACTS && contactsVm != null && contactsState != null) { contactsDrawerItems( me, books, contactsTotal, contactsState.duplicates, contactsState.filter, inboxRow, label, diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailMoreScreens.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailMoreScreens.kt index bd9ec37b..1be001c5 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailMoreScreens.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailMoreScreens.kt @@ -330,6 +330,19 @@ fun MailSettingsScreen( onCheckedChange = display::setDarkMails, ) } + // Mail 1.9.0: „Frei/Gebucht für alle in GateControl“ + val cals by viewModel.calendars.collectAsStateWithLifecycle() + if (m.has(com.gatecontrol.android.mail.MMe.FEATURE_FREE_BUSY) && cals != null) { + GcSectionLabel(stringResource(R.string.share_settings_label)) + GcCard(contentPadding = PaddingValues(vertical = 4.dp)) { + GcSwitchRow( + label = stringResource(R.string.share_fb_all), + description = stringResource(R.string.share_fb_all_d), + checked = cals?.prefs?.freeBusy ?: true, + onCheckedChange = viewModel::setFreeBusy, + ) + } + } GcSectionLabel(stringResource(R.string.mail_accounts)) GcCard(contentPadding = PaddingValues(vertical = 4.dp)) { m.accounts.forEach { a -> diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailMoreViewModels.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailMoreViewModels.kt index 81d265e2..a1fca12e 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailMoreViewModels.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailMoreViewModels.kt @@ -244,9 +244,28 @@ class MailSettingsViewModel @Inject constructor( val me: StateFlow = repository.me private val _toast = MutableStateFlow(null) val toast: StateFlow = _toast.asStateFlow() + /** The calendar settings (Mail 1.9.0: „Frei/Gebucht für alle in GateControl“). */ + val calendars: StateFlow = repository.pim.calendars init { repository.refreshSoon() + if (repository.me.value?.has(MMe.FEATURE_FREE_BUSY) == true) repository.pim.refreshCalendarsSoon() + } + + /** „Frei/Gebucht für alle in GateControl“ (the same switch as in „Kalender teilen“). */ + fun setFreeBusy(on: Boolean) { + val pim = repository.pim + val before = pim.calendars.value?.prefs ?: return + pim.putPrefs(before.copy(freeBusy = on)) + viewModelScope.launch { + when (val r = repository.api.calendarSettings(com.gatecontrol.android.mail.MailPimJson.calSettingsBody(freeBusy = on))) { + is MResult.Ok -> pim.putPrefs(r.value) + else -> { + pim.putPrefs(before) + _toast.value = mailText(context, MailHomeViewModel.code(r)) + } + } + } } fun put(threads: Boolean? = null, swipeLeft: String? = null, swipeRight: String? = null) { diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/BookingRequestsScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/BookingRequestsScreen.kt new file mode 100644 index 00000000..cb4147ed --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/BookingRequestsScreen.kt @@ -0,0 +1,394 @@ +package com.gatecontrol.android.ui.mail.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MBooking +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MailPimJson +import com.gatecontrol.android.mail.MailPimLogic +import com.gatecontrol.android.mail.MailPlanLogic +import com.gatecontrol.android.mail.MailRepository +import com.gatecontrol.android.navigation.Screen +import com.gatecontrol.android.ui.components.GcFilterChip +import com.gatecontrol.android.ui.components.GcOutlineButton +import com.gatecontrol.android.ui.components.GcPrimaryButton +import com.gatecontrol.android.ui.components.GcSubpageBar +import com.gatecontrol.android.ui.mail.ErrorBox +import com.gatecontrol.android.ui.mail.mailContext +import com.gatecontrol.android.ui.mail.toast +import com.gatecontrol.android.ui.theme.GateControlTheme +import com.gatecontrol.android.ui.update.currentLocale +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import javax.inject.Inject + +/** What „Buchungsanfragen“ shows. */ +data class BookingsState( + /** open | done | mine */ + val tab: String = TAB_MINE, + val items: List? = null, + val loading: Boolean = true, + val error: MResult<*>? = null, + /** Bookings being decided or called off. */ + val busy: Set = emptySet(), + /** A booking shown first (a push). */ + val focus: Long? = null, + /** What happened, for a toast: a text key with its name, or a refusal. */ + val note: Pair? = null, + val failure: MResult<*>? = null, +) { + companion object { + const val TAB_OPEN = "open" + const val TAB_DONE = "done" + const val TAB_MINE = "mine" + } +} + +/** + * „Buchungsanfragen“ (mockup Anfragen, Mail 1.9.0): the open requests the person decides on, the decided ones and the + * person's own bookings; Freigeben / Ablehnen / Trotzdem freigeben, Absagen. Never kept offline. + */ +@HiltViewModel +class BookingRequestsViewModel @Inject constructor( + private val repository: MailRepository, + saved: SavedStateHandle, +) : ViewModel() { + + private val api = repository.api + val me: StateFlow = repository.me + val open: StateFlow = repository.pim.openRequests + val zone: ZoneId = ZoneId.systemDefault() + + private val _state = MutableStateFlow(BookingsState()) + val state: StateFlow = _state.asStateFlow() + + private var loadJob: Job? = null + + /** The tabs: without deciding on requests only „Meine Buchungen“. */ + fun tabs(me: MMe? = repository.me.value): List = + if (me?.has(MMe.FEATURE_BOOKING_REQUESTS) == true) listOf(BookingsState.TAB_OPEN, BookingsState.TAB_DONE, BookingsState.TAB_MINE) else listOf(BookingsState.TAB_MINE) + + init { + val want = saved.get(Screen.MailBookings.ARG_TAB) + val tabs = tabs() + val tab = want?.takeIf { it in tabs } ?: tabs.first() + _state.update { it.copy(tab = tab, focus = saved.get(Screen.MailBookings.ARG_FOCUS)?.toLongOrNull()) } + load() + viewModelScope.launch { repository.pim.resChanged.collect { load(quiet = true) } } + } + + fun onShown() = load(quiet = true) + + fun setTab(tab: String) { + if (tab == _state.value.tab) return + _state.update { it.copy(tab = tab, items = null, focus = null) } + load() + } + + fun load(quiet: Boolean = false) { + loadJob?.cancel() + val tab = _state.value.tab + if (!quiet) _state.update { it.copy(loading = true, error = null) } + loadJob = viewModelScope.launch { + when (val r = api.bookings(tab)) { + is MResult.Ok -> { + if (tabs().contains(BookingsState.TAB_OPEN)) repository.pim.putOpenRequests(r.value.open) + _state.update { if (it.tab == tab) it.copy(items = r.value.items, loading = false, error = null) else it } + } + else -> _state.update { if (it.tab == tab) it.copy(loading = false, error = r) else it } + } + } + // the count of „Offen · n“ while another tab is shown + if (tab != BookingsState.TAB_OPEN && tabs().contains(BookingsState.TAB_OPEN)) repository.pim.refreshOpenRequestsSoon() + } + + fun decide(b: MBooking, approve: Boolean, override: Boolean = false) { + if (b.id in _state.value.busy) return + _state.update { it.copy(busy = it.busy + b.id) } + viewModelScope.launch { + val r = api.decideBooking(b.id, approve, override) + _state.update { it.copy(busy = it.busy - b.id) } + when { + r is MResult.Ok -> _state.update { it.copy(note = (if (approve) R.string.res_approved else R.string.res_declined) to b.resourceName) } + r is MResult.Failed && r.code == "already_decided" -> + _state.update { it.copy(note = R.string.res_already to (MailPimJson.decidedBy(r.error) ?: "")) } + else -> _state.update { it.copy(failure = r) } + } + load(quiet = true) + } + } + + fun cancel(b: MBooking) { + if (b.id in _state.value.busy) return + _state.update { it.copy(busy = it.busy + b.id) } + viewModelScope.launch { + val r = api.cancelBooking(b.id) + _state.update { it.copy(busy = it.busy - b.id) } + if (r is MResult.Ok) _state.update { it.copy(note = R.string.res_cancelled to b.resourceName) } else _state.update { it.copy(failure = r) } + load(quiet = true) + } + } + + fun clearNote() = _state.update { it.copy(note = null, failure = null) } +} + +/** „Buchungsanfragen“ (mockup Anfragen): chips Offen · n / Erledigt / Meine Buchungen, a card per booking. */ +@Composable +fun BookingRequestsScreen(onBack: () -> Unit, onEvent: (Long) -> Unit, viewModel: BookingRequestsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val me by viewModel.me.collectAsStateWithLifecycle() + val open by viewModel.open.collectAsStateWithLifecycle() + val context = mailContext() + val extra = GateControlTheme.extraColors + var cancelling by remember { mutableStateOf(null) } + LifecycleResumeEffect(viewModel) { + viewModel.onShown() + onPauseOrDispose { } + } + LaunchedEffect(state.note, state.failure) { + state.note?.let { (key, name) -> toast(context, if (key == R.string.res_already && name.isBlank()) context.getString(R.string.mail_err_already_decided) else context.getString(key, name)) } + state.failure?.let { toast(context, planErrorText(context, it)) } + if (state.note != null || state.failure != null) viewModel.clearNote() + } + val tabs = viewModel.tabs(me) + Column(Modifier.fillMaxSize()) { + GcSubpageBar( + title = stringResource(if (tabs.size == 1) R.string.res_mine else R.string.res_requests), + onBack = onBack, + backDescription = stringResource(R.string.mail_back), + ) + if (tabs.size > 1) { + Row( + Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + tabs.forEach { t -> + val label = when (t) { + BookingsState.TAB_OPEN -> stringResource(R.string.res_tab_open, open ?: state.items?.takeIf { state.tab == t }?.size ?: 0) + BookingsState.TAB_DONE -> stringResource(R.string.res_tab_done) + else -> stringResource(R.string.res_mine) + } + GcFilterChip(label, selected = state.tab == t, onClick = { viewModel.setTab(t) }) + } + } + } + val list = state.items + val listState = rememberLazyListState() + LaunchedEffect(list, state.focus) { + val f = state.focus ?: return@LaunchedEffect + val i = list?.indexOfFirst { it.id == f } ?: -1 + if (i >= 0) listState.animateScrollToItem(i) + } + LazyColumn( + Modifier.weight(1f).fillMaxWidth(), + state = listState, + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + when { + state.loading && list == null -> item(key = "loading") { + Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(28.dp)) } + } + state.error != null && list == null -> item(key = "error") { ErrorBox(planErrorText(context, state.error!!), onRetry = { viewModel.load() }) } + list != null -> { + if (list.isEmpty()) { + item(key = "empty") { + Text( + stringResource( + when (state.tab) { + BookingsState.TAB_OPEN -> R.string.res_empty_open + BookingsState.TAB_DONE -> R.string.res_empty_done + else -> R.string.res_empty_mine + }, + ), + color = extra.muted, + modifier = Modifier.padding(vertical = 12.dp), + ) + } + } + items(list, key = { "b${it.id}" }) { b -> + BookingCard( + b = b, + tab = state.tab, + zone = viewModel.zone, + busy = b.id in state.busy, + focus = b.id == state.focus, + onDecide = { approve, override -> viewModel.decide(b, approve, override) }, + onCancel = { cancelling = b }, + onEvent = b.eventId?.let { id -> { onEvent(id) } }, + ) + } + } + } + if (tabs.size > 1) { + item(key = "note") { + Text(stringResource(R.string.res_requests_note), style = MaterialTheme.typography.bodySmall, color = extra.muted, modifier = Modifier.padding(2.dp)) + } + } + } + } + cancelling?.let { b -> + AlertDialog( + onDismissRequest = { cancelling = null }, + title = { Text(stringResource(R.string.res_cancel_title, b.resourceName)) }, + text = { Text(stringResource(R.string.res_cancel_text)) }, + confirmButton = { TextButton(onClick = { cancelling = null; viewModel.cancel(b) }) { Text(stringResource(R.string.res_cancel), color = MaterialTheme.colorScheme.error) } }, + dismissButton = { TextButton(onClick = { cancelling = null }) { Text(stringResource(R.string.mail_cancel)) } }, + ) + } +} + +/** The time of a booking: „Do 5.11. · 12:00 – 15:00“, all-day „Fr 6.11. – Sa 7.11. · ganztägig“. */ +@Composable +internal fun bookingWhen(b: MBooking, zone: ZoneId): String { + val locale = currentLocale() + val fmt = DateTimeFormatter.ofPattern(if (locale.language == "de") "EEE d.M." else "EEE M/d", locale) + if (b.allDay) { + val a = MailPimLogic.date(b.startDate) ?: Instant.ofEpochMilli(b.start).atZone(zone).toLocalDate() + val z = MailPimLogic.date(b.endDate) ?: a + val days = if (a == z) a.format(fmt) else "${a.format(fmt)} – ${z.format(fmt)}" + return days + " · " + stringResource(R.string.cal_all_day) + } + val day = Instant.ofEpochMilli(b.start).atZone(zone).toLocalDate() + return day.format(fmt) + " · " + clock(b.start, zone, locale) + " – " + clock(b.end, zone, locale) +} + +/** A booking (mockup Anfragen): resource, who and what for, its pill, the time, the day's bar, what is wrong, the answers. */ +@Composable +private fun BookingCard( + b: MBooking, + tab: String, + zone: ZoneId, + busy: Boolean, + focus: Boolean, + onDecide: (approve: Boolean, override: Boolean) -> Unit, + onCancel: () -> Unit, + onEvent: (() -> Unit)?, +) { + val context = mailContext() + val extra = GateControlTheme.extraColors + val locale = currentLocale() + val pill = MailPlanLogic.pill(b) + val (pillText, pillColor) = when (pill) { + MailPlanLogic.PILL_WAITING -> stringResource(R.string.res_pill_waiting) to PlanColors.warn + MailPlanLogic.PILL_CONFLICT -> stringResource(R.string.res_pill_conflict) to PlanColors.bad + MailPlanLogic.PILL_CONFIRMED -> stringResource(R.string.res_pill_confirmed) to Color(0xFF46E0A0) + MailPlanLogic.PILL_DECLINED -> stringResource(R.string.res_pill_declined) to PlanColors.bad + else -> stringResource(R.string.res_pill_cancelled) to extra.muted + } + val pending = b.state == MBooking.PENDING + val border = when { + pending && pill == MailPlanLogic.PILL_CONFLICT -> PlanColors.bad.copy(alpha = 0.4f) + pending -> PlanColors.warn.copy(alpha = 0.4f) + focus -> PlanColors.chosen.copy(alpha = 0.45f) + else -> extra.border + } + val shape = RoundedCornerShape(18.dp) + Column( + Modifier.fillMaxWidth().clip(shape).background(MaterialTheme.colorScheme.surface).border(1.dp, border, shape).padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + KindIcon(b.resourceKind, b.resourceColor) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(b.resourceName, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + val sub = if (tab == BookingsState.TAB_MINE) b.title else listOf(b.bookerName, b.title).filter { it.isNotBlank() }.joinToString(" · ") + if (sub.isNotBlank()) Text(sub, style = MaterialTheme.typography.bodySmall, color = extra.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + PlanPill(pillText, pillColor) + } + Text(bookingWhen(b, zone), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + if (pending && !b.allDay) { + val day = Instant.ofEpochMilli(b.start).atZone(zone).toLocalDate() + val (from, to) = MailPlanLogic.dayWindow(day, null, b.start, b.end, zone) + val blocks = MailPlanLogic.bookingBlocks(b) + PlanBar(blocks, from, to, description = barDescription(context, blocks, from, to, zone), height = 18.dp) + } + val lines = buildList { + if (b.violations.isNotEmpty()) add(stringResource(R.string.res_breaks, b.violations.joinToString(", ") { ruleText(context, it) })) + if (b.conflicts.isNotEmpty()) add(stringResource(R.string.res_taken, b.conflicts.joinToString(", ") { "${clock(it.start, zone, locale)}–${clock(it.end, zone, locale)}" })) + if (pending && isEmpty()) add(stringResource(R.string.res_free_ok)) + if (!pending && b.decidedBy != null) { + val at = b.decidedAt?.let { Instant.ofEpochMilli(it).atZone(zone).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).withLocale(locale)) }.orEmpty() + add(stringResource(R.string.res_decided_by, b.decidedBy, at) + if (b.override) " · " + stringResource(R.string.res_override) else "") + } + b.comment?.let { add("„$it“") } + } + if (lines.isNotEmpty()) { + Text( + lines.joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = if (pending && pill == MailPlanLogic.PILL_CONFLICT) PlanColors.bad else extra.muted, + ) + } + if (tab == BookingsState.TAB_OPEN && b.canDecide && pending) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GcOutlineButton(stringResource(R.string.res_decline), onClick = { onDecide(false, false) }, enabled = !busy, modifier = Modifier.weight(1f)) + when (MailPlanLogic.approve(b)) { + MailPlanLogic.Approve.APPROVE -> GcPrimaryButton(stringResource(R.string.res_approve), onClick = { onDecide(true, false) }, loading = busy, modifier = Modifier.weight(1f)) + MailPlanLogic.Approve.OVERRIDE -> GcOutlineButton(stringResource(R.string.res_approve_anyway), onClick = { onDecide(true, true) }, enabled = !busy, modifier = Modifier.weight(1f)) + MailPlanLogic.Approve.NONE -> Unit + } + } + } + if (tab == BookingsState.TAB_MINE && (MailPlanLogic.canCancel(b, System.currentTimeMillis()) || onEvent != null)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (onEvent != null) GcOutlineButton(stringResource(R.string.res_open_event), onClick = onEvent, modifier = Modifier.weight(1f)) + if (MailPlanLogic.canCancel(b, System.currentTimeMillis())) { + GcOutlineButton(stringResource(R.string.res_cancel), onClick = onCancel, enabled = !busy, modifier = Modifier.weight(1f)) + } + } + } + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/CalendarDrawer.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/CalendarDrawer.kt index 8043c607..b6d5289e 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/CalendarDrawer.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/CalendarDrawer.kt @@ -45,6 +45,7 @@ import com.gatecontrol.android.mail.MCalendar import com.gatecontrol.android.mail.MCalendars import com.gatecontrol.android.mail.MMe import com.gatecontrol.android.mail.MPimSide +import com.gatecontrol.android.mail.MSharedCalendar import com.gatecontrol.android.ui.components.GcIcons import com.gatecontrol.android.ui.components.GcSwitch import com.gatecontrol.android.ui.components.GcTextField @@ -63,12 +64,24 @@ class CalendarMenuState { var deleting by mutableStateOf(null) var adding by mutableStateOf(false) var subscribing by mutableStateOf(false) + /** Mail 1.9.0: a calendar shared with the person (long press): owner, level, color, hide. */ + var shared by mutableStateOf(null) } /** A row of the menu with a check square in [color] (filled: shown), the name and its state or count. */ @OptIn(ExperimentalFoundationApi::class) @Composable -internal fun CheckRow(label: String, color: Color, checked: Boolean, trailing: String?, trailingWarn: Boolean = false, onClick: () -> Unit, onLongClick: (() -> Unit)? = null) { +internal fun CheckRow( + label: String, + color: Color, + checked: Boolean, + trailing: String?, + trailingWarn: Boolean = false, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, + /** A calendar shared with the person: a frame in its color instead of the surface, the check in the color. */ + outlined: Boolean = false, +) { val extra = GateControlTheme.extraColors val state = stringResource(if (checked) R.string.cal_shown else R.string.cal_hidden) Row( @@ -84,10 +97,10 @@ internal fun CheckRow(label: String, color: Color, checked: Boolean, trailing: S ) { Box( Modifier.size(18.dp).clip(RoundedCornerShape(5.dp)) - .then(if (checked) Modifier.background(color) else Modifier.border(2.dp, color, RoundedCornerShape(5.dp))), + .then(if (checked && !outlined) Modifier.background(color) else Modifier.border(2.dp, color, RoundedCornerShape(5.dp))), contentAlignment = Alignment.Center, ) { - if (checked) Icon(GcIcons.Check, contentDescription = null, tint = Color(0xFF0D1015), modifier = Modifier.size(13.dp)) + if (checked) Icon(GcIcons.Check, contentDescription = null, tint = if (outlined) color else Color(0xFF0D1015), modifier = Modifier.size(13.dp)) } Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) if (trailing != null) Text(trailing, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = if (trailingWarn) extra.warn else extra.muted) @@ -116,6 +129,7 @@ internal fun LazyListScope.calendarDrawerItems( label: @Composable (String) -> Unit, onToggle: (MCalendar) -> Unit, onToggleOverlay: (String) -> Unit, + onToggleShared: (MSharedCalendar) -> Unit = {}, ) { item(key = "cal-inbox") { inboxRow() } cals ?: return @@ -128,6 +142,23 @@ internal fun LazyListScope.calendarDrawerItems( item(key = "cal-a$accountId") { label(me?.account(accountId)?.label ?: stringResource(R.string.cal_group_account)) } list.forEach { c -> item(key = "cal${c.id}") { CalRow(c, menu, onToggle) } } } + // Mail 1.9.0: „Mit mir geteilt“ (framed in their color; „neu“ until shown once) + if (cals.shared.isNotEmpty()) { + item(key = "cal-sh") { label(stringResource(R.string.share_with_me)) } + cals.shared.forEach { c -> + item(key = "sh${c.id}") { + CheckRow( + c.name + if (c.ownerName.isNotBlank()) " · " + c.ownerName else "", + calColor(c.color), + c.visible, + stringResource(R.string.share_new).takeIf { !c.accepted }, + onClick = { onToggleShared(c) }, + onLongClick = { menu.shared = c }, + outlined = true, + ) + } + } + } if (cals.overlays.isNotEmpty()) { item(key = "cal-ov") { label(stringResource(R.string.cal_group_overlays)) } cals.overlays.forEach { o -> @@ -171,7 +202,13 @@ private fun Modifier.combinedClickableCompat(onClick: () -> Unit): Modifier = co /** The sheets and dialogs of the calendar menu. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CalendarMenuDialogs(menu: CalendarMenuState, viewModel: MailCalendarViewModel, cals: MCalendars?) { +fun CalendarMenuDialogs( + menu: CalendarMenuState, + viewModel: MailCalendarViewModel, + cals: MCalendars?, + /** Mail 1.9.0: „Teilen“ of an own GateControl calendar (null: sharing is off). */ + onShare: ((MCalendar) -> Unit)? = null, +) { val extra = GateControlTheme.extraColors menu.options?.let { c0 -> val c = cals?.calendar(c0.id) ?: c0 @@ -184,6 +221,7 @@ fun CalendarMenuDialogs(menu: CalendarMenuState, viewModel: MailCalendarViewMode Text(stringResource(R.string.cal_reminders_on), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f).padding(start = 16.dp)) GcSwitch(checked = c.notify, onCheckedChange = { viewModel.setNotify(c, it) }) } + if (c.local && onShare != null) SheetRow(GcIcons.Share, stringResource(R.string.share_action)) { menu.options = null; onShare(c) } if (c.local) SheetRow(GcIcons.Edit, stringResource(R.string.cal_rename)) { menu.options = null; menu.renaming = c } if (c.local || c.source == MCalendar.SOURCE_ICS) { SheetRow(GcIcons.Trash, stringResource(R.string.cal_delete), MaterialTheme.colorScheme.error) { menu.options = null; menu.deleting = c } @@ -207,6 +245,32 @@ fun CalendarMenuDialogs(menu: CalendarMenuState, viewModel: MailCalendarViewMode } } } + menu.shared?.let { c0 -> + val c = cals?.sharedCalendar(c0.id) + if (c == null) { + menu.shared = null + } else { + ModalBottomSheet(onDismissRequest = { menu.shared = null }, containerColor = MaterialTheme.colorScheme.surface) { + Column(Modifier.padding(bottom = 24.dp)) { + Text(c.name, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp).semantics { heading() }) + Text( + stringResource(R.string.share_info, c.ownerName.ifBlank { "?" }, levelText(c.level)) + (c.group?.let { " · " + stringResource(R.string.share_via, it) } ?: ""), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), + ) + if (c.level == com.gatecontrol.android.mail.MShare.FREEBUSY) { + Text(stringResource(R.string.share_busy_only), style = MaterialTheme.typography.bodySmall, color = extra.muted, modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp)) + } + ColorRow(c.color) { viewModel.setSharedColor(c, it) } + SheetRow(if (c.visible) GcIcons.EyeOff else GcIcons.Eye, stringResource(if (c.visible) R.string.share_hide else R.string.share_show)) { + menu.shared = null + viewModel.toggleShared(c) + } + } + } + } + } menu.renaming?.let { c -> NameDialog(stringResource(R.string.cal_rename), c.name, onDone = { viewModel.rename(c, it) }, onDismiss = { menu.renaming = null }) } diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailCalendarScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailCalendarScreen.kt index d7da66ec..f1d6b7fb 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailCalendarScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailCalendarScreen.kt @@ -82,6 +82,7 @@ import com.gatecontrol.android.mail.MailPimLogic import com.gatecontrol.android.ui.components.GcBanner import com.gatecontrol.android.ui.components.GcIconButton import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcOutlineButton import com.gatecontrol.android.ui.components.GcPrimaryButton import com.gatecontrol.android.ui.components.GcTone import com.gatecontrol.android.ui.mail.mailContext @@ -106,6 +107,9 @@ data class CalendarNav( val newEvent: (start: Long?, end: Long?, allDay: Boolean) -> Unit, /** "Neu anmelden" of a Microsoft account (calendar and contacts rights). */ val relogin: (accountId: Long) -> Unit, + /** Mail 1.9.0: „Ressource buchen“ (the tapped time, else the next hour), the requests / own bookings. */ + val resources: (start: Long?, end: Long?) -> Unit = { _, _ -> }, + val bookings: () -> Unit = {}, ) /** The grid's height of an hour. */ @@ -141,7 +145,24 @@ fun MailCalendarContent(nav: CalendarNav, viewModel: MailCalendarViewModel) { } } + val openRequests by viewModel.openRequests.collectAsStateWithLifecycle() val slots = remember(state.events, cals) { slotsOf(state.events, cals) } + // a free/busy entry of a shared calendar opens nothing: its time, the calendar and the level + val busyInfo: (MailPimLogic.Slot) -> Unit = { s -> + val o = s.occurrence + if (o != null && o.busyOnly) { + val calName = cals?.sharedCalendar(o.calendarId)?.name + val parts = listOfNotNull( + o.title.ifBlank { null }, + busyWhen(context, o.start, o.end, o.allDay, zone, locale), + calName, + context.getString(if (o.shared == com.gatecontrol.android.mail.MShare.FREEBUSY) R.string.share_level_freebusy else R.string.cal_private), + ) + toast(context, parts.joinToString(" · ")) + } else if (o != null) { + nav.event(o.eventId, o.occurrence) + } + } Column(Modifier.fillMaxSize()) { CalendarBar( title = monthTitle(if (view == MCalPrefs.VIEW_MONTH) state.anchor else viewModel.shownDays(state).firstOrNull() ?: state.anchor, today, locale), @@ -151,7 +172,19 @@ fun MailCalendarContent(nav: CalendarNav, viewModel: MailCalendarViewModel) { onStep = viewModel::step, onView = viewModel::setView, ) - CalendarChips(cals, onCalendar = { id -> cals?.calendar(id)?.let(viewModel::toggleCalendar) }, onOverlay = viewModel::toggleOverlay) + CalendarChips( + cals, + onCalendar = { id -> cals?.calendar(id)?.let(viewModel::toggleCalendar) }, + onOverlay = viewModel::toggleOverlay, + onShared = { id -> cals?.sharedCalendar(id)?.let(viewModel::toggleShared) }, + requests = when { + me?.has(MMe.FEATURE_BOOKING_REQUESTS) == true -> stringResource(R.string.res_requests_n, openRequests ?: 0) + me?.has(MMe.FEATURE_RESOURCES) == true -> stringResource(R.string.res_mine) + else -> null + }, + requestsHot = me?.has(MMe.FEATURE_BOOKING_REQUESTS) == true && (openRequests ?: 0) > 0, + onRequests = nav.bookings, + ) CalendarBands(me, cals, offline, relogins, onRelogin = { id -> viewModel.relogin(id) nav.relogin(id) @@ -160,14 +193,14 @@ fun MailCalendarContent(nav: CalendarNav, viewModel: MailCalendarViewModel) { val swipe = if (LocalInStartPager.current || view == MCalPrefs.VIEW_LIST) Modifier else Modifier.swipeToPage(viewModel::step) when (view) { MCalPrefs.VIEW_MONTH -> MonthView(state.anchor, today, cals?.prefs?.weekStart ?: 1, slots, zone, swipe, onDay = viewModel::openDay) - MCalPrefs.VIEW_LIST -> AgendaView(state.anchor, state.listDays, today, slots, zone, onSlot = { s -> s.occurrence?.let { nav.event(it.eventId, it.occurrence) } }, onEnd = viewModel::moreList) + MCalPrefs.VIEW_LIST -> AgendaView(state.anchor, state.listDays, today, slots, zone, onSlot = busyInfo, onEnd = viewModel::moreList) else -> TimeGrid( days = viewModel.shownDays(state), today = today, slots = slots, zone = zone, modifier = swipe, - onSlot = { s -> s.occurrence?.let { nav.event(it.eventId, it.occurrence) } }, + onSlot = busyInfo, onEmpty = { day, minute -> if (!offline) { val (a, b) = MailPimLogic.newAt(day, minute, zone) @@ -182,16 +215,26 @@ fun MailCalendarContent(nav: CalendarNav, viewModel: MailCalendarViewModel) { Text(mailText(context, it), color = GateControlTheme.extraColors.muted, modifier = Modifier.align(Alignment.Center).padding(24.dp)) } } - // "Neuer Termin" (mockup: the primary button in the bar below; "Ressource buchen" comes with phase 3) + // "Neuer Termin" (mockup: the primary button in the bar below) and, with resources (Mail 1.9.0), "Ressource buchen" Row( Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface).padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { GcPrimaryButton( stringResource(R.string.cal_new_event), onClick = { nav.newEvent(null, null, false) }, enabled = !offline && cals?.calendars?.any { it.writable } != false, icon = GcIcons.Plus, + modifier = Modifier.weight(1f), ) + if (me?.has(MMe.FEATURE_RESOURCES) == true) { + GcOutlineButton( + stringResource(R.string.res_book), + onClick = { nav.resources(null, null) }, + enabled = !offline, + fillWidth = false, + ) + } } } } @@ -199,7 +242,8 @@ fun MailCalendarContent(nav: CalendarNav, viewModel: MailCalendarViewModel) { /** The occurrences and overlay entries as the grid's slots, in their calendar's color. */ internal fun slotsOf(events: MEvents?, cals: MCalendars?): List { events ?: return emptyList() - val colors = cals?.calendars?.associate { it.id to it.color }.orEmpty() + // the calendars shared with the person keep their own color (Mail 1.9.0) + val colors = cals?.shared?.associate { it.id to it.color }.orEmpty() + cals?.calendars?.associate { it.id to it.color }.orEmpty() val overlayColors = cals?.overlays?.associate { it.id to it.color }.orEmpty() return events.items.map { o -> MailPimLogic.Slot(o.key, o.title, o.start, o.end, o.allDay, o.startDate, o.endDate, colors[o.calendarId] ?: "#46E0A0", @@ -262,7 +306,16 @@ private fun CalendarBar(title: String, view: String, onMenu: () -> Unit, onToday /** The chips of the calendars and overlays (mockup): the color square, filled when shown; a tap shows or hides. */ @Composable -private fun CalendarChips(cals: MCalendars?, onCalendar: (Long) -> Unit, onOverlay: (String) -> Unit) { +private fun CalendarChips( + cals: MCalendars?, + onCalendar: (Long) -> Unit, + onOverlay: (String) -> Unit, + onShared: (Long) -> Unit = {}, + /** Mail 1.9.0: „Anfragen · n“ / „Meine Buchungen“ first (null: no resources). */ + requests: String? = null, + requestsHot: Boolean = false, + onRequests: () -> Unit = {}, +) { cals ?: return val extra = GateControlTheme.extraColors val shown = stringResource(R.string.cal_shown) @@ -271,10 +324,27 @@ private fun CalendarChips(cals: MCalendars?, onCalendar: (Long) -> Unit, onOverl Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(start = 12.dp, end = 12.dp, top = 10.dp, bottom = 6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - val chips = cals.calendars.map { Triple(it.name, it.color, it.visible) to { onCalendar(it.id) } } + - cals.overlays.map { Triple(it.name, it.color, it.visible) to { onOverlay(it.id) } } + if (requests != null) { + Row( + Modifier + .heightIn(min = 36.dp) + .clip(RoundedCornerShape(999.dp)) + .then(if (requestsHot) Modifier.background(extra.warnBg) else Modifier.border(1.dp, extra.border2, RoundedCornerShape(999.dp))) + .clickable(role = Role.Button, onClick = onRequests) + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(GcIcons.Tasks, contentDescription = null, tint = if (requestsHot) extra.warn else extra.muted, modifier = Modifier.size(14.dp)) + Text(requests, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = if (requestsHot) extra.warn else MaterialTheme.colorScheme.onBackground, maxLines = 1) + } + } + // shared calendars: a frame in their color instead of the surface (mockup) + val chips = cals.calendars.map { ChipSpec(it.name, it.color, it.visible) to { onCalendar(it.id) } } + + cals.shared.map { ChipSpec(it.name, it.color, it.visible, outlined = true) to { onShared(it.id) } } + + cals.overlays.map { ChipSpec(it.name, it.color, it.visible) to { onOverlay(it.id) } } chips.forEach { (c, click) -> - val (name, color, on) = c + val (name, color, on, outlined) = c val col = calColor(color) Row( Modifier @@ -289,7 +359,7 @@ private fun CalendarChips(cals: MCalendars?, onCalendar: (Long) -> Unit, onOverl ) { Box( Modifier.size(12.dp).clip(RoundedCornerShape(4.dp)) - .then(if (on) Modifier.background(col) else Modifier.border(2.dp, col, RoundedCornerShape(4.dp))), + .then(if (on && !outlined) Modifier.background(col) else Modifier.border(2.dp, col, RoundedCornerShape(4.dp))), ) Text(name, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = if (on) MaterialTheme.colorScheme.onBackground else extra.faint, maxLines = 1) } @@ -297,6 +367,13 @@ private fun CalendarChips(cals: MCalendars?, onCalendar: (Long) -> Unit, onOverl } } +/** A chip of a calendar: shown or not, a shared one framed. */ +private data class ChipSpec(val name: String, val color: String, val on: Boolean, val outlined: Boolean = false) + +/** „10:00–11:00“ or „ganztägig“ of a free/busy entry. */ +private fun busyWhen(context: android.content.Context, start: Long, end: Long, allDay: Boolean, zone: ZoneId, locale: java.util.Locale): String = + if (allDay) context.getString(R.string.cal_all_day) else "${clock(start, zone, locale)}–${clock(end, zone, locale)}" + /** * The bands above the view: a Microsoft account without the calendar rights ("Neu anmelden"; after that still * missing: the company has to allow them, with its link), GMX / Web.de without the host's CalDAV, offline. diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailCalendarViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailCalendarViewModel.kt index 8cc0addd..1a5fa964 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailCalendarViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailCalendarViewModel.kt @@ -8,6 +8,7 @@ import com.gatecontrol.android.mail.MCalendars import com.gatecontrol.android.mail.MEvents import com.gatecontrol.android.mail.MMe import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MSharedCalendar import com.gatecontrol.android.mail.MailPimJson import com.gatecontrol.android.mail.MailPimLogic import com.gatecontrol.android.mail.MailRepository @@ -53,6 +54,8 @@ class MailCalendarViewModel @Inject constructor( val calendars: StateFlow = pim.calendars val offline: StateFlow = pim.offline val relogins: StateFlow> = pim.relogins + /** „Anfragen · n“ (Mail 1.9.0, only for people who decide on requests). */ + val openRequests: StateFlow = pim.openRequests /** The device's zone: the grid's days and hours. */ val zone: ZoneId = ZoneId.systemDefault() @@ -79,6 +82,12 @@ class MailCalendarViewModel @Inject constructor( load(quiet = true) } } + refreshRequests() + } + + /** The count of open booking requests (never kept offline). */ + private fun refreshRequests() { + if (repository.me.value?.has(MMe.FEATURE_BOOKING_REQUESTS) == true) pim.refreshOpenRequestsSoon() } /** The person's zone on the server follows the device's (reminders of all-day events, the overlays' days). */ @@ -91,6 +100,7 @@ class MailCalendarViewModel @Inject constructor( fun onShown() { pim.refreshCalendarsSoon() load(quiet = true) + refreshRequests() } /** The days the view shows (the list: from its first day on). */ @@ -170,6 +180,31 @@ class MailCalendarViewModel @Inject constructor( } } + /** A calendar shared with the person shown or hidden (Mail 1.9.0; the first time: „hat angenommen“). */ + fun toggleShared(c: MSharedCalendar) { + pim.putShared(c.copy(visible = !c.visible)) + viewModelScope.launch { + when (val r = api.patchShared(c.id, visible = !c.visible)) { + is MResult.Ok -> pim.putShared(r.value) + else -> { + pim.putShared(c) + fail(r) + } + } + load(quiet = true) + } + } + + fun setSharedColor(c: MSharedCalendar, color: String) { + viewModelScope.launch { + when (val r = api.patchShared(c.id, color = color)) { + is MResult.Ok -> pim.putShared(r.value) + else -> fail(r) + } + load(quiet = true) + } + } + fun toggleOverlay(id: String) { val o = pim.calendars.value?.overlays?.firstOrNull { it.id == id } ?: return pim.putOverlay(o.copy(visible = !o.visible)) diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventEditScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventEditScreen.kt index 2d75fa4d..37354f78 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventEditScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventEditScreen.kt @@ -56,9 +56,12 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MBooking import com.gatecontrol.android.mail.MCalendar +import com.gatecontrol.android.mail.MResource import com.gatecontrol.android.mail.MRule import com.gatecontrol.android.mail.MailPimLogic +import com.gatecontrol.android.mail.MailPlanLogic import com.gatecontrol.android.ui.components.GcBanner import com.gatecontrol.android.ui.components.GcCard import com.gatecontrol.android.ui.components.GcIcons @@ -97,12 +100,21 @@ fun MailEventEditScreen(onClose: () -> Unit, viewModel: MailEventEditViewModel = val extra = GateControlTheme.extraColors val locale = currentLocale() val f = state.form - LaunchedEffect(state.done) { if (state.done) onClose() } - LaunchedEffect(state.message) { + LaunchedEffect(state.done) { + if (state.done) { + // Mail 1.9.0: what became of the resources + state.booked.forEach { b -> + toast(context, context.getString(if (b.state == MBooking.PENDING) R.string.res_requested else R.string.res_booked, b.resourceName)) + } + onClose() + } + } + LaunchedEffect(state.message, state.failure) { state.message?.let { toast(context, if (it == MailEventEditViewModel.SERIES_SHIFT) context.getString(R.string.cal_series_shift) else mailText(context, it)) - viewModel.clearMessage() } + state.failure?.let { toast(context, planErrorText(context, it)) } + if (state.message != null || state.failure != null) viewModel.clearMessage() } // a new event without a calendar yet (the list came later): the default one LaunchedEffect(cals) { @@ -147,43 +159,78 @@ fun MailEventEditScreen(onClose: () -> Unit, viewModel: MailEventEditViewModel = OnlineBlock(cal, f.teams, f.onlineUrl, onTeams = { v -> viewModel.editOnline { it.copy(teams = v) } }, onUrl = { v -> viewModel.editOnline { it.copy(onlineUrl = v.take(2000)) } }) RemindersBlock(f.reminders, f.allDay, max = cal?.maxReminders ?: 3) { list -> viewModel.edit { it.copy(reminders = list) } } GcSectionLabel(stringResource(R.string.cal_attendees)) - if (f.attendees.isNotEmpty()) { - FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - f.attendees.forEach { a -> - Row( - Modifier.clip(RoundedCornerShape(999.dp)).background(extra.panel2) - .clickable(role = Role.Button, onClickLabel = stringResource(R.string.mail_remove)) { viewModel.removeAttendee(a) } - .padding(start = 10.dp, end = 6.dp, top = 6.dp, bottom = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text(a.name ?: a.email, style = MaterialTheme.typography.bodyMedium, maxLines = 1) - Icon(GcIcons.Close, contentDescription = null, tint = extra.muted, modifier = Modifier.size(14.dp)) + // an event of a calendar shared with the person: attendees stay the owner's (Mail 1.9.0) + if (state.existing?.occ?.shared != null) { + Text(stringResource(R.string.share_no_attendees), style = MaterialTheme.typography.bodySmall, color = extra.muted) + } else { + if (f.attendees.isNotEmpty()) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + f.attendees.forEach { a -> + // a resource (Mail 1.9.0): lilac, its name only (the address is never shown) + val resource = MailPlanLogic.isResource(a.email, a.role) + Row( + Modifier.clip(RoundedCornerShape(999.dp)).background(extra.panel2) + .clickable(role = Role.Button, onClickLabel = stringResource(R.string.mail_remove)) { viewModel.removeAttendee(a) } + .padding(start = 10.dp, end = 6.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (resource) Icon(GcIcons.Briefcase, contentDescription = stringResource(R.string.cal_resource), tint = PlanColors.resource, modifier = Modifier.size(14.dp)) + Text( + if (resource) a.name ?: stringResource(R.string.cal_resource) else a.name ?: a.email, + style = MaterialTheme.typography.bodyMedium, + color = if (resource) PlanColors.resource else MaterialTheme.colorScheme.onSurface, + maxLines = 1, + ) + Icon(GcIcons.Close, contentDescription = null, tint = extra.muted, modifier = Modifier.size(14.dp)) + } } } } - } - GcTextField( - state.attendeeInput, - viewModel::setAttendeeInput, - stringResource(R.string.cal_add_attendee), - placeholder = stringResource(R.string.cal_add_attendee_hint), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { viewModel.commitAttendees() }), - ) - if (state.suggestions.isNotEmpty()) { - Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.surface)) { - state.suggestions.take(6).forEach { c -> SuggestionRow(c) { viewModel.pickAttendee(c) } } + GcTextField( + state.attendeeInput, + viewModel::setAttendeeInput, + stringResource(R.string.cal_add_attendee), + placeholder = stringResource(R.string.cal_add_attendee_hint), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { viewModel.commitAttendees() }), + ) + if (state.suggestions.isNotEmpty()) { + Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.surface)) { + state.suggestions.take(6).forEach { c -> SuggestionRow(c) { viewModel.pickAttendee(c) } } + } } - } - if (f.attendees.isNotEmpty()) { - val sender = viewModel.sender(me, cal) - val text = when { - cal?.source == MCalendar.SOURCE_GRAPH -> stringResource(R.string.cal_invites_provider) - sender != null -> stringResource(R.string.cal_invites_via, sender.label) - else -> null + // Mail 1.9.0: „Ressource hinzufügen …“ (GateControl calendars, no series) + val resources = state.resources.orEmpty().filter { r -> f.attendees.none { it.email.equals(r.address, ignoreCase = true) } } + if (resources.isNotEmpty() && cal?.local == true && f.ruleKind == MailPimLogic.RULE_NONE && state.existing?.occ?.shared == null) { + ResourcePicker(resources) { viewModel.addResource(it) } + } + val people = f.attendees.filter { !MailPlanLogic.isResource(it.email, it.role) } + if (people.isEmpty() && f.attendees.isNotEmpty()) { + Text(stringResource(R.string.res_hint), style = MaterialTheme.typography.bodySmall, color = extra.muted) + } + if (people.isNotEmpty()) { + val sender = viewModel.sender(me, cal) + val text = when { + cal?.source == MCalendar.SOURCE_GRAPH -> stringResource(R.string.cal_invites_provider) + sender != null -> stringResource(R.string.cal_invites_via, sender.label) + else -> null + } + if (text != null) GcBanner(tone = GcTone.Info, icon = GcIcons.Mail) { Text(text, style = MaterialTheme.typography.bodySmall) } } - if (text != null) GcBanner(tone = GcTone.Info, icon = GcIcons.Mail) { Text(text, style = MaterialTheme.typography.bodySmall) } + } + // Mail 1.9.0: „Frei/Gebucht“ and the suggestions + state.plan?.let { plan -> + val zone = MailPimLogic.zone(f.zone) ?: viewModel.deviceZone + PlannerSection( + plan = plan, + allDay = f.allDay, + start = MailPimLogic.ms(f.startDay, f.startTime, zone), + end = MailPimLogic.ms(f.endDay, f.endTime, zone), + hours = cals?.prefs?.workHours, + zone = zone, + onPick = viewModel::pickSlot, + ) } GcCard(contentPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp), verticalArrangement = Arrangement.Top) { GcSwitchRow(label = stringResource(R.string.cal_show_free), description = null, checked = f.free, onCheckedChange = { v -> viewModel.edit { it.copy(free = v) } }) @@ -220,6 +267,34 @@ fun MailEventEditScreen(onClose: () -> Unit, viewModel: MailEventEditViewModel = } } +/** „Ressource hinzufügen …“: the resources the person may book, „mit Freigabe“ where somebody approves. */ +@Composable +private fun ResourcePicker(list: List, onPick: (MResource) -> Unit) { + var open by remember { mutableStateOf(false) } + val extra = GateControlTheme.extraColors + Box { + Row( + Modifier.fillMaxWidth().heightIn(min = 48.dp).clip(RoundedCornerShape(14.dp)).border(1.dp, extra.border2, RoundedCornerShape(14.dp)) + .clickable(role = Role.DropdownList) { open = true }.padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(GcIcons.Plus, contentDescription = null, tint = PlanColors.resource, modifier = Modifier.size(18.dp)) + Text(stringResource(R.string.res_add), style = MaterialTheme.typography.bodyMedium, color = PlanColors.resource, modifier = Modifier.weight(1f)) + Icon(GcIcons.ChevronDown, contentDescription = null, tint = extra.muted, modifier = Modifier.size(18.dp)) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + list.forEach { r -> + DropdownMenuItem( + text = { Text(r.name + if (r.needsApproval) " · " + stringResource(R.string.res_needs_ok) else "") }, + leadingIcon = { Icon(kindIcon(r.kind), contentDescription = null, tint = calColor(r.color)) }, + onClick = { open = false; onPick(r) }, + ) + } + } + } +} + @Composable private fun CalendarPicker(list: List, current: MCalendar?, onPick: (MCalendar) -> Unit) { var open by remember { mutableStateOf(false) } diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventEditViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventEditViewModel.kt index edf45573..d1caac5f 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventEditViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/MailEventEditViewModel.kt @@ -4,18 +4,24 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.gatecontrol.android.mail.MAccount +import com.gatecontrol.android.mail.MAttendee import com.gatecontrol.android.mail.MAttendeeInput +import com.gatecontrol.android.mail.MBooking import com.gatecontrol.android.mail.MCalendar import com.gatecontrol.android.mail.MCalendars import com.gatecontrol.android.mail.MContact import com.gatecontrol.android.mail.MEvent import com.gatecontrol.android.mail.MEventInput import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MResource import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MSlot +import com.gatecontrol.android.mail.MSuggestions import com.gatecontrol.android.mail.MRule import com.gatecontrol.android.mail.MailLogic import com.gatecontrol.android.mail.MailPimJson import com.gatecontrol.android.mail.MailPimLogic +import com.gatecontrol.android.mail.MailPlanLogic import com.gatecontrol.android.mail.MailRepository import com.gatecontrol.android.navigation.Screen import com.gatecontrol.android.ui.mail.MailHomeViewModel @@ -25,6 +31,10 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.LocalDate @@ -84,8 +94,16 @@ data class EventEditState( val suggestions: List = emptyList(), val error: String? = null, val message: String? = null, + /** A refusal of a booking (Mail 1.9.0: its rules, the resource taken …), shown with its details. */ + val failure: MResult<*>? = null, /** Saved: the screen closes. */ val done: Boolean = false, + /** Mail 1.9.0: „Frei/Gebucht“ of the day (null: not offered). */ + val plan: PlanState? = null, + /** Mail 1.9.0: the resources the person may book („Ressource hinzufügen …“; null: none or not offered). */ + val resources: List? = null, + /** Mail 1.9.0: the resources' bookings of the saved event („ gebucht“ / „Anfrage für gesendet“). */ + val booked: List = emptyList(), ) /** A new event or a change (contract D3): the form, the attendees' suggestions, saving with the etag, conflicts. */ @@ -117,6 +135,78 @@ class MailEventEditViewModel @Inject constructor( init { if (pim.calendars.value == null) viewModelScope.launch { pim.refreshCalendars() } if (id != null) load() else newForm(saved) + if (repository.me.value?.has(MMe.FEATURE_RESOURCES) == true) { + viewModelScope.launch { api.resources().valueOrNull()?.let { r -> _state.update { it.copy(resources = r.items) } } } + } + if (repository.me.value?.has(MMe.FEATURE_FREE_BUSY) == true) watchPlan() + } + + // ─── free/busy (Mail 1.9.0) ───────────────────────────────────── + + /** What „Frei/Gebucht“ depends on: the day, the time, all-day, the people and resources. */ + private data class PlanKey(val day: LocalDate, val start: Long, val end: Long, val allDay: Boolean, val zone: String, val people: List, val resources: List) + + private var lastSuggest: Pair, MSuggestions>? = null + + /** Loads „Frei/Gebucht“ again (250 ms after the last change) while the form changes; never kept offline. */ + @OptIn(kotlinx.coroutines.FlowPreview::class) + private fun watchPlan() { + viewModelScope.launch { + _state.map { s -> if (s.loading) null else planKey(s.form) } + .distinctUntilChanged() + .debounce(250) + .collectLatest { k -> if (k != null) loadPlan(k) } + } + } + + private fun planKey(f: EventForm): PlanKey { + val (a, b) = times(f) + val (people, res) = MailPlanLogic.split(f.attendees) + return PlanKey(f.startDay, a, b, f.allDay, f.zone, people.sorted(), res.sorted()) + } + + private suspend fun loadPlan(k: PlanKey) { + val zone = MailPimLogic.zone(k.zone) ?: deviceZone + val (from, to) = MailPimLogic.range(k.day, k.day, zone) + _state.update { s -> s.copy(plan = (s.plan?.takeIf { it.day == k.day } ?: PlanState(k.day)).copy(loading = true, offline = false)) } + val dur = MailPlanLogic.duration(k.start, k.end, k.allDay) + val sKey = listOf(k.day, k.people, k.resources, dur) + val fb = api.freeBusy(from, to, k.people, k.resources) + val sg = lastSuggest?.takeIf { it.first == sKey }?.second + ?: api.suggest(dur, maxOf(System.currentTimeMillis(), from), k.people, k.resources).valueOrNull()?.also { lastSuggest = sKey to it } + _state.update { s -> + s.copy( + plan = PlanState( + day = k.day, + rows = fb.valueOrNull() ?: s.plan?.rows?.takeIf { s.plan.day == k.day && fb !is MResult.Offline }, + suggestions = sg ?: MSuggestions(emptyList(), emptyList(), null, null), + loading = false, + offline = fb is MResult.Offline, + error = fb.takeIf { it !is MResult.Ok }, + ), + ) + } + } + + /** A suggestion taken over: its start, the length stays (all-day becomes a time). */ + fun pickSlot(slot: MSlot) { + val f = _state.value.form + val zone = MailPimLogic.zone(f.zone) ?: deviceZone + val s = MailPimLogic.local(slot.start, zone) + if (f.allDay) { + val e = MailPimLogic.local(slot.end, zone) + edit { it.copy(allDay = false, startDay = s.toLocalDate(), startTime = s.toLocalTime(), endDay = e.toLocalDate(), endTime = e.toLocalTime()) } + } else { + setStart(s.toLocalDate(), s.toLocalTime()) + } + } + + /** „Ressource hinzufügen …“: the resource as an attendee (booked when saved, not invited). */ + fun addResource(r: MResource) { + _state.update { s -> + if (s.form.attendees.any { it.email.equals(r.address, ignoreCase = true) }) s + else s.copy(form = s.form.copy(attendees = (s.form.attendees + MAttendeeInput(r.address, r.name, MAttendee.ROLE_RESOURCE)).take(100))) + } } private fun newForm(saved: SavedStateHandle) { @@ -363,10 +453,12 @@ class MailEventEditViewModel @Inject constructor( reminders = f.reminders, recurrence = rule(f), recurrenceSet = sendRule && (s.existing != null || f.ruleKind != MailPimLogic.RULE_NONE), - attendees = f.attendees, + // an event of a calendar shared with the person: attendees stay the owner's (Mail 1.9.0) + attendees = f.attendees.takeIf { s.existing?.occ?.shared == null }, busy = if (f.free) "free" else "busy", private = f.private, - sendInvites = if (f.attendees.isNotEmpty()) true else null, + // resources are booked, not invited + sendInvites = if (f.attendees.any { !MailPlanLogic.isResource(it.email, it.role) }) true else null, ) } @@ -405,7 +497,7 @@ class MailEventEditViewModel @Inject constructor( api.updateEvent(ev.id, MailPimJson.eventBody(input(s, series = sc == "series"), scope = if (ev.occ.occurrence != null) sc else null, occurrence = occ, etag = etag)) } when (r) { - is MResult.Ok -> _state.update { it.copy(saving = false, done = true) } + is MResult.Ok -> _state.update { it.copy(saving = false, done = true, booked = r.value.bookings) } is MResult.Failed -> { val current = if (r.code == "conflict") MailPimJson.conflictEvent(r.error) else null // Mail 1.3.0: the series cannot take this move (`invalid { field: 'start', reason: 'series_shift' }`) @@ -413,7 +505,7 @@ class MailEventEditViewModel @Inject constructor( when { current != null -> _state.update { it.copy(saving = false, conflict = current) } shiftRefused -> _state.update { it.copy(saving = false, message = SERIES_SHIFT) } - else -> _state.update { it.copy(saving = false, message = r.code) } + else -> _state.update { it.copy(saving = false, failure = r) } } } else -> _state.update { it.copy(saving = false, message = MailHomeViewModel.code(r)) } @@ -436,7 +528,7 @@ class MailEventEditViewModel @Inject constructor( fun dismissConflict() = _state.update { it.copy(conflict = null) } - fun clearMessage() = _state.update { it.copy(message = null) } + fun clearMessage() = _state.update { it.copy(message = null, failure = null) } companion object { const val SERIES_SHIFT = "series_shift" diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/PlanComponents.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/PlanComponents.kt new file mode 100644 index 00000000..ba515e47 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/PlanComponents.kt @@ -0,0 +1,271 @@ +package com.gatecontrol.android.ui.mail.calendar + +import android.content.Context +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MBusyBlock +import com.gatecontrol.android.mail.MResource +import com.gatecontrol.android.mail.MViolation +import com.gatecontrol.android.mail.MailPimJson +import com.gatecontrol.android.mail.MailPlanLogic +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.mail.MailHomeViewModel +import com.gatecontrol.android.ui.mail.mailText +import com.gatecontrol.android.ui.theme.GateControlTheme +import com.google.gson.JsonObject +import java.time.ZoneId + +/** The colors of the bars (mockups Planer, Ressource, Anfragen). */ +internal object PlanColors { + val free = Color(0xFF3DDC97).copy(alpha = 0.2f) + val busy = Color(0xFFFF8080).copy(alpha = 0.32f) + val tentative = Color(0xFFF5B544).copy(alpha = 0.3f) + val tentativeStripe = Color(0xFFF5B544).copy(alpha = 0.12f) + val unknown = Color(0xFF8C96A5).copy(alpha = 0.16f) + val chosen = Color(0xFF3DDC97) + val ask = Color(0xFFF5B544).copy(alpha = 0.7f) + /** Resources among the attendees (chips, rows). */ + val resource = Color(0xFFB79CFF) + val warn = Color(0xFFF5B544) + val bad = Color(0xFFFF8A8A) + + fun of(status: String): Color = when (status) { + MailPlanLogic.BUSY -> busy + MailPlanLogic.TENTATIVE -> tentative + MailPlanLogic.UNKNOWN -> unknown + MailPlanLogic.ASK -> ask + "sel" -> chosen + else -> free + } +} + +/** + * A day's bar of [from, to): free, busy, tentative (striped), unknown; [frame] the chosen time as a frame, [filled] + * as a filled part („deine Auswahl“). The bar is described as a whole for TalkBack ([description]). + */ +@Composable +internal fun PlanBar( + blocks: List, + from: Long, + to: Long, + description: String, + modifier: Modifier = Modifier, + gap: String = MailPlanLogic.FREE, + frame: Pair? = null, + filled: Pair? = null, + height: Dp = 22.dp, +) { + val segments = MailPlanLogic.bar(blocks, from, to, gap) + val base = MaterialTheme.colorScheme.background + BoxWithConstraints(modifier.fillMaxWidth().height(height).clearAndSetSemantics { contentDescription = description }) { + val w = maxWidth + Canvas(Modifier.fillMaxSize().clip(RoundedCornerShape(6.dp)).background(base)) { + var x = 0f + segments.forEach { s -> + val sw = s.fraction * size.width + val c = PlanColors.of(s.status) + drawRect(c, topLeft = Offset(x, 0f), size = androidx.compose.ui.geometry.Size(sw, size.height)) + if (s.status == MailPlanLogic.TENTATIVE) { + // stripes like the mockup's vorläufig + val left = x + clipRect(left = left, top = 0f, right = left + sw, bottom = size.height) { + var t = left - size.height + val step = 8.dp.toPx() + while (t < left + sw) { + drawLine(PlanColors.tentativeStripe, Offset(t, size.height), Offset(t + size.height, 0f), strokeWidth = 3.dp.toPx()) + t += step + } + } + } + x += sw + } + filled?.let { (a, z) -> + MailPlanLogic.frame(a, z, from, to)?.let { (l, fw) -> + drawRect(PlanColors.chosen, topLeft = Offset(l * size.width, 0f), size = androidx.compose.ui.geometry.Size(fw * size.width, size.height)) + } + } + } + frame?.let { (a, z) -> + MailPlanLogic.frame(a, z, from, to)?.let { (l, fw) -> + Box( + Modifier.offset(x = w * l).width((w * fw).coerceAtLeast(4.dp)).height(height) + .border(2.dp, PlanColors.chosen, RoundedCornerShape(6.dp)), + ) + } + } + } +} + +/** The hours below a bar (8 10 12 …), each at its place. */ +@Composable +internal fun PlanScale(from: Long, to: Long, zone: ZoneId, modifier: Modifier = Modifier) { + val extra = GateControlTheme.extraColors + val marks = MailPlanLogic.scale(from, to, zone) + BoxWithConstraints(modifier.fillMaxWidth().height(14.dp).clearAndSetSemantics { }) { + val w = maxWidth + marks.forEach { (h, f) -> + val x = (w * f - 6.dp).coerceIn(0.dp, (w - 14.dp).coerceAtLeast(0.dp)) + Text(h.toString(), fontSize = 10.sp, lineHeight = 12.sp, color = extra.faint, modifier = Modifier.offset(x = x)) + } + } +} + +/** The legend below the bars. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun PlanLegend(kinds: List) { + val extra = GateControlTheme.extraColors + FlowRow(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(2.dp)) { + kinds.forEach { k -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box(Modifier.size(12.dp).clip(RoundedCornerShape(3.dp)).background(PlanColors.of(k))) + Text(legendText(k), style = MaterialTheme.typography.bodySmall, color = extra.muted) + } + } + } +} + +@Composable +internal fun legendText(status: String): String = androidx.compose.ui.res.stringResource( + when (status) { + MailPlanLogic.BUSY -> R.string.fb_legend_busy + MailPlanLogic.TENTATIVE -> R.string.fb_legend_tentative + MailPlanLogic.UNKNOWN -> R.string.fb_legend_unknown + "sel" -> R.string.fb_legend_sel + else -> R.string.fb_legend_free + }, +) + +/** A bar in words: „gebucht 08:00–10:00, vorläufig 14:00–15:00“ (free when nothing is taken). */ +internal fun barDescription(context: Context, blocks: List, from: Long, to: Long, zone: ZoneId, gap: String = MailPlanLogic.FREE): String { + val locale = context.resources.configuration.locales[0] + val taken = MailPlanLogic.bar(blocks, from, to, gap).filter { it.status != gap } + if (taken.isEmpty()) return context.getString(if (gap == MailPlanLogic.UNKNOWN) R.string.fb_legend_unknown else R.string.fb_legend_free) + return taken.joinToString(", ") { s -> + val label = context.getString( + when (s.status) { + MailPlanLogic.TENTATIVE -> R.string.fb_legend_tentative + MailPlanLogic.ASK -> R.string.fb_legend_sel + else -> R.string.fb_legend_busy + }, + ) + "$label ${clock(s.start, zone, locale)}–${clock(s.end, zone, locale)}" + } +} + +/** The symbol of a resource's kind in its color (the mockups' square). */ +@Composable +internal fun KindIcon(kind: String, color: String?, size: Dp = 36.dp) { + val c = calColor(color, fallback = Color(0xFF8C96A5)) + Box(Modifier.size(size).clip(RoundedCornerShape(10.dp)).background(c.copy(alpha = 0.16f)), contentAlignment = Alignment.Center) { + Icon(kindIcon(kind), contentDescription = null, tint = c, modifier = Modifier.size(18.dp)) + } +} + +internal fun kindIcon(kind: String): ImageVector = when (kind) { + MResource.ROOM -> GcIcons.Door + MResource.VEHICLE -> GcIcons.Car + MResource.DEVICE -> GcIcons.Monitor + else -> GcIcons.Briefcase +} + +/** A small pill (wartet, Konflikt, zugesagt …). */ +@Composable +internal fun PlanPill(text: String, color: Color) { + Box(Modifier.clip(RoundedCornerShape(999.dp)).background(color.copy(alpha = 0.14f)).padding(horizontal = 10.dp, vertical = 4.dp)) { + Text(text, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = color) + } +} + +// ─── texts ────────────────────────────────────────────────────────── + +/** „1 Tag“, „3 Tage“, „2,5 Std.“. */ +internal fun maxText(context: Context, minutes: Int): String { + val (kind, n) = MailPlanLogic.maxLimit(minutes) + return when (kind) { + "day" -> context.getString(R.string.res_day) + "days" -> context.resources.getQuantityString(R.plurals.res_days, n.toInt(), n.toInt()) + else -> { + val text = if (n % 1.0 == 0.0) n.toInt().toString() else java.text.NumberFormat.getNumberInstance(context.resources.configuration.locales[0]).format(n) + context.resources.getQuantityString(R.plurals.res_hours, if (n == 1.0) 1 else 2, text) + } + } +} + +/** A broken rule in words: „höchstens 1 Tag“, „bis zu 30 Tage im Voraus“, „buchbar 08:00 bis 18:00 Uhr“. */ +internal fun ruleText(context: Context, v: MViolation): String = when (v.rule) { + MViolation.MAX_DURATION -> context.getString(R.string.res_rule_max, maxText(context, v.limit ?: 0)) + MViolation.HORIZON -> context.resources.getQuantityString(R.plurals.res_rule_horizon, v.limit ?: 0, v.limit ?: 0) + else -> context.getString(R.string.res_rule_hours, v.from.orEmpty(), v.to.orEmpty()) +} + +/** Who approves a resource („Fuhrpark“, else „die Freigebenden“). */ +internal fun approversText(context: Context, r: MResource): String = r.approvers.joinToString(", ").ifBlank { context.getString(R.string.res_approvers) } + +/** A resource's line below its name: place · people · equipment · who approves · the longest time. */ +internal fun resourceSub(context: Context, r: MResource): String = listOfNotNull( + r.location, + r.seats?.let { context.resources.getQuantityString(R.plurals.res_seats, it, it) }, + r.equipment, + if (r.needsApproval) context.getString(R.string.res_by, approversText(context, r)) else context.getString(R.string.res_auto), + r.rules.maxMinutes?.let { context.getString(R.string.res_max, maxText(context, it)) }, +).joinToString(" · ") + +/** + * A refusal of free/busy, sharing or a booking in words: the rules a booking breaks in brackets, what is not possible + * in which calendar, a time in the past; else [mailText]. + */ +internal fun planErrorText(context: Context, r: MResult<*>): String { + val code = MailHomeViewModel.code(r) + val error: JsonObject? = (r as? MResult.Failed)?.error + fun field(key: String): String? = error?.get(key)?.takeIf { it.isJsonPrimitive }?.asString + return when { + code == "rule_violation" -> { + val rules = MailPimJson.violations(error).map { ruleText(context, it) } + mailText(context, code) + if (rules.isEmpty()) "" else " (" + rules.joinToString(", ") + ")" + } + code == "not_supported" -> when (field("what")) { + "resource" -> context.getString(R.string.mail_err_res_local) + "resource_series" -> context.getString(R.string.mail_err_res_series) + "share" -> context.getString(R.string.mail_err_share_account) + "shared_attendees" -> context.getString(R.string.share_no_attendees) + else -> mailText(context, code) + } + code == "invalid" && field("reason") == "past" -> context.getString(R.string.mail_err_past) + else -> mailText(context, code) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/Planner.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/Planner.kt new file mode 100644 index 00000000..5973633a --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/Planner.kt @@ -0,0 +1,210 @@ +package com.gatecontrol.android.ui.mail.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MFreeBusy +import com.gatecontrol.android.mail.MFreeBusyRow +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MSlot +import com.gatecontrol.android.mail.MSuggestions +import com.gatecontrol.android.mail.MWorkHours +import com.gatecontrol.android.mail.MailPlanLogic +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.mail.mailContext +import com.gatecontrol.android.ui.theme.GateControlTheme +import com.gatecontrol.android.ui.update.currentLocale +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** „Frei/Gebucht“ of the event form (Mail 1.9.0): the day's rows and the suggestions; never kept offline. */ +data class PlanState( + val day: LocalDate, + val rows: MFreeBusy? = null, + val suggestions: MSuggestions? = null, + val loading: Boolean = true, + val offline: Boolean = false, + val error: MResult<*>? = null, +) + +/** + * The section „Frei/Gebucht“ (mockup Planer): per person and resource a bar of the day (the working hours, widened to + * the chosen time) with the chosen time as a frame, who cannot then, „Vorschläge, bei denen alle können“ (a tap takes + * the time over, the length stays), the note and the legend. Never titles: the server sends none. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun PlannerSection( + plan: PlanState?, + allDay: Boolean, + start: Long, + end: Long, + hours: MWorkHours?, + zone: ZoneId, + onPick: (MSlot) -> Unit, +) { + plan ?: return + val context = mailContext() + val extra = GateControlTheme.extraColors + val locale = currentLocale() + val sel = if (allDay) null else start to end + val (from, to) = MailPlanLogic.dayWindow(plan.day, hours, sel?.first, sel?.second, zone) + val dayFmt = DateTimeFormatter.ofPattern(if (locale.language == "de") "EEE d.M." else "EEE M/d", locale) + Row(Modifier.fillMaxWidth().padding(top = 6.dp, start = 2.dp, end = 2.dp), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.fb_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f).semantics { heading() }) + Text( + plan.day.format(dayFmt) + " · " + stringResource(R.string.fb_hours, clock(from, zone, locale), clock(to, zone, locale)), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + ) + } + if (plan.offline) { + Text(stringResource(R.string.fb_offline), style = MaterialTheme.typography.bodySmall, color = extra.muted, modifier = Modifier.padding(horizontal = 2.dp)) + return + } + Column( + Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)).background(MaterialTheme.colorScheme.surface) + .border(1.dp, extra.border, RoundedCornerShape(16.dp)).padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + val rows = plan.rows?.items + when { + rows == null && plan.loading -> Box(Modifier.fillMaxWidth().padding(12.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(22.dp)) } + rows == null -> Text(plan.error?.let { planErrorText(context, it) }.orEmpty(), style = MaterialTheme.typography.bodySmall, color = extra.muted) + else -> { + val you = stringResource(R.string.fb_you) + rows.forEach { r -> PlanRow(r, you, from, to, sel, zone) } + Row { + Box(Modifier.width(ROW_LABEL + 8.dp)) + PlanScale(from, to, zone, Modifier.weight(1f)) + } + if (sel != null) { + val busyText = legendText(MailPlanLogic.BUSY) + val tentativeText = legendText(MailPlanLogic.TENTATIVE) + val clashes = MailPlanLogic.clashes(rows, sel.first, sel.second) + val unknown = MailPlanLogic.unknown(rows) + val text = when { + clashes.isNotEmpty() -> stringResource( + R.string.fb_clash, + clock(sel.first, zone, locale), + clashes.joinToString(", ") { c -> (if (c.self) you else c.name) + " (" + (if (c.status == MailPlanLogic.BUSY) busyText else tentativeText) + ")" }, + ) + unknown.isNotEmpty() -> stringResource(R.string.fb_all_known_free, unknown.joinToString(", ") { u -> MailPlanLogic.label(u).ifBlank { "?" } }) + else -> stringResource(R.string.fb_all_free) + } + Row( + Modifier.fillMaxWidth().padding(top = 4.dp).semantics { liveRegion = LiveRegionMode.Polite }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + if (clashes.isNotEmpty()) GcIcons.Alert else GcIcons.Check, + contentDescription = null, + tint = if (clashes.isNotEmpty()) PlanColors.warn else extra.accentText, + modifier = Modifier.size(16.dp), + ) + Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + } + } + } + } + } + Text(stringResource(R.string.fb_suggestions), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 4.dp, start = 2.dp)) + val items = plan.suggestions?.items + if (items != null && items.isEmpty()) { + Text(stringResource(R.string.fb_no_suggestions), style = MaterialTheme.typography.bodySmall, color = extra.muted) + } else if (items != null) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + items.forEach { x -> + val on = !allDay && x.start == start + val day = Instant.ofEpochMilli(x.start).atZone(zone) + val label = (if (MailPlanLogic.sameDay(x.start, plan.day, zone)) day.format(DateTimeFormatter.ofPattern("EEE", locale)).removeSuffix(".") else day.format(dayFmt)) + + " " + clock(x.start, zone, locale) + val desc = stringResource(R.string.fb_take, label) + Box( + Modifier.heightIn(min = 40.dp).clip(RoundedCornerShape(12.dp)) + .then(if (on) Modifier.background(extra.accentBg) else Modifier.border(1.dp, extra.border2, RoundedCornerShape(12.dp))) + .selectable(selected = on, role = Role.Button) { onPick(x) } + .semantics { contentDescription = desc } + .padding(horizontal = 12.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text(label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = if (on) extra.accentText else MaterialTheme.colorScheme.onSurface) + } + } + } + } + Text(stringResource(R.string.fb_note), style = MaterialTheme.typography.bodySmall, color = extra.muted, modifier = Modifier.padding(horizontal = 2.dp)) + PlanLegend(listOf(MailPlanLogic.FREE, MailPlanLogic.BUSY, MailPlanLogic.TENTATIVE, MailPlanLogic.UNKNOWN)) +} + +private val ROW_LABEL = 96.dp + +/** One row: avatar and short name („Du“ first), the bar (grey when unknown) with the chosen time as a frame. */ +@Composable +private fun PlanRow(r: MFreeBusyRow, you: String, from: Long, to: Long, sel: Pair?, zone: ZoneId) { + val context = mailContext() + val extra = GateControlTheme.extraColors + val self = r.kind == MFreeBusyRow.SELF + val name = if (self) you else MailPlanLogic.label(r) + val color = when { + !r.known -> Color(0xFF8C96A5) + self -> Color(0xFF46E0A0) + r.kind == MFreeBusyRow.RESOURCE -> PlanColors.resource + r.kind == MFreeBusyRow.MICROSOFT -> Color(0xFF79B8FF) + else -> Color(0xFFF5B544) + } + val gap = if (r.known) MailPlanLogic.FREE else MailPlanLogic.UNKNOWN + Row(Modifier.fillMaxWidth().heightIn(min = 36.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row(Modifier.width(ROW_LABEL), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box(Modifier.size(28.dp).clip(CircleShape).background(color.copy(alpha = if (r.known) 0.16f else 0.14f)), contentAlignment = Alignment.Center) { + Text(if (self) you else MailPlanLogic.initials(r.name.ifBlank { r.email ?: "" }), style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, color = if (r.known) color else extra.muted, maxLines = 1) + } + Text(name, style = MaterialTheme.typography.bodySmall, color = if (r.known) MaterialTheme.colorScheme.onSurface else extra.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + PlanBar( + blocks = r.blocks, + from = from, + to = to, + description = name + ": " + barDescription(context, r.blocks, from, to, zone, gap), + modifier = Modifier.weight(1f), + gap = gap, + frame = sel, + ) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/ResourceBookingScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/ResourceBookingScreen.kt new file mode 100644 index 00000000..d6a26e61 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/ResourceBookingScreen.kt @@ -0,0 +1,397 @@ +package com.gatecontrol.android.ui.mail.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +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.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +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.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MResource +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MBooking +import com.gatecontrol.android.mail.MailPimJson +import com.gatecontrol.android.mail.MailPimLogic +import com.gatecontrol.android.mail.MailPlanLogic +import com.gatecontrol.android.mail.MailRepository +import com.gatecontrol.android.mail.MResChanged +import com.gatecontrol.android.navigation.Screen +import com.gatecontrol.android.ui.components.GcFilterChip +import com.gatecontrol.android.ui.components.GcPrimaryButton +import com.gatecontrol.android.ui.components.GcSubpageBar +import com.gatecontrol.android.ui.components.GcTextField +import com.gatecontrol.android.ui.mail.ErrorBox +import com.gatecontrol.android.ui.mail.mailContext +import com.gatecontrol.android.ui.mail.toast +import com.gatecontrol.android.ui.theme.GateControlTheme +import com.gatecontrol.android.ui.update.currentLocale +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import javax.inject.Inject + +/** What „Ressource buchen“ shows. */ +data class ResourceState( + val day: LocalDate = LocalDate.now(), + val from: LocalTime = LocalTime.of(9, 0), + val to: LocalTime = LocalTime.of(10, 0), + val title: String = "", + /** `all` or a kind of [MResource.KINDS]. */ + val kind: String = ALL, + val selected: Long? = null, + val list: List? = null, + val loading: Boolean = true, + val error: MResult<*>? = null, + val booking: Boolean = false, + /** Booked or asked for: the booking (its state says which). */ + val booked: Pair? = null, + /** A refusal to show (a toast). */ + val failure: MResult<*>? = null, +) { + companion object { + const val ALL = "all" + } +} + +/** + * „Ressource buchen“ (mockup Ressource, Mail 1.9.0): the resources the person may book with their day, the time and + * what for; booked at once or asked for at the approvers. Never kept offline. + */ +@HiltViewModel +class ResourceBookingViewModel @Inject constructor( + private val repository: MailRepository, + saved: SavedStateHandle, +) : ViewModel() { + + private val api = repository.api + val zone: ZoneId = ZoneId.systemDefault() + + private val _state = MutableStateFlow(ResourceState()) + val state: StateFlow = _state.asStateFlow() + + private var loadJob: Job? = null + + init { + val start = saved.get(Screen.MailResources.ARG_START)?.toLongOrNull() + val end = saved.get(Screen.MailResources.ARG_END)?.toLongOrNull() + val (a, b) = if (start != null) start to (end?.takeIf { it > start } ?: (start + 3_600_000L)) else MailPimLogic.nextHour(System.currentTimeMillis(), zone) + val s = MailPimLogic.local(a, zone) + val e = MailPimLogic.local(b, zone) + _state.update { it.copy(day = s.toLocalDate(), from = s.toLocalTime(), to = if (e.toLocalDate() == s.toLocalDate()) e.toLocalTime() else LocalTime.of(23, 59)) } + load() + viewModelScope.launch { + repository.pim.resChanged.collect { c -> if (c.kind == MResChanged.RESOURCES || c.kind == MResChanged.BOOKING || c.kind == MResChanged.REQUEST) load(quiet = true) } + } + } + + fun load(quiet: Boolean = false) { + loadJob?.cancel() + val day = _state.value.day + if (!quiet) _state.update { it.copy(loading = true, error = null) } + loadJob = viewModelScope.launch { + val (from, to) = MailPimLogic.range(day, day, zone) + when (val r = api.resources(from, to)) { + is MResult.Ok -> _state.update { s -> + s.copy(list = r.value.items, loading = false, error = null, selected = s.selected?.takeIf { id -> r.value.items.any { it.id == id } }) + } + else -> _state.update { it.copy(loading = false, error = r) } + } + } + } + + fun setDay(day: LocalDate) { + if (day == _state.value.day) return + _state.update { it.copy(day = day) } + load() + } + + /** The start moves: the end keeps the length (within the day). */ + fun setFrom(t: LocalTime) = _state.update { s -> + val len = java.time.Duration.between(s.from, s.to).toMinutes().coerceAtLeast(15) + val end = t.plusMinutes(len).takeIf { it.isAfter(t) } ?: LocalTime.of(23, 59) + s.copy(from = t, to = end) + } + + fun setTo(t: LocalTime) = _state.update { it.copy(to = t) } + + fun setTitle(v: String) = _state.update { it.copy(title = v.take(255)) } + + fun setKind(k: String) = _state.update { it.copy(kind = k) } + + fun select(id: Long) = _state.update { it.copy(selected = if (it.selected == id) null else id) } + + /** The chosen time in ms (start, end). */ + fun span(s: ResourceState = _state.value): Pair = MailPimLogic.ms(s.day, s.from, zone) to MailPimLogic.ms(s.day, s.to, zone) + + fun book(r: MResource) { + val s = _state.value + if (s.booking) return + val (start, end) = span(s) + if (end <= start) return + _state.update { it.copy(booking = true) } + viewModelScope.launch { + when (val x = api.book(r.id, MailPimJson.bookBody(s.title.trim().ifEmpty { null }, start, end, timeZone = zone.id))) { + is MResult.Ok -> { + _state.update { it.copy(booking = false, booked = r to x.value.bookings.firstOrNull { b -> b.resourceId == r.id }) } + } + else -> _state.update { it.copy(booking = false, failure = x) } + } + } + } + + fun clearFailure() = _state.update { it.copy(failure = null) } +} + +/** „Ressource buchen“ (mockup Ressource): chips of the kinds, the day and time, what for, a card per resource. */ +@Composable +fun ResourceBookingScreen(onBack: () -> Unit, viewModel: ResourceBookingViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = mailContext() + val extra = GateControlTheme.extraColors + val locale = currentLocale() + val zone = viewModel.zone + LaunchedEffect(state.booked) { + val (r, b) = state.booked ?: return@LaunchedEffect + toast(context, context.getString(if (b?.state == MBooking.PENDING) R.string.res_requested else R.string.res_booked, r.name)) + onBack() + } + LaunchedEffect(state.failure) { + state.failure?.let { + toast(context, planErrorText(context, it)) + viewModel.clearFailure() + } + } + Column(Modifier.fillMaxSize().imePadding()) { + GcSubpageBar(title = stringResource(R.string.res_title), onBack = onBack, backDescription = stringResource(R.string.mail_back)) + val list = state.list.orEmpty() + Row( + Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + (listOf(ResourceState.ALL) + MailPlanLogic.kindChips(list)).forEach { k -> + GcFilterChip(kindText(k), selected = state.kind == k, onClick = { viewModel.setKind(k) }) + } + } + val (start, end) = viewModel.span(state) + val (wFrom, wTo) = MailPlanLogic.dayWindow(state.day, null, start, end, zone) + LazyColumn( + Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item(key = "when") { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DayChip(state.day, locale, viewModel::setDay) + Box(Modifier.weight(1f)) + TimeChip(state.from, stringResource(R.string.res_from), locale, viewModel::setFrom) + Text("–", color = extra.muted) + TimeChip(state.to, stringResource(R.string.res_to), locale, viewModel::setTo) + } + if (end <= start) Text(stringResource(R.string.cal_end_before), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + Text( + stringResource(R.string.fb_hours, clock(wFrom, zone, locale), clock(wTo, zone, locale)), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + ) + GcTextField(state.title, viewModel::setTitle, stringResource(R.string.res_what), placeholder = stringResource(R.string.res_title_ph)) + } + } + when { + state.loading && state.list == null -> item(key = "loading") { + Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(28.dp)) } + } + state.error != null && state.list == null -> item(key = "error") { ErrorBox(planErrorText(context, state.error!!), onRetry = { viewModel.load() }) } + else -> { + val shown = list.filter { state.kind == ResourceState.ALL || it.kind == state.kind } + if (shown.isEmpty()) item(key = "none") { Text(stringResource(R.string.res_none), color = extra.muted, modifier = Modifier.padding(vertical = 12.dp)) } + items(shown, key = { "r${it.id}" }) { r -> + ResourceCard( + r = r, + on = state.selected == r.id, + from = wFrom, + to = wTo, + start = start, + end = end, + zone = zone, + title = state.title, + busy = state.booking, + onSelect = { viewModel.select(r.id) }, + onBook = { viewModel.book(r) }, + ) + } + item(key = "legend") { PlanLegend(listOf(MailPlanLogic.FREE, MailPlanLogic.BUSY, MailPlanLogic.TENTATIVE, "sel")) } + } + } + } + } +} + +@Composable +private fun kindText(kind: String): String = stringResource( + when (kind) { + MResource.ROOM -> R.string.res_kind_room + MResource.VEHICLE -> R.string.res_kind_vehicle + MResource.DEVICE -> R.string.res_kind_device + MResource.OTHER -> R.string.res_kind_other + else -> R.string.res_kind_all + }, +) + +/** One resource (mockup): its symbol, name and line, the day as a bar with the chosen time, „Buchen“ when chosen. */ +@Composable +private fun ResourceCard( + r: MResource, + on: Boolean, + from: Long, + to: Long, + start: Long, + end: Long, + zone: ZoneId, + title: String, + busy: Boolean, + onSelect: () -> Unit, + onBook: () -> Unit, +) { + val context = mailContext() + val extra = GateControlTheme.extraColors + val locale = currentLocale() + val shape = RoundedCornerShape(18.dp) + Column( + Modifier.fillMaxWidth().clip(shape).background(MaterialTheme.colorScheme.surface) + .border(1.dp, if (on) PlanColors.chosen.copy(alpha = 0.45f) else extra.border, shape) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + val chosen = stringResource(R.string.fb_legend_sel) + Row( + Modifier.fillMaxWidth().heightIn(min = 44.dp).clip(RoundedCornerShape(12.dp)) + .clickable(role = Role.Button, onClick = onSelect) + .semantics { if (on) stateDescription = chosen }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + KindIcon(r.kind, r.color) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(r.name, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(resourceSub(context, r), style = MaterialTheme.typography.bodySmall, color = extra.muted, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + PlanBar( + blocks = r.busy, + from = from, + to = to, + description = r.name + ": " + barDescription(context, r.busy, from, to, zone), + filled = if (on && end > start) start to end else null, + height = 28.dp, + ) + PlanScale(from, to, zone) + if (on) { + val what = title.trim() + Text( + stringResource(R.string.res_chosen, clock(start, zone, locale), clock(end, zone, locale)) + if (what.isNotEmpty()) " · $what" else "", + style = MaterialTheme.typography.bodyMedium, + ) + GcPrimaryButton( + if (r.needsApproval) stringResource(R.string.res_request_to, approversText(context, r)) else stringResource(R.string.res_book_now), + onClick = onBook, + enabled = end > start, + loading = busy, + ) + } + } +} + +/** The day („Do 5.11.“) opening the date picker. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DayChip(day: LocalDate, locale: java.util.Locale, onPick: (LocalDate) -> Unit) { + var open by remember { mutableStateOf(false) } + PickerChip(day.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)), stringResource(R.string.res_day_field) + ": " + dayLong(day, locale)) { open = true } + if (open) { + val st = rememberDatePickerState(initialSelectedDateMillis = day.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()) + DatePickerDialog( + onDismissRequest = { open = false }, + confirmButton = { + TextButton(onClick = { + open = false + st.selectedDateMillis?.let { ms -> onPick(java.time.Instant.ofEpochMilli(ms).atZone(ZoneOffset.UTC).toLocalDate()) } + }) { Text(stringResource(R.string.mail_ok)) } + }, + dismissButton = { TextButton(onClick = { open = false }) { Text(stringResource(R.string.mail_cancel)) } }, + ) { DatePicker(st) } + } +} + +/** A time („12:00“) opening the time picker. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun TimeChip(time: LocalTime, label: String, locale: java.util.Locale, onPick: (LocalTime) -> Unit) { + var open by remember { mutableStateOf(false) } + val text = time.format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)) + PickerChip(text, "$label: $text") { open = true } + if (open) { + val st = rememberTimePickerState(initialHour = time.hour, initialMinute = time.minute, is24Hour = locale.language != "en") + AlertDialog( + onDismissRequest = { open = false }, + text = { TimePicker(st) }, + confirmButton = { TextButton(onClick = { open = false; onPick(LocalTime.of(st.hour, st.minute)) }) { Text(stringResource(R.string.mail_ok)) } }, + dismissButton = { TextButton(onClick = { open = false }) { Text(stringResource(R.string.mail_cancel)) } }, + ) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/ShareCalendarScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/ShareCalendarScreen.kt new file mode 100644 index 00000000..c394a00a --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/calendar/ShareCalendarScreen.kt @@ -0,0 +1,446 @@ +package com.gatecontrol.android.ui.mail.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MCalChanged +import com.gatecontrol.android.mail.MCalendar +import com.gatecontrol.android.mail.MCalendars +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MPrincipal +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MShare +import com.gatecontrol.android.mail.MSharedAccountCalendar +import com.gatecontrol.android.mail.MSharedCalendar +import com.gatecontrol.android.mail.MSharedList +import com.gatecontrol.android.mail.MailPimJson +import com.gatecontrol.android.mail.MailPlanLogic +import com.gatecontrol.android.mail.MailRepository +import com.gatecontrol.android.navigation.Screen +import com.gatecontrol.android.ui.components.GcCard +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcSegmented +import com.gatecontrol.android.ui.components.GcSubpageBar +import com.gatecontrol.android.ui.components.GcSwitchRow +import com.gatecontrol.android.ui.components.GcTextField +import com.gatecontrol.android.ui.mail.mailContext +import com.gatecontrol.android.ui.mail.toast +import com.gatecontrol.android.ui.theme.GateControlTheme +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** What „Kalender teilen“ shows. */ +data class ShareState( + val shares: List? = null, + val loading: Boolean = false, + val error: MResult<*>? = null, + /** The level a new share gets (mockup: Lesen). */ + val level: String = MShare.READ, + val query: String = "", + val results: List = emptyList(), + val shared: MSharedList? = null, + val failure: MResult<*>? = null, +) + +/** + * „Kalender teilen“ (mockup Teilen, Mail 1.9.0) of an own GateControl calendar: a person or group (`GET principals`) at + * Frei/Gebucht, Lesen or Bearbeiten, „Geteilt mit“ with their state and level, „Frei/Gebucht für alle in GateControl“ + * and „Mit mir geteilt“ with „Anzeigen“. Without a calendar only the last two. + */ +@HiltViewModel +class ShareCalendarViewModel @Inject constructor( + private val repository: MailRepository, + saved: SavedStateHandle, +) : ViewModel() { + + private val pim = repository.pim + private val api = repository.api + val me: StateFlow = repository.me + val calendars: StateFlow = pim.calendars + val calendarId: Long? = saved.get(Screen.MailShareCalendar.ARG_CAL)?.toLongOrNull()?.takeIf { it > 0 } + + private val _state = MutableStateFlow(ShareState()) + val state: StateFlow = _state.asStateFlow() + + private var searchJob: Job? = null + + init { + if (pim.calendars.value == null) viewModelScope.launch { pim.refreshCalendars() } + loadShares() + loadShared() + viewModelScope.launch { + pim.calChanged.collect { c -> + if (c.kind == MCalChanged.SHARES) { + if (c.calendarId == null || c.calendarId == calendarId) loadShares(quiet = true) + loadShared() + } + } + } + } + + fun loadShares(quiet: Boolean = false) { + val id = calendarId ?: return + if (!quiet) _state.update { it.copy(loading = true, error = null) } + viewModelScope.launch { + when (val r = api.shares(id)) { + is MResult.Ok -> { + _state.update { it.copy(shares = r.value.items, loading = false, error = null) } + r.value.freeBusy?.let { fb -> pim.calendars.value?.prefs?.let { p -> if (p.freeBusy != fb) pim.putPrefs(p.copy(freeBusy = fb)) } } + } + else -> _state.update { it.copy(loading = false, error = r) } + } + } + } + + fun loadShared() { + viewModelScope.launch { api.shared().valueOrNull()?.let { l -> _state.update { it.copy(shared = l) } } } + } + + fun setLevel(level: String) = _state.update { it.copy(level = level) } + + fun search(q: String) { + _state.update { it.copy(query = q.take(60)) } + searchJob?.cancel() + if (q.trim().length < 2) { + _state.update { it.copy(results = emptyList()) } + return + } + searchJob = viewModelScope.launch { + delay(250) + val found = api.principals(q).valueOrNull().orEmpty() + _state.update { it.copy(results = found) } + } + } + + fun share(p: MPrincipal) { + val id = calendarId ?: return + val level = _state.value.level + _state.update { it.copy(query = "", results = emptyList()) } + viewModelScope.launch { + when (val r = api.addShare(id, p.type, p.id, level)) { + is MResult.Ok -> loadShares(quiet = true) + else -> _state.update { it.copy(failure = r) } + } + } + } + + fun changeLevel(s: MShare, level: String) { + val id = calendarId ?: return + if (level == s.level) return + _state.update { st -> st.copy(shares = st.shares?.map { if (it.id == s.id) it.copy(level = level) else it }) } + viewModelScope.launch { + val r = api.patchShare(id, s.id, level) + if (r !is MResult.Ok) _state.update { it.copy(failure = r) } + loadShares(quiet = true) + } + } + + fun remove(s: MShare) { + val id = calendarId ?: return + _state.update { st -> st.copy(shares = st.shares?.filter { it.id != s.id }) } + viewModelScope.launch { + val r = api.deleteShare(id, s.id) + if (r !is MResult.Ok) _state.update { it.copy(failure = r) } + loadShares(quiet = true) + } + } + + /** „Frei/Gebucht für alle in GateControl“ (the person's setting). */ + fun setFreeBusy(on: Boolean) { + val before = pim.calendars.value?.prefs ?: return + pim.putPrefs(before.copy(freeBusy = on)) + viewModelScope.launch { + when (val r = api.calendarSettings(MailPimJson.calSettingsBody(freeBusy = on))) { + is MResult.Ok -> pim.putPrefs(r.value) + else -> { + pim.putPrefs(before) + _state.update { it.copy(failure = r) } + } + } + } + } + + /** „Anzeigen“ of a calendar shared with the person (the first time: the owner reads „hat angenommen“). */ + fun toggleShared(x: MSharedCalendar) { + _state.update { st -> st.copy(shared = st.shared?.copy(items = st.shared.items.map { if (it.id == x.id) it.copy(visible = !x.visible) else it })) } + viewModelScope.launch { + val r = api.patchShared(x.id, visible = !x.visible) + if (r !is MResult.Ok) _state.update { it.copy(failure = r) } + loadShared() + pim.refreshCalendars() + } + } + + /** „Anzeigen“ of an account's calendar of another mailbox (like the account's own ones). */ + fun toggleAccount(x: MSharedAccountCalendar) { + _state.update { st -> st.copy(shared = st.shared?.copy(accounts = st.shared.accounts.map { if (it.calendarId == x.calendarId) it.copy(visible = !x.visible) else it })) } + viewModelScope.launch { + when (val r = api.patchCalendar(x.calendarId, MailPimJson.calendarPatch(visible = !x.visible))) { + is MResult.Ok -> pim.putCalendar(r.value) + else -> _state.update { it.copy(failure = r) } + } + loadShared() + } + } + + fun clearFailure() = _state.update { it.copy(failure = null) } +} + +/** „Kalender teilen“ (mockup Teilen). */ +@Composable +fun ShareCalendarScreen(onBack: () -> Unit, viewModel: ShareCalendarViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val cals by viewModel.calendars.collectAsStateWithLifecycle() + val me by viewModel.me.collectAsStateWithLifecycle() + val context = mailContext() + val extra = GateControlTheme.extraColors + LaunchedEffect(state.failure) { + state.failure?.let { + toast(context, planErrorText(context, it)) + viewModel.clearFailure() + } + } + val cal: MCalendar? = cals?.calendar(viewModel.calendarId) + Column(Modifier.fillMaxSize().imePadding()) { + GcSubpageBar( + title = stringResource(if (viewModel.calendarId != null) R.string.share_title else R.string.share_with_me), + onBack = onBack, + backDescription = stringResource(R.string.mail_back), + ) + Column( + Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (viewModel.calendarId != null) { + Row(Modifier.padding(2.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box(Modifier.size(14.dp).clip(RoundedCornerShape(4.dp)).background(calColor(cal?.color))) + Text(cal?.name.orEmpty(), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Text(stringResource(R.string.share_gc_calendar), style = MaterialTheme.typography.bodySmall, color = extra.muted) + } + AddCard(state, viewModel) + Text(stringResource(R.string.share_shared_with), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(2.dp).semantics { heading() }) + GcCard(contentPadding = PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.Top) { + val shares = state.shares + when { + shares == null && state.loading -> Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(24.dp)) } + shares == null -> Text(state.error?.let { planErrorText(context, it) }.orEmpty(), color = extra.muted, modifier = Modifier.padding(14.dp)) + shares.isEmpty() -> Text(stringResource(R.string.share_none), color = extra.muted, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(14.dp)) + else -> shares.forEach { s -> ShareRow(s, onLevel = { viewModel.changeLevel(s, it) }, onRemove = { viewModel.remove(s) }) } + } + } + } + if (me?.has(MMe.FEATURE_FREE_BUSY) == true) { + GcCard(contentPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp), verticalArrangement = Arrangement.Top) { + GcSwitchRow( + label = stringResource(R.string.share_fb_all), + description = stringResource(R.string.share_fb_all_d), + checked = cals?.prefs?.freeBusy ?: true, + onCheckedChange = viewModel::setFreeBusy, + ) + } + } + Text(stringResource(R.string.share_with_me), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(2.dp).semantics { heading() }) + GcCard(contentPadding = PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.Top) { + val l = state.shared + if (l == null) { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(24.dp)) } + } else if (l.items.isEmpty() && l.accounts.isEmpty()) { + Text(stringResource(R.string.share_mine_none), color = extra.muted, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(14.dp)) + } else { + l.items.forEach { x -> + val level = levelText(x.level) + (x.group?.let { " · " + stringResource(R.string.share_via, it) } ?: "") + if (!x.accepted) " · " + stringResource(R.string.share_new) else "" + SharedRow(stringResource(R.string.share_from, x.name, x.ownerName.ifBlank { "?" }), level, x.color, x.visible) { viewModel.toggleShared(x) } + } + l.accounts.forEach { x -> + val account = me?.account(x.accountId)?.label + SharedRow( + x.name + (account?.let { " · $it" } ?: ""), + listOf(x.ownerEmail, levelText(if (x.writable) MShare.WRITE else MShare.READ)).filter { it.isNotBlank() }.joinToString(" · "), + x.color, + x.visible, + ) { viewModel.toggleAccount(x) } + } + } + } + Text(stringResource(R.string.share_accounts_note), style = MaterialTheme.typography.bodySmall, color = extra.muted, modifier = Modifier.padding(horizontal = 2.dp)) + Box(Modifier.heightIn(min = 16.dp)) + } + } +} + +@Composable +internal fun levelText(level: String): String = stringResource( + when (level) { + MShare.FREEBUSY -> R.string.share_level_freebusy + MShare.WRITE -> R.string.share_level_write + else -> R.string.share_level_read + }, +) + +/** „Person oder Gruppe hinzufügen“: the search, its results, the level for the new share. */ +@Composable +private fun AddCard(state: ShareState, viewModel: ShareCalendarViewModel) { + val extra = GateControlTheme.extraColors + GcCard(contentPadding = PaddingValues(14.dp)) { + Text(stringResource(R.string.share_add), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + GcTextField(state.query, viewModel::search, stringResource(R.string.share_add), placeholder = stringResource(R.string.share_search_ph)) + if (state.results.isNotEmpty()) { + Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(extra.panel2)) { + state.results.forEach { p -> + Row( + Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable(role = Role.Button) { viewModel.share(p) }.padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Avatar(p.name, p.color, group = p.type == MShare.GROUP) + Text( + if (p.type == MShare.GROUP) stringResource(R.string.share_group_entry, p.sub ?: p.name) else p.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + } + } + } + GcSegmented(options = MShare.LEVELS.map { it to levelText(it) }, selected = state.level, onSelect = viewModel::setLevel) + Text(stringResource(R.string.share_levels_note), style = MaterialTheme.typography.bodySmall, color = extra.muted) + } +} + +@Composable +private fun Avatar(name: String, color: String?, group: Boolean) { + val c = if (group) Color(0xFFF5B544) else calColor(color, fallback = Color(0xFF79B8FF)) + Box(Modifier.size(32.dp).clip(CircleShape).background(c.copy(alpha = 0.16f)), contentAlignment = Alignment.Center) { + if (group) { + Icon(GcIcons.Users, contentDescription = null, tint = c, modifier = Modifier.size(18.dp)) + } else { + Text(MailPlanLogic.initials(name), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = c) + } + } +} + +/** One share: avatar, name, its state or the group's size, the level with „Nicht mehr teilen“. */ +@Composable +private fun ShareRow(s: MShare, onLevel: (String) -> Unit, onRemove: () -> Unit) { + val extra = GateControlTheme.extraColors + var open by remember { mutableStateOf(false) } + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(horizontal = 14.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Avatar(s.name, null, group = s.type == MShare.GROUP) + Column(Modifier.weight(1f)) { + Text(s.name.ifBlank { "?" }, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + when { + s.type == MShare.GROUP -> androidx.compose.ui.res.pluralStringResource(R.plurals.share_members, s.members ?: 0, s.members ?: 0) + s.state == MShare.STATE_ACCEPTED -> stringResource(R.string.share_state_accepted) + else -> stringResource(R.string.share_state_pending) + }, + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + ) + } + Box { + val label = levelText(s.level) + val desc = stringResource(R.string.share_level_for, s.name) + Row( + Modifier.heightIn(min = 36.dp).clip(RoundedCornerShape(10.dp)).border(1.dp, extra.border2, RoundedCornerShape(10.dp)) + .clickable(role = Role.DropdownList) { open = true }.semantics { contentDescription = "$desc: $label" } + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) + Icon(GcIcons.ChevronDown, contentDescription = null, tint = extra.muted, modifier = Modifier.size(16.dp)) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + MShare.LEVELS.forEach { l -> DropdownMenuItem(text = { Text(levelText(l)) }, onClick = { open = false; onLevel(l) }) } + DropdownMenuItem(text = { Text(stringResource(R.string.share_remove), color = MaterialTheme.colorScheme.error) }, onClick = { open = false; onRemove() }) + } + } + } +} + +/** A calendar shared with the person: its color (a frame while hidden), name and owner, level, „Anzeigen“. */ +@Composable +private fun SharedRow(title: String, sub: String, color: String, visible: Boolean, onToggle: () -> Unit) { + val extra = GateControlTheme.extraColors + val c = calColor(color) + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(horizontal = 14.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(Modifier.size(14.dp).clip(RoundedCornerShape(4.dp)).then(if (visible) Modifier.background(c) else Modifier.border(2.dp, c, RoundedCornerShape(4.dp)))) + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (sub.isNotBlank()) Text(sub, style = MaterialTheme.typography.bodySmall, color = extra.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Box( + Modifier.heightIn(min = 36.dp).clip(RoundedCornerShape(10.dp)) + .then(if (visible) Modifier.background(extra.accentBg) else Modifier.border(1.dp, extra.border2, RoundedCornerShape(10.dp))) + .toggleable(value = visible, role = Role.Switch, onValueChange = { onToggle() }) + .padding(horizontal = 12.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text(stringResource(R.string.share_show), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = if (visible) extra.accentText else MaterialTheme.colorScheme.onSurface) + } + } +} diff --git a/app/src/main/res/values-de/strings_mail3.xml b/app/src/main/res/values-de/strings_mail3.xml new file mode 100644 index 00000000..54e7f0fd --- /dev/null +++ b/app/src/main/res/values-de/strings_mail3.xml @@ -0,0 +1,144 @@ + + + + Geteilte Kalender + Buchungsanfragen + Buchungen + Diese Funktion ist ausgeschaltet. + Diese Ressource darfst du nicht buchen. + Die Ressource ist in dieser Zeit schon gebucht. + Die Buchung hält die Regeln der Ressource nicht ein. + Über diese Anfrage wurde schon entschieden. + Diesen Kalender darfst du nur lesen. + Die Zeit liegt in der Vergangenheit. + Das geht in diesem Kalender nicht. + Ressourcen lassen sich nur in GateControl-Kalendern buchen. + Ressourcen lassen sich nicht für Serien buchen. + Kalender eines Mail-Kontos teilst du beim Anbieter. + Frei/Gebucht + %1$s bis %2$s Uhr + Du + %1$s passt nicht für %2$s + Zu dieser Zeit können alle + Alle, die man sehen kann, haben Zeit · unbekannt: %1$s + Vorschläge, bei denen alle können + Keine freie Zeit in der Arbeitszeit der nächsten Tage + Frei/Gebucht zeigt nur belegt oder frei – keine Titel. Personen außerhalb von GateControl erscheinen grau, Kolleginnen in Microsoft 365 über dein Microsoft-Konto. + frei + gebucht + vorläufig + unbekannt + deine Auswahl + Offline – geht wieder mit Verbindung + Übernehmen: %1$s + Ressource buchen + Ressourcen + Art der Ressource + Alle + Räume + Fahrzeuge + Geräte + Sonstiges + Tag + Von + Bis + Wofür? (z. B. Kunde Müller) + Titel + Keine Ressource, die du buchen kannst. + Freigabe durch %1$s + die Freigebenden + sagt automatisch zu + max. %1$s + 1 Tag + Ressource hinzufügen … + mit Freigabe + Ressourcen werden gebucht, nicht eingeladen + Ausgewählt: %1$s bis %2$s + Buchen + Anfrage an %1$s senden + %1$s gebucht + Anfrage für %1$s gesendet + höchstens %1$s + buchbar %1$s bis %2$s Uhr + Buchungsanfragen + Anfragen · %1$d + Meine Buchungen + Offen · %1$d + Erledigt + Keine offenen Anfragen. + In den letzten 60 Tagen nichts entschieden. + Du hast keine Buchungen. + Die Anfragen kommen auch als Benachrichtigung und als Mail an alle, die freigeben dürfen. Wer zuerst entscheidet, erledigt die Anfrage für alle. Die buchende Person bekommt die Antwort als Zusage oder Absage in ihrem Kalender. + wartet + Konflikt + zugesagt + abgelehnt + abgesagt + Überschreitet „%1$s“ + %1$s schon gebucht + Frei · Regeln erfüllt + %1$s, %2$s + trotz Regelverstoß freigegeben + Freigeben + Trotzdem freigeben + Ablehnen + Freigegeben + Abgelehnt + %1$s hat schon entschieden + Absagen + Buchung abgesagt + Buchung von %1$s absagen? + Die Ressource verlässt den Termin. + Termin öffnen + Kalender teilen + Teilen + GateControl-Kalender + Person oder Gruppe hinzufügen + Name oder Gruppe … + Gruppe %1$s + Stufe + Frei/Gebucht + Lesen + Bearbeiten + Stufe für %1$s + Lesen: Titel, Ort und Zeiten. Bearbeiten: Termine anlegen und ändern. Private Termine bleiben bei allen Stufen „Gebucht“. + Geteilt mit + Noch mit niemandem geteilt. + Nicht mehr teilen + hat angenommen + Einladung offen + Frei/Gebucht für alle in GateControl + Damit dich alle zu Besprechungen einladen können, ohne Termine zu sehen + Mit mir geteilt + %1$s · von %2$s + Anzeigen + Ausblenden + neu + Niemand teilt gerade einen Kalender mit dir. + Kalender eines Mail-Kontos (Exchange, Google) teilst du beim Anbieter; GateControl zeigt sie hier, sobald sie dort freigegeben sind. + Geteilt von %1$s · %2$s + über %1$s + Nur belegt – mehr zeigt dieser Kalender nicht. + In geteilten Kalendern ohne Teilnehmer + Kalender + + %d Person + %d Personen + + + %d Tag + %d Tage + + + %s Std. + %s Std. + + + bis zu %d Tag im Voraus + bis zu %d Tage im Voraus + + + %d Person + %d Personen + + diff --git a/app/src/main/res/values/strings_mail3.xml b/app/src/main/res/values/strings_mail3.xml new file mode 100644 index 00000000..d85a3b58 --- /dev/null +++ b/app/src/main/res/values/strings_mail3.xml @@ -0,0 +1,144 @@ + + + + Shared calendars + Booking requests + Bookings + This function is switched off. + You may not book this resource. + The resource is already booked at that time. + The booking breaks the resource\'s rules. + This request has already been decided. + You may only read this calendar. + That time is in the past. + That is not possible in this calendar. + Resources can only be booked in GateControl calendars. + Resources cannot be booked for a series. + You share the calendars of a mail account at the provider. + Free/busy + %1$s to %2$s + You + %1$s does not suit %2$s + Everybody can at that time + Everybody who can be seen is free · unknown: %1$s + Suggestions when everybody can + No free time within the working hours of the next days + Free/busy only shows taken or free – no titles. People outside GateControl appear grey, colleagues in Microsoft 365 come through your Microsoft account. + free + busy + tentative + unknown + your choice + Offline – works again once connected + Take over: %1$s + Book a resource + Resources + Kind of resource + All + Rooms + Vehicles + Devices + Other + Day + From + To + What for? (e.g. customer meeting) + Title + No resource you can book. + Approval by %1$s + the approvers + confirms automatically + max. %1$s + 1 day + Add a resource … + with approval + Resources are booked, not invited + Chosen: %1$s to %2$s + Book + Send the request to %1$s + %1$s booked + Request for %1$s sent + at most %1$s + bookable %1$s to %2$s + Booking requests + Requests · %1$d + My bookings + Open · %1$d + Done + No open requests. + Nothing decided in the last 60 days. + You have no bookings. + Requests also come as a notification and a mail to everybody who may approve. The first decision settles the request for everybody. The booker gets the answer as a yes or no in their calendar. + waiting + Conflict + confirmed + declined + called off + Breaks „%1$s“ + %1$s already booked + Free · rules kept + %1$s, %2$s + approved despite the rules + Approve + Approve anyway + Decline + Approved + Declined + %1$s has already decided + Call off + Booking called off + Call off the booking of %1$s? + The resource leaves the event. + Open the event + Share calendar + Share + GateControl calendar + Add a person or group + Name or group … + Group %1$s + Level + Free/busy + Read + Edit + Level for %1$s + Read: titles, places and times. Edit: create and change events. Private events stay „Busy“ at every level. + Shared with + Not shared with anybody yet. + Stop sharing + accepted + invitation open + Free/busy for everybody in GateControl + So everybody can invite you to meetings without seeing your events + Shared with me + %1$s · from %2$s + Show + Hide + new + Nobody shares a calendar with you right now. + You share the calendars of a mail account (Exchange, Google) at the provider; GateControl shows them here once they are shared there. + Shared by %1$s · %2$s + through %1$s + Only taken – this calendar shows nothing more. + No attendees in shared calendars + Calendar + + %d person + %d people + + + %d day + %d days + + + %s h + %s h + + + up to %d day ahead + up to %d days ahead + + + %d person + %d people + + diff --git a/app/src/test/java/com/gatecontrol/android/mail/MailPlanTest.kt b/app/src/test/java/com/gatecontrol/android/mail/MailPlanTest.kt new file mode 100644 index 00000000..ca7c2732 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/mail/MailPlanTest.kt @@ -0,0 +1,320 @@ +package com.gatecontrol.android.mail + +import com.gatecontrol.android.notify.model.PushMessage +import com.google.gson.JsonParser +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId + +/** Mail 1.9.0 (phase 3): free/busy, shared calendars, resources and bookings — what is read, sent and shown. */ +class MailPlanTest { + + private fun o(s: String) = JsonParser.parseString(s).asJsonObject + private val zone: ZoneId = ZoneId.of("Europe/Berlin") + private val day: LocalDate = LocalDate.of(2026, 11, 5) + private fun at(h: Int, m: Int = 0): Long = day.atTime(LocalTime.of(h, m)).atZone(zone).toInstant().toEpochMilli() + + @Test + fun `me - the phase 3 flags, an older server has none`() { + val me = MailJson.me(o("""{"features":["mail","calendar","freeBusy","calendarSharing","resources","bookingRequests"]}"""))!! + assertTrue(me.has(MMe.FEATURE_FREE_BUSY)) + assertTrue(me.has(MMe.FEATURE_SHARING)) + assertTrue(me.has(MMe.FEATURE_RESOURCES)) + assertTrue(me.has(MMe.FEATURE_BOOKING_REQUESTS)) + val old = MailJson.me(o("""{"features":["mail","calendar","contacts","scan"]}"""))!! + listOf(MMe.FEATURE_FREE_BUSY, MMe.FEATURE_SHARING, MMe.FEATURE_RESOURCES, MMe.FEATURE_BOOKING_REQUESTS).forEach { assertFalse(old.has(it), it) } + } + + @Test + fun `calendars - shared ones, sharedBy, free busy and working hours of the prefs`() { + val c = MailPimJson.calendars( + o( + """{"calendars":[{"id":1,"name":"Privat","color":"#46e0a0"},{"id":2,"name":"Team","source":"graph","accountId":3,"sharedBy":{"email":"chef@firma.de"}}], + "prefs":{"freeBusy":false,"workHours":{"days":[1,2,3,4],"from":"07:30","to":"16:00"}}, + "shared":[{"id":9,"name":"Urlaube","color":"#8C96A5","owner":{"userId":4,"name":"Sabine K."},"level":"read","via":"group","group":"Familie","visible":true,"accepted":false,"writable":false}, + {"id":0,"name":"kaputt"},{"id":10,"name":"Frei","level":"bogus"}]}""", + ), + )!! + assertNull(c.calendar(1)!!.sharedBy) + assertEquals("chef@firma.de", c.calendar(2)!!.sharedBy) + assertFalse(c.prefs.freeBusy) + assertEquals(MWorkHours(listOf(1, 2, 3, 4), "07:30", "16:00"), c.prefs.workHours) + assertEquals(listOf(9L, 10L), c.shared.map { it.id }) + val s = c.sharedCalendar(9)!! + assertEquals("Sabine K.", s.ownerName) + assertEquals(MShare.GROUP, s.via) + assertEquals("Familie", s.group) + assertTrue(s.visible) + assertFalse(s.accepted) + assertEquals(MShare.FREEBUSY, c.sharedCalendar(10)!!.level, "an unknown level is the least") + // an older server: no shared calendars, free/busy on, Mon–Fri 8–18 + val old = MailPimJson.calendars(o("""{"calendars":[],"prefs":{"workHours":{"days":[1],"from":"18:00","to":"08:00"}}}"""))!! + assertTrue(old.shared.isEmpty()) + assertTrue(old.prefs.freeBusy) + assertEquals(MWorkHours.DEFAULT, old.prefs.workHours, "from after to is not read") + } + + @Test + fun `events - a free busy entry of a shared calendar without an event`() { + val e = MailPimJson.events( + o( + """{"items":[{"key":"s:9:1:1","eventId":null,"calendarId":9,"title":"Gebucht","start":1000,"end":2000,"busy":"busy","private":true,"shared":"freebusy"}, + {"eventId":5,"calendarId":9,"title":"Urlaub","start":1000,"end":2000,"shared":"read"}, + {"eventId":null,"title":"kaputt","start":1}]}""", + ), + )!! + assertEquals(2, e.items.size, "without an event only a shared entry is kept") + val busy = e.items[0] + assertTrue(busy.busyOnly) + assertEquals(MShare.FREEBUSY, busy.shared) + assertEquals("s:9:1:1", busy.key) + assertFalse(e.items[1].busyOnly) + assertEquals(MShare.READ, e.items[1].shared) + assertNull(MailPimJson.occurrence(o("""{"eventId":null,"start":1,"shared":"read"}""")), "no key and no event: nothing to tell it by") + } + + @Test + fun `free busy - rows, unknown ones grey, suggestions`() { + val fb = MailPimJson.freeBusy( + o( + """{"from":0,"to":100,"items":[{"key":"self","kind":"self","userId":1,"name":"Ich","state":"ok","blocks":[{"start":10,"end":20,"status":"busy"}]}, + {"key":"tom@x.de","kind":"person","email":"tom@x.de","name":"Tom B.","state":"ok","blocks":[{"start":30,"end":40,"status":"tentative"},{"start":50,"end":40}]}, + {"key":"r:3","kind":"resource","resourceId":3,"name":"Raum 1","state":"ok","blocks":[{"start":60,"end":70,"status":"pending"}]}, + {"key":"kunde@mueller.de","kind":"external","email":"kunde@mueller.de","name":"","state":"unknown","blocks":[{"start":1,"end":2}]}]}""", + ), + )!! + assertEquals(listOf(MFreeBusyRow.SELF, MFreeBusyRow.PERSON, MFreeBusyRow.RESOURCE, MFreeBusyRow.EXTERNAL), fb.items.map { it.kind }) + assertEquals(1, fb.items[1].blocks.size, "a block ending before it starts is left out") + assertEquals(MBusyBlock.TENTATIVE, fb.items[2].blocks[0].status, "a request shows as tentative") + assertFalse(fb.items[3].known) + assertTrue(fb.items[3].blocks.isEmpty()) + val sg = MailPimJson.suggestions(o("""{"items":[{"start":5,"end":10},{"start":9}],"unknown":["kunde@mueller.de"],"workHours":{"days":[1,2,3,4,5],"from":"08:00","to":"24:00"},"timeZone":"Europe/Berlin"}"""))!! + assertEquals(listOf(MSlot(5, 10)), sg.items) + assertEquals(listOf("kunde@mueller.de"), sg.unknown) + assertEquals("24:00", sg.workHours!!.to) + } + + @Test + fun `free busy - the window, the bar, who cannot`() { + // the working hours, widened to a chosen time before them (from its full hour) and after them + assertEquals(at(8) to at(18), MailPlanLogic.dayWindow(day, null, null, null, zone)) + assertEquals(at(7) to at(19, 30), MailPlanLogic.dayWindow(day, MWorkHours.DEFAULT, at(7, 30), at(19, 30), zone)) + assertEquals(at(9) to at(17), MailPlanLogic.dayWindow(day, MWorkHours(listOf(1), "09:00", "17:00"), at(10), at(11), zone)) + assertEquals(at(8) to at(18), MailPlanLogic.dayWindow(day, null, at(7) + 86_400_000L, at(8) + 86_400_000L, zone), "another day's time widens nothing") + val bar = MailPlanLogic.bar(listOf(MBusyBlock(at(10), at(11), "busy"), MBusyBlock(at(7), at(9), "tentative")), at(8), at(12)) + assertEquals(listOf("tentative", "free", "busy", "free"), bar.map { it.status }) + assertEquals(1f, bar.sumOf { it.fraction.toDouble() }.toFloat(), 0.0001f) + assertEquals(0.25f, bar[0].fraction, 0.0001f, "cut to the window") + assertEquals(listOf("unknown"), MailPlanLogic.bar(emptyList(), at(8), at(12), MailPlanLogic.UNKNOWN).map { it.status }) + assertTrue(MailPlanLogic.bar(emptyList(), 5, 5).isEmpty()) + assertEquals(0.25f to 0.5f, MailPlanLogic.frame(at(9), at(11), at(8), at(12))) + assertNull(MailPlanLogic.frame(at(13), at(14), at(8), at(12))) + assertEquals(listOf(8, 10, 12, 14, 16, 18), MailPlanLogic.scale(at(8), at(18), zone).map { it.first }) + assertEquals(listOf(0, 4, 8, 12, 16, 20, 0), MailPlanLogic.scale(at(0), at(0) + 86_400_000L, zone).map { it.first }) + + val rows = listOf( + MFreeBusyRow("self", MFreeBusyRow.SELF, null, null, 1, "", true, listOf(MBusyBlock(at(9), at(10), "busy"))), + MFreeBusyRow("s", MFreeBusyRow.PERSON, "s@x.de", null, 2, "Sabine K.", true, listOf(MBusyBlock(at(14), at(15), "tentative"))), + MFreeBusyRow("t", MFreeBusyRow.PERSON, "t@x.de", null, 3, "Tom B.", true, listOf(MBusyBlock(at(13), at(16), "busy"))), + MFreeBusyRow("k", MFreeBusyRow.EXTERNAL, "kunde@mueller.de", null, null, "", false, emptyList()), + ) + val c = MailPlanLogic.clashes(rows, at(14), at(15)) + assertEquals(listOf("Tom", "Sabine"), c.map { it.name }, "the busy ones first, first names") + assertEquals(listOf("busy", "tentative"), c.map { it.status }) + assertTrue(MailPlanLogic.clashes(rows, at(9, 30), at(10)).single().self) + assertTrue(MailPlanLogic.clashes(rows, at(11), at(12)).isEmpty()) + assertEquals(listOf("kunde"), MailPlanLogic.unknown(rows).map { MailPlanLogic.label(it) }) + assertEquals("SK", MailPlanLogic.initials("Sabine K.")) + assertEquals("KU", MailPlanLogic.initials("kunde@mueller.de")) + assertEquals("?", MailPlanLogic.initials("")) + assertEquals("Raum 1", MailPlanLogic.label(MFreeBusyRow("r", MFreeBusyRow.RESOURCE, null, 3, null, "Raum 1", true, emptyList()))) + } + + @Test + fun `resources - as attendees, the people and resources of a form, suggestion body`() { + val a = listOf( + MAttendeeInput("Tom@X.de", "Tom"), + MAttendeeInput("resource-12@resources.gatecontrol.invalid", "Raum 1", MAttendee.ROLE_RESOURCE), + MAttendeeInput("tom@x.de"), + MAttendeeInput("resource-x@resources.gatecontrol.invalid"), + ) + val (people, res) = MailPlanLogic.split(a) + assertEquals(listOf("tom@x.de", "resource-x@resources.gatecontrol.invalid"), people, "an address that only looks like one stays a person") + assertEquals(listOf(12L), res) + assertTrue(MailPlanLogic.isResource("x@y.de", MAttendee.ROLE_RESOURCE)) + assertEquals(12L, MailPlanLogic.resourceId("RESOURCE-12@resources.gatecontrol.invalid")) + assertNull(MailPlanLogic.resourceId("resource-12@resources.gatecontrol.invalid.evil.de")) + val body = MailPimJson.suggestBody(2000, 7L, people, res, limit = 99) + assertEquals(1440, body.get("duration").asInt) + assertEquals(10, body.get("limit").asInt) + assertEquals(12L, body.getAsJsonArray("resources")[0].asLong) + assertFalse(MailPimJson.freeBusyBody(1, 2, emptyList(), emptyList()).has("resources")) + assertEquals(60, MailPlanLogic.duration(0, 10, allDay = true)) + assertEquals(15, MailPlanLogic.duration(0, 60_000, allDay = false)) + } + + @Test + fun `resources - the list, rules, labels and chips`() { + val r = MailPimJson.resources( + o( + """{"items":[{"id":3,"name":"Firmenwagen Kombi","kind":"vehicle","location":null,"seats":0,"color":"#79b8ff","approval":"approval","approvers":["Fuhrpark",""], + "rules":{"maxMinutes":1440,"horizonDays":30,"hours":null},"timeZone":"Europe/Berlin","address":"resource-3@resources.gatecontrol.invalid", + "busy":[{"start":10,"end":20,"status":"busy","mine":true},{"start":30,"end":40,"status":"pending"}]}, + {"id":4,"name":"Beamer","kind":"thing","address":"resource-4@resources.gatecontrol.invalid"},{"id":5,"name":"ohne Adresse"}],"kinds":["room","vehicle","device","other"]}""", + ), + )!! + assertEquals(listOf(3L, 4L), r.items.map { it.id }) + val car = r.items[0] + assertTrue(car.needsApproval) + assertEquals(listOf("Fuhrpark"), car.approvers) + assertNull(car.seats) + assertEquals(1440, car.rules.maxMinutes) + assertTrue(car.busy[0].mine) + assertEquals(MBusyBlock.PENDING, car.busy[1].status) + assertEquals(MResource.OTHER, r.items[1].kind) + assertFalse(r.items[1].needsApproval) + assertEquals(listOf("room", "vehicle", "device", "other"), MailPlanLogic.kindChips(r.items)) + assertEquals(listOf("room", "vehicle", "device"), MailPlanLogic.kindChips(listOf(car)), "„Sonstiges“ only when there is one") + assertEquals("day" to 1.0, MailPlanLogic.maxLimit(1440)) + assertEquals("days" to 3.0, MailPlanLogic.maxLimit(4320)) + assertEquals("hours" to 2.5, MailPlanLogic.maxLimit(150)) + // a pending block in a resource's bar shows like tentative + assertEquals(listOf("free", "tentative", "free"), MailPlanLogic.bar(listOf(MBusyBlock(2, 4, MBusyBlock.PENDING)), 0, 6).map { it.status }) + val book = MailPimJson.bookBody(" Kunde Müller ", 100, 200, timeZone = "Europe/Berlin") + assertEquals("Kunde Müller", book.get("title").asString) + assertFalse(book.has("allDay")) + val days = MailPimJson.bookBody(null, null, null, startDate = "2026-11-06", endDate = "2026-11-07", timeZone = null) + assertTrue(days.get("allDay").asBoolean) + assertFalse(days.has("title")) + } + + @Test + fun `bookings - pills, what may be done, the refusals`() { + val list = MailPimJson.bookings( + o( + """{"items":[ + {"id":1,"resourceId":3,"resource":{"id":3,"name":"Firmenwagen","kind":"vehicle","color":"#79B8FF"},"title":"Kunde Müller","start":100,"end":200,"state":"pending", + "booker":{"userId":2,"name":"Markus B."},"violations":[],"conflicts":[],"canDecide":true,"day":[{"start":0,"end":50,"status":"busy"}]}, + {"id":2,"resourceId":3,"resource":{"name":"Firmenwagen","kind":"vehicle"},"title":"Messe","start":100,"end":200,"allDay":true,"startDate":"2026-11-06","endDate":"2026-11-07", + "state":"pending","violations":[{"rule":"max_duration","limit":1440},{"rule":"bogus"}],"conflicts":[],"canDecide":true}, + {"id":3,"resourceId":3,"start":100,"end":200,"state":"pending","violations":[],"conflicts":[{"start":120,"end":150}],"canDecide":true}, + {"id":4,"resourceId":3,"start":100,"end":200,"state":"confirmed","decidedBy":{"userId":5,"name":"Fuhrpark"},"decidedAt":99,"override":true,"comment":"ok","eventId":7}, + {"id":5,"resourceId":3,"start":100,"end":200,"state":"cancelled"},{"id":6,"start":1}],"counts":{"open":3}}""", + ), + )!! + assertEquals(3, list.open) + assertEquals(5, list.items.size) + val (ok, rules, taken, done, off) = list.items + assertEquals(MailPlanLogic.PILL_WAITING, MailPlanLogic.pill(ok)) + assertEquals(MailPlanLogic.Approve.APPROVE, MailPlanLogic.approve(ok)) + assertEquals("Markus B.", ok.bookerName) + assertEquals(MailPlanLogic.PILL_CONFLICT, MailPlanLogic.pill(rules)) + assertEquals(listOf(MViolation.MAX_DURATION), rules.violations.map { it.rule }) + assertEquals(MailPlanLogic.Approve.OVERRIDE, MailPlanLogic.approve(rules), "„Trotzdem freigeben“") + assertEquals("2026-11-07", rules.endDate) + assertEquals(MailPlanLogic.Approve.NONE, MailPlanLogic.approve(taken), "a confirmed booking in the way: no approving") + assertEquals(MailPlanLogic.PILL_CONFLICT, MailPlanLogic.pill(taken)) + assertEquals(MailPlanLogic.PILL_CONFIRMED, MailPlanLogic.pill(done)) + assertEquals("Fuhrpark", done.decidedBy) + assertTrue(done.override) + assertEquals(7L, done.eventId) + assertEquals(MailPlanLogic.Approve.NONE, MailPlanLogic.approve(done)) + assertEquals(MailPlanLogic.PILL_CANCELLED, MailPlanLogic.pill(off)) + assertTrue(MailPlanLogic.canCancel(done, 150)) + assertFalse(MailPlanLogic.canCancel(done, 200), "over") + assertFalse(MailPlanLogic.canCancel(off, 0)) + assertEquals(listOf("busy", MailPlanLogic.ASK), MailPlanLogic.bookingBlocks(ok).map { it.status }) + // refusals: the rules, who decided first + val err = o("""{"code":"rule_violation","violations":[{"rule":"hours","days":[1,2,3,4,5],"from":"08:00","to":"18:00"},{"rule":"horizon","limit":30}]}""") + assertEquals(listOf(MViolation.HOURS, MViolation.HORIZON), MailPimJson.violations(err).map { it.rule }) + assertEquals("08:00", MailPimJson.violations(o("""{"error":${err}}"""))[0].from) + assertEquals("Sabine", MailPimJson.decidedBy(o("""{"code":"already_decided","decidedBy":{"userId":4,"name":"Sabine"}}"""))) + assertNull(MailPimJson.decidedBy(o("""{"code":"already_decided","decidedBy":null}"""))) + assertEquals("approve", MailPimJson.decideBody(true, override = true).get("decision").asString) + assertTrue(MailPimJson.decideBody(true, override = true).get("override").asBoolean) + assertFalse(MailPimJson.decideBody(false).has("override")) + // a saved event with its bookings + val saved = MailPimJson.eventSaved(o("""{"event":{"eventId":7,"start":1,"end":2},"sent":{"mail":0},"bookings":[{"id":9,"resourceId":3,"start":1,"end":2,"state":"confirmed"}]}"""))!! + assertEquals(listOf(9L), saved.bookings.map { it.id }) + assertTrue(MailPimJson.eventSaved(o("""{"event":{"eventId":7,"start":1,"end":2}}"""))!!.bookings.isEmpty()) + } + + @Test + fun `sharing - shares, the people to share with, shared with me, bodies`() { + val s = MailPimJson.shares( + o( + """{"items":[{"id":1,"type":"user","principalId":4,"name":"Sabine K.","level":"write","state":"accepted","members":null}, + {"id":2,"type":"group","principalId":7,"name":"Familie","level":"read","state":"group","members":4},{"id":0}],"freeBusy":true}""", + ), + )!! + assertEquals(listOf(1L, 2L), s.items.map { it.id }) + assertEquals(MShare.STATE_ACCEPTED, s.items[0].state) + assertEquals(4, s.items[1].members) + assertEquals(true, s.freeBusy) + val p = MailPimJson.principals( + o("""{"items":[{"type":"user","id":4,"name":"Sabine K.","initials":"SK","color":"#79B8FF"},{"type":"group","id":7,"name":"Familie","sub":"Haus/Familie","members":4},{"type":"device","id":1,"name":"x"}]}"""), + )!! + assertEquals(listOf(MShare.USER, MShare.GROUP), p.map { it.type }) + assertEquals("Haus/Familie", p[1].sub) + val mine = MailPimJson.sharedList( + o( + """{"items":[{"id":9,"name":"Urlaube","color":"#8C96A5","owner":{"userId":4,"name":"Sabine"},"level":"read","via":"user","visible":false,"accepted":false}], + "accounts":[{"calendarId":12,"accountId":3,"name":"Team","color":"#79B8FF","owner":{"email":"chef@firma.de"},"writable":true,"visible":true}]}""", + ), + )!! + assertEquals("Urlaube", mine.items.single().name) + assertEquals("chef@firma.de", mine.accounts.single().ownerEmail) + assertTrue(mine.accounts.single().writable) + val body = MailPimJson.shareBody("group", 7, "bogus") + assertEquals("group", body.get("type").asString) + assertEquals(MShare.READ, body.get("level").asString) + assertEquals("#AABBCC", MailPimJson.sharedPatch(color = "#aabbcc").get("color").asString) + assertFalse(MailPimJson.sharedPatch(color = "red").has("color")) + assertTrue(MailPimJson.calSettingsBody(freeBusy = false).has("freeBusy")) + assertFalse(MailPimJson.calSettingsBody(view = "day").has("freeBusy")) + } + + @Test + fun `live - shares, changes in shared calendars, resources`() { + val sh = MailPimJson.calChanged(JsonParser.parseString("""{"kind":"shares","calendarId":9}"""))!! + assertEquals(MCalChanged.SHARES, sh.kind) + assertTrue(MailPimJson.calChanged(JsonParser.parseString("""{"kind":"changed","calendarId":9,"accountId":null,"shared":true}"""))!!.shared) + val r = MailPimJson.resChanged(JsonParser.parseString("""{"resources":{"kind":"request","bookingId":4,"resourceId":3,"state":"pending"}}"""))!! + assertEquals(MResChanged.REQUEST, r.kind) + assertEquals(4L, r.bookingId) + assertEquals(MResChanged.RESOURCES, MailPimJson.resChanged(JsonParser.parseString("""{"kind":"resources"}"""))!!.kind) + assertNull(MailPimJson.resChanged(JsonParser.parseString("""{"kind":"other"}"""))) + assertNull(MailPimJson.resChanged(null)) + } + + private fun msg(topic: String, data: String?) = PushMessage( + seq = 1, id = 1, eventId = "e", topic = topic, priority = "normal", title = "Buchungsanfrage: Firmenwagen", body = "Markus B. · Kunde Müller", + createdAt = null, expiresAt = null, collapseKey = null, silent = false, route = null, actions = emptyList(), dataJson = data, + ) + + @Test + fun `pushes - a request opens the requests at its card, an answer the event, a share shared with me`() { + val req = MailPush.destination(msg(MailPush.TOPIC_RES_REQUEST, """{"resources":{"bookingId":4,"resourceId":3}}"""))!! + assertEquals("request:4", req) + assertEquals("open" to 4L, MailPush.bookingsOf(req)) + assertEquals("open" to null, MailPush.bookingsOf(MailPush.destination(msg(MailPush.TOPIC_RES_REQUEST, null))!!)) + val answer = MailPush.destination(msg(MailPush.TOPIC_RES_BOOKING, """{"resources":{"bookingId":4,"resourceId":3},"calendar":{"eventId":7,"occurrence":null}}"""))!! + assertEquals(7L to null, MailPush.eventOf(answer)) + val gone = MailPush.destination(msg(MailPush.TOPIC_RES_BOOKING, """{"resources":{"bookingId":4,"resourceId":3}}"""))!! + assertEquals("mine" to 4L, MailPush.bookingsOf(gone)) + val share = MailPush.destination(msg(MailPush.TOPIC_SHARE, """{"calendar":{"sharedCalendarId":9}}"""))!! + assertTrue(MailPush.isShared(share)) + assertNull(MailPush.bookingsOf(share)) + assertNull(MailPush.eventOf(share)) + assertTrue(MailPush.isCalendarTopic(MailPush.TOPIC_SHARE)) + assertTrue(MailPush.isCalendarTopic(MailPush.TOPIC_RES_REQUEST)) + assertTrue(MailPush.isCalendarTopic(MailPush.TOPIC_RES_BOOKING)) + assertNotNull(MailPush.destination(msg(MailPush.TOPIC_SHARE, null))) + } +}