From 809f21252c81303cff1e978cb22db3cdc3d0fb75 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 03:46:38 +0000 Subject: [PATCH] feat(calendars): add create flows for events and collections Adds NewCalendarEventScreen (Material3 form with all-day toggle, DatePicker/TimePicker chain, location, summary, image, hashtags) and NewCalendarCollectionScreen (title + description). Both publish via the standard signAndComputeBroadcast pipeline and are wired into AppNavigation as bottom-up routes. https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U --- .../amethyst/ui/navigation/AppNavigation.kt | 4 + .../create/CalendarDateTimePickerButton.kt | 191 +++++++++++++++++ .../create/NewCalendarCollectionScreen.kt | 129 ++++++++++++ .../create/NewCalendarEventScreen.kt | 199 ++++++++++++++++++ .../create/NewCalendarEventViewModel.kt | 123 +++++++++++ 5 files changed, 646 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/CalendarDateTimePickerButton.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index cdb5f65a3b..4b789ce180 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -74,6 +74,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipMa import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarCollectionScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarEventScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.CreateGroupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.EditGroupInfoScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupChatScreen @@ -254,6 +256,8 @@ fun BuildNavigation( composableFromBottomArgs { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } composableFromEnd { PicturesScreen(accountViewModel, nav) } composableFromEnd { CalendarsScreen(accountViewModel, nav) } + composableFromBottomArgs { NewCalendarEventScreen(nav, accountViewModel) } + composableFromBottomArgs { NewCalendarCollectionScreen(nav, accountViewModel) } composableFromEnd { ProductsScreen(accountViewModel, nav) } composableFromEnd { ShortsScreen(accountViewModel, nav) } composableFromEnd { PublicChatsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/CalendarDateTimePickerButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/CalendarDateTimePickerButton.kt new file mode 100644 index 0000000000..bc4955c227 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/CalendarDateTimePickerButton.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.TimePickerDialog +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset +import java.util.Date +import java.util.Locale + +/** + * Tap-to-edit button that opens a Material3 DatePicker (and, when [includeTime] is true, + * chains into a TimePicker). The resolved instant is converted to UTC epoch seconds using + * the device's zone offset *at the picked moment*, so DST transitions are handled correctly. + * + * Pass `0L` for [unixSeconds] when the user hasn't picked anything yet — the button shows + * [placeholder] instead. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CalendarDateTimePickerButton( + unixSeconds: Long, + placeholder: String, + includeTime: Boolean, + onChange: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + var showDate by remember { mutableStateOf(false) } + var showTime by remember { mutableStateOf(false) } + + val pretty = + if (unixSeconds <= 0L) { + placeholder + } else if (includeTime) { + DateFormat + .getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) + .format(Date(unixSeconds * 1000)) + } else { + SimpleDateFormat("EEEE, MMMM d, yyyy", Locale.getDefault()).format(Date(unixSeconds * 1000)) + } + + val initialMillis = if (unixSeconds > 0L) unixSeconds * 1000L else System.currentTimeMillis() + val initialLocal = + Instant + .ofEpochMilli(initialMillis) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() + + val datePickerState = + rememberDatePickerState( + initialSelectedDateMillis = initialMillis, + ) + val timePickerState = + rememberTimePickerState( + initialHour = initialLocal.hour, + initialMinute = initialLocal.minute, + is24Hour = false, + ) + + fun reset() { + datePickerState.selectedDateMillis = initialMillis + timePickerState.hour = initialLocal.hour + timePickerState.minute = initialLocal.minute + } + + OutlinedButton( + onClick = { showDate = true }, + modifier = modifier.fillMaxWidth(), + ) { + Text(pretty) + } + + if (showDate) { + DatePickerDialog( + onDismissRequest = { + reset() + showDate = false + }, + confirmButton = { + TextButton(onClick = { + showDate = false + if (includeTime) { + showTime = true + } else { + commit(datePickerState.selectedDateMillis, includeTime = false, hour = 0, minute = 0, onChange = onChange) + } + }) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = { + reset() + showDate = false + }) { Text("Cancel") } + }, + ) { + DatePicker(state = datePickerState) + } + } + + if (showTime) { + TimePickerDialog( + title = { Text("Pick time") }, + onDismissRequest = { + reset() + showTime = false + }, + confirmButton = { + TextButton(onClick = { + commit( + dayMillisUtc = datePickerState.selectedDateMillis, + includeTime = true, + hour = timePickerState.hour, + minute = timePickerState.minute, + onChange = onChange, + ) + showTime = false + }) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = { + reset() + showTime = false + }) { Text("Cancel") } + }, + ) { + TimePicker(state = timePickerState) + } + } +} + +private fun commit( + dayMillisUtc: Long?, + includeTime: Boolean, + hour: Int, + minute: Int, + onChange: (Long) -> Unit, +) { + if (dayMillisUtc == null) return + val zone = ZoneId.systemDefault() + val localDate = + Instant + .ofEpochMilli(dayMillisUtc) + .atZone(ZoneOffset.UTC) + .toLocalDate() + val picked = + if (includeTime) { + localDate.atTime(hour, minute).atZone(zone).toEpochSecond() + } else { + // For date-only events, anchor to midnight in the user's local zone so the day + // boundary matches their wall-clock intent. + localDate.atStartOfDay(zone).toEpochSecond() + } + onChange(picked) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionScreen.kt new file mode 100644 index 0000000000..84b19c599d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionScreen.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewCalendarCollectionScreen( + nav: INav, + accountViewModel: AccountViewModel, +) { + var title by rememberSaveable { mutableStateOf("") } + var description by rememberSaveable { mutableStateOf("") } + var errorMessage by rememberSaveable { mutableStateOf(null) } + + Scaffold( + topBar = { + SavingTopBar( + titleRes = R.string.new_calendar_collection, + onCancel = { nav.popBack() }, + onPost = { + if (title.isBlank()) { + errorMessage = "title-required" + return@SavingTopBar + } + accountViewModel.launchSigner { + accountViewModel.account.signAndComputeBroadcast( + CalendarEvent.build( + title = title.trim(), + content = description.trim(), + ), + ) + nav.popBack() + } + }, + ) + }, + ) { pad -> + Column( + modifier = + Modifier + .padding( + start = 16.dp, + end = 16.dp, + top = pad.calculateTopPadding(), + bottom = pad.calculateBottomPadding(), + ).consumeWindowInsets(pad) + .imePadding() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = title, + onValueChange = { + title = it + errorMessage = null + }, + label = { Text(stringRes(R.string.calendar_collection_title)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + isError = errorMessage == "title-required", + ) + + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text(stringRes(R.string.calendar_collection_description)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 5, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + ) + + if (errorMessage != null) { + Text( + text = stringRes(R.string.calendar_collection_invalid), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventScreen.kt new file mode 100644 index 0000000000..4286f7b244 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventScreen.kt @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewCalendarEventScreen( + nav: INav, + accountViewModel: AccountViewModel, +) { + val vm: NewCalendarEventViewModel = viewModel() + vm.init(accountViewModel) + + Scaffold( + topBar = { + SavingTopBar( + titleRes = R.string.new_calendar_event, + onCancel = { nav.popBack() }, + onPost = { + accountViewModel.launchSigner { + if (vm.publish()) { + nav.popBack() + } + } + }, + ) + }, + ) { pad -> + Column( + modifier = + Modifier + .padding( + start = 16.dp, + end = 16.dp, + top = pad.calculateTopPadding(), + bottom = pad.calculateBottomPadding(), + ).consumeWindowInsets(pad) + .imePadding() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + AllDayToggleRow(vm) + + OutlinedTextField( + value = vm.title.value, + onValueChange = { vm.title.value = it }, + label = { Text(stringRes(R.string.calendar_event_title)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + ) + + val isAllDay by vm.isAllDay + FieldLabel(stringRes(R.string.calendar_event_start)) + CalendarDateTimePickerButton( + unixSeconds = vm.startSeconds.value, + placeholder = stringRes(R.string.calendar_event_pick_date), + includeTime = !isAllDay, + onChange = { vm.startSeconds.value = it }, + ) + + FieldLabel(stringRes(R.string.calendar_event_end)) + CalendarDateTimePickerButton( + unixSeconds = vm.endSeconds.value, + placeholder = stringRes(R.string.calendar_event_pick_date), + includeTime = !isAllDay, + onChange = { vm.endSeconds.value = it }, + ) + + OutlinedTextField( + value = vm.location.value, + onValueChange = { vm.location.value = it }, + label = { Text(stringRes(R.string.calendar_event_location)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + OutlinedTextField( + value = vm.summary.value, + onValueChange = { vm.summary.value = it }, + label = { Text(stringRes(R.string.calendar_event_summary)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + ) + + OutlinedTextField( + value = vm.imageUrl.value, + onValueChange = { vm.imageUrl.value = it }, + label = { Text(stringRes(R.string.calendar_event_image)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + OutlinedTextField( + value = vm.hashtags.value, + onValueChange = { vm.hashtags.value = it }, + label = { Text(stringRes(R.string.calendar_event_hashtags)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + if (!vm.isValid()) { + Text( + text = stringRes(R.string.calendar_event_invalid), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } else if (!vm.isEndAfterStart()) { + Text( + text = stringRes(R.string.calendar_event_end_before_start), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +@Composable +private fun AllDayToggleRow(vm: NewCalendarEventViewModel) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(R.string.calendar_event_all_day), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + Switch( + checked = vm.isAllDay.value, + onCheckedChange = { vm.isAllDay.value = it }, + ) + } +} + +@Composable +private fun FieldLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 4.dp), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventViewModel.kt new file mode 100644 index 0000000000..6853e65128 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarEventViewModel.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create + +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import java.text.SimpleDateFormat +import java.time.ZoneId +import java.util.Locale +import java.util.TimeZone +import com.vitorpamplona.quartz.nip52Calendar.appt.day.image as dayImage +import com.vitorpamplona.quartz.nip52Calendar.appt.day.locations as dayLocations +import com.vitorpamplona.quartz.nip52Calendar.appt.day.summary as daySummary +import com.vitorpamplona.quartz.nip52Calendar.appt.time.image as timeImage +import com.vitorpamplona.quartz.nip52Calendar.appt.time.locations as timeLocations +import com.vitorpamplona.quartz.nip52Calendar.appt.time.summary as timeSummary + +class NewCalendarEventViewModel : ViewModel() { + private lateinit var account: Account + + val isAllDay = mutableStateOf(false) + val title = mutableStateOf("") + val summary = mutableStateOf("") + val location = mutableStateOf("") + val imageUrl = mutableStateOf("") + val hashtags = mutableStateOf("") // comma-separated + + /** Start instant in epoch seconds. 0 means unset; the create screen guards against publishing without a real value. */ + val startSeconds = mutableStateOf(0L) + val endSeconds = mutableStateOf(0L) + + val isPublishing = mutableStateOf(false) + + fun init(accountViewModel: AccountViewModel) { + this.account = accountViewModel.account + } + + fun isValid(): Boolean = title.value.isNotBlank() && startSeconds.value > 0L + + fun isEndAfterStart(): Boolean = endSeconds.value == 0L || endSeconds.value >= startSeconds.value + + suspend fun publish(): Boolean { + if (!isValid() || !isEndAfterStart()) return false + isPublishing.value = true + try { + val parsedHashtags = + hashtags.value + .split(',', '\n', ' ') + .map { it.trim().trimStart('#') } + .filter { it.isNotBlank() } + val parsedSummary = summary.value.trim().takeIf { it.isNotBlank() } + val parsedImage = imageUrl.value.trim().takeIf { it.isNotBlank() } + val parsedLocation = location.value.trim().takeIf { it.isNotBlank() } + val tzId = TimeZone.getDefault().id + + if (isAllDay.value) { + account.signAndComputeBroadcast( + CalendarDateSlotEvent.build( + title = title.value.trim(), + start = toIsoDate(startSeconds.value), + end = endSeconds.value.takeIf { it > 0L }?.let { toIsoDate(it) }, + content = parsedSummary.orEmpty(), + ) { + parsedSummary?.let { daySummary(it) } + parsedImage?.let { dayImage(it) } + parsedLocation?.let { dayLocations(listOf(it)) } + if (parsedHashtags.isNotEmpty()) hashtags(parsedHashtags) + }, + ) + } else { + account.signAndComputeBroadcast( + CalendarTimeSlotEvent.build( + title = title.value.trim(), + start = startSeconds.value, + end = endSeconds.value.takeIf { it > 0L }, + startTzId = tzId, + endTzId = tzId, + content = parsedSummary.orEmpty(), + ) { + parsedSummary?.let { timeSummary(it) } + parsedImage?.let { timeImage(it) } + parsedLocation?.let { timeLocations(listOf(it)) } + if (parsedHashtags.isNotEmpty()) hashtags(parsedHashtags) + }, + ) + } + return true + } finally { + isPublishing.value = false + } + } +} + +private val IsoFormat = + SimpleDateFormat("yyyy-MM-dd", Locale.US).apply { + // 31922 uses calendar-date strings; format the user's local date. + timeZone = TimeZone.getTimeZone(ZoneId.systemDefault()) + } + +private fun toIsoDate(epochSeconds: Long): String = IsoFormat.format(java.util.Date(epochSeconds * 1000))