mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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
This commit is contained in:
@@ -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<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
|
||||
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.NewCalendarEvent> { NewCalendarEventScreen(nav, accountViewModel) }
|
||||
composableFromBottomArgs<Route.NewCalendarCollection> { NewCalendarCollectionScreen(nav, accountViewModel) }
|
||||
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
|
||||
|
||||
+191
@@ -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)
|
||||
}
|
||||
+129
@@ -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<String?>(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+199
@@ -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),
|
||||
)
|
||||
}
|
||||
+123
@@ -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))
|
||||
Reference in New Issue
Block a user