refactor(calendars): extract DAL to commons + a11y + inline-FQN cleanup

DAL extraction (commons/src/jvmAndroid/.../model/nip52Calendar/):
- CalendarSortKeys, CalendarAppointmentView, MonthGridBars, IcsExport now
  live in commons so desktop and the future CLI can consume them. Package
  changed to com.vitorpamplona.amethyst.commons.model.nip52Calendar.
- IcsExportTest moved to commons/jvmTest so it can see the `internal`
  escapeText helper without exposing it as public API.
- Feed-filter classes (CalendarAppointmentsFeedFilter,
  CalendarCollectionsFeedFilter) stay in amethyst — they depend on Account
  and LocalCache. The ViewModels stay for the same reason; lifting them
  requires moving Account/LocalCache too, out of scope here.
- A few smart-cast call sites needed local bindings because cross-module
  properties don't support implicit smart-cast.

Accessibility:
- Each month-grid cell announces a full content description ("Wednesday,
  January 15, 2025, 2 events, today, selected") via mergeDescendants so
  TalkBack reads the cell as one item with role=Button.
- Week-strip cells get the same treatment with role=Tab.
- Header title now announces both the title and "jump to today" so the
  affordance is discoverable.
- The expanding FAB describes itself as a toggle, and each sub-FAB as
  the concrete create action.

Inline-FQN cleanup pass:
- CalendarEventDetailScreen (already in a prior pass), CalendarCollectionsView,
  NewCalendarEventScreen, NewCalendarCollectionScreen: hoisted
  fully-qualified androidx.compose / com.vitorpamplona references into
  proper imports per the codebase style.

All 56 calendar tests still pass (48 in amethyst + 8 IcsExport in commons).
This commit is contained in:
Claude
2026-05-19 20:11:43 +00:00
parent ab5d884b34
commit 5116bc21ae
26 changed files with 187 additions and 113 deletions
@@ -27,8 +27,8 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.appointmentView
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
@@ -24,7 +24,7 @@ import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.provider.CalendarContract
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.parseIsoDateToUnixSeconds
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.parseIsoDateToUnixSeconds
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
@@ -34,6 +34,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -41,6 +42,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -48,15 +50,22 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.IcsExport
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@Composable
fun CalendarCollectionsView(
@@ -118,7 +127,7 @@ fun CalendarCollectionCard(
val title = remember(note.idHex) { event.title() }
val description = remember(note.idHex) { event.content.take(180) }
val count = remember(note.idHex) { event.calendarEventAddresses().size }
val context = androidx.compose.ui.platform.LocalContext.current
val context = LocalContext.current
Card(
modifier =
@@ -131,7 +140,7 @@ fun CalendarCollectionCard(
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
) {
// Author header matches every other social card in the app.
com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader(
UserCardHeader(
baseNote = note,
accountViewModel = accountViewModel,
nav = nav,
@@ -172,19 +181,10 @@ fun CalendarCollectionCard(
color = MaterialTheme.colorScheme.primary,
)
}
androidx.compose.material3.IconButton(onClick = {
IconButton(onClick = {
val members = collectMembers(event)
val ics =
com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.IcsExport
.calendarToIcs(
event,
members,
com.vitorpamplona.quartz.utils.TimeUtils
.now(),
)
val filename =
com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.IcsExport
.calendarFilename(event)
val ics = IcsExport.calendarToIcs(event, members, TimeUtils.now())
val filename = IcsExport.calendarFilename(event)
shareIcs(context, filename, ics)
}) {
Icon(
@@ -201,19 +201,14 @@ fun CalendarCollectionCard(
/**
* Resolves a calendar's member addresses to their cached events, skipping members that haven't
* arrived from relays yet or aren't appointments. Returns a list compatible with
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.IcsExport.calendarToIcs].
* [IcsExport.calendarToIcs].
*/
private fun collectMembers(calendar: CalendarEvent): List<Pair<com.vitorpamplona.quartz.nip01Core.core.Address, Any>> =
private fun collectMembers(calendar: CalendarEvent): List<Pair<Address, Any>> =
calendar
.calendarEventAddresses()
.mapNotNull { addr ->
val cachedEvent =
com.vitorpamplona.amethyst.model.LocalCache.addressables
.get(addr)
?.event
if (cachedEvent is com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent ||
cachedEvent is com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
) {
val cachedEvent = LocalCache.addressables.get(addr)?.event
if (cachedEvent is CalendarTimeSlotEvent || cachedEvent is CalendarDateSlotEvent) {
addr to cachedEvent
} else {
null
@@ -51,15 +51,15 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarLocalDayKeyRange
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpanded
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.appointmentView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.calendarLocalDayKeyRange
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKeyExpanded
import com.vitorpamplona.amethyst.ui.stringRes
import java.time.LocalDate
import java.time.ZoneId
@@ -164,15 +164,16 @@ private fun DayRow(
null
}
val startSeconds = view.startSeconds
val timeLabel =
when {
view.isAllDay -> stringRes(R.string.calendar_all_day)
view.startSeconds != null && visibleEpochDay > (range?.first ?: visibleEpochDay) ->
startSeconds != null && visibleEpochDay > (range?.first ?: visibleEpochDay) ->
// Continuation day of a multi-day timed event — the "9:00 AM" of day 1 is
// misleading on day 2 since the event has been ongoing overnight. Show a
// continuation marker so the user reads it as "still happening".
stringRes(R.string.calendar_continues)
view.startSeconds != null -> formatTimeOfDay(view.startSeconds)
startSeconds != null -> formatTimeOfDay(startSeconds)
else -> ""
}
@@ -46,12 +46,12 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.appointmentView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader
import com.vitorpamplona.quartz.utils.TimeUtils
import java.time.Instant
@@ -155,10 +155,12 @@ fun CalendarEventListCard(
)
}
}
if (!view.image.isNullOrBlank()) {
val image = view.image
val summary = view.summary
if (!image.isNullOrBlank()) {
Spacer(modifier = Modifier.size(4.dp))
MyAsyncImage(
imageUrl = view.image,
imageUrl = image,
contentDescription = view.title,
contentScale = ContentScale.Crop,
mainImageModifier = Modifier.fillMaxWidth().height(120.dp),
@@ -168,9 +170,9 @@ fun CalendarEventListCard(
onError = { Box(modifier = Modifier.fillMaxWidth().height(120.dp)) },
)
}
if (!view.summary.isNullOrBlank() && view.image.isNullOrBlank()) {
if (!summary.isNullOrBlank() && image.isNullOrBlank()) {
Text(
text = view.summary,
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
@@ -38,6 +38,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarEndSeconds
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarStartSeconds
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.Note
@@ -45,8 +47,6 @@ import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.calendarEndSeconds
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.calendarStartSeconds
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -46,22 +46,27 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.MONTH_GRID_MAX_LANES
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.MonthGridBarSegment
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.computeMonthGridBars
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpanded
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.MONTH_GRID_MAX_LANES
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.MonthGridBarSegment
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.computeMonthGridBars
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKeyExpanded
import com.vitorpamplona.amethyst.ui.stringRes
import java.time.LocalDate
import java.time.YearMonth
import java.time.ZoneId
@Composable
fun CalendarMonthView(
@@ -197,17 +202,24 @@ private fun MonthGrid(
val date = visibleMonth.atDay(dayNumber)
val dayKey = date.toEpochDay()
val cellBars = barsByDay[dayKey].orEmpty()
val isToday = isCurrentMonth && date == today
val isSelected = selectedDayKey == dayKey
DayCell(
modifier = Modifier.weight(1f),
dayNumber = dayNumber,
isToday = isCurrentMonth && date == today,
isSelected = selectedDayKey == dayKey,
isToday = isToday,
isSelected = isSelected,
bars = cellBars,
// Anything past the visible lane cap collapses into a "+N" tail —
// keeps each cell readable when a day has more than three events.
extraEventCount = cellBars.count { it.lane >= MONTH_GRID_MAX_LANES },
isWeekStart = c == 0,
isWeekEnd = c == 6,
// Full date label fed to the screen-reader content description so
// TalkBack reads "Wednesday January 15 2025, 2 events" instead of
// just "15".
dateLabel = formatLongDate(date.atStartOfDay(ZoneId.systemDefault()).toEpochSecond()),
totalEventCount = cellBars.size,
onClick = { onDayClick(dayKey) },
)
} else {
@@ -231,6 +243,8 @@ private fun DayCell(
extraEventCount: Int,
isWeekStart: Boolean,
isWeekEnd: Boolean,
dateLabel: String,
totalEventCount: Int,
onClick: () -> Unit,
) {
val bg =
@@ -240,6 +254,17 @@ private fun DayCell(
MaterialTheme.colorScheme.surface
}
val baseDescription =
pluralStringResource(R.plurals.calendar_day_a11y_events, totalEventCount, dateLabel, totalEventCount)
val todaySuffix = stringRes(R.string.calendar_day_a11y_today_suffix)
val selectedSuffix = stringRes(R.string.calendar_day_a11y_selected_suffix)
val a11y =
buildString {
append(baseDescription)
if (isToday) append(", ").append(todaySuffix)
if (isSelected) append(", ").append(selectedSuffix)
}
Box(
modifier =
modifier
@@ -253,7 +278,8 @@ private fun DayCell(
width = if (isToday) 1.5.dp else 0.5.dp,
color = if (isToday) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
shape = RoundedCornerShape(8.dp),
).clickable(onClick = onClick),
).clickable(role = Role.Button, onClick = onClick)
.semantics(mergeDescendants = true) { contentDescription = a11y },
) {
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 2.dp, vertical = 4.dp),
@@ -31,11 +31,16 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Shared `[◀] title [▶]` header used by month / week / day view bodies. Tapping the title
@@ -62,10 +67,18 @@ fun CalendarNavigationHeader(
tint = MaterialTheme.colorScheme.onSurface,
)
}
// Title doubles as the "jump to today" affordance. Adding a TalkBack-only contentDescription
// makes that role discoverable for screen-reader users — the bare text alone reads as a
// label, not an action.
val jumpToToday = stringRes(R.string.calendar_nav_jump_to_today)
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f).clickable(onClick = onToday),
modifier =
Modifier
.weight(1f)
.clickable(role = Role.Button, onClickLabel = jumpToToday, onClick = onToday)
.semantics { contentDescription = "$title, $jumpToToday" },
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
)
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars
import android.content.Context
import android.text.format.DateUtils
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.CalendarAppointmentView
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.CalendarAppointmentView
/**
* Localised "starts in 2 hours" / "started 5 minutes ago" / "Happening now · ends in 2 hours"
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.appointmentView
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
@@ -44,17 +44,21 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
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.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpanded
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKeyExpanded
import com.vitorpamplona.amethyst.ui.stringRes
import java.time.LocalDate
import java.time.ZoneId
@@ -182,6 +186,17 @@ private fun WeekStrip(
else -> MaterialTheme.colorScheme.onSurface
}
val dateLabel = formatLongDate(date.atStartOfDay(ZoneId.systemDefault()).toEpochSecond())
val baseA11y = pluralStringResource(R.plurals.calendar_day_a11y_events, count, dateLabel, count)
val todaySuffix = stringRes(R.string.calendar_day_a11y_today_suffix)
val selectedSuffix = stringRes(R.string.calendar_day_a11y_selected_suffix)
val a11y =
buildString {
append(baseA11y)
if (isToday) append(", ").append(todaySuffix)
if (isSelected) append(", ").append(selectedSuffix)
}
Column(
modifier =
Modifier
@@ -192,8 +207,9 @@ private fun WeekStrip(
width = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant,
shape = RoundedCornerShape(10.dp),
).clickable { onSelect(i) }
.padding(vertical = 6.dp),
).clickable(role = Role.Tab) { onSelect(i) }
.padding(vertical = 6.dp)
.semantics(mergeDescendants = true) { contentDescription = a11y },
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
@@ -70,7 +70,9 @@ fun NewCalendarButton(nav: INav) {
) {
Icon(
symbol = MaterialSymbols.CalendarMonth,
contentDescription = stringRes(R.string.new_calendar_collection),
// Spell out the full intent for screen readers — the bare "New collection"
// string is ambiguous out of context.
contentDescription = stringRes(R.string.calendar_fab_new_collection),
modifier = Size26Modifier,
tint = Color.White,
)
@@ -89,7 +91,7 @@ fun NewCalendarButton(nav: INav) {
) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(R.string.new_calendar_event),
contentDescription = stringRes(R.string.calendar_fab_new_event),
modifier = Size26Modifier,
tint = Color.White,
)
@@ -112,7 +114,7 @@ fun NewCalendarButton(nav: INav) {
) {
Icon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(R.string.new_calendar_event),
contentDescription = stringRes(R.string.calendar_fab_toggle),
modifier = Size26Modifier,
tint = Color.White,
)
@@ -125,7 +127,8 @@ fun NewCalendarButton(nav: INav) {
) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(R.string.new_calendar_event),
// Top FAB toggles the sub-FABs in/out — describe that, not the sub-action.
contentDescription = stringRes(R.string.calendar_fab_toggle),
modifier = Size26Modifier,
tint = Color.White,
)
@@ -31,13 +31,17 @@ 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.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -140,24 +144,21 @@ private fun DeleteCalendarRow(
) {
var confirming by rememberSaveable { mutableStateOf(false) }
androidx.compose.material3.OutlinedButton(
OutlinedButton(
onClick = { confirming = true },
modifier = Modifier.fillMaxWidth(),
colors =
androidx.compose.material3.ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(text = stringRes(R.string.calendar_collection_delete))
}
if (confirming) {
androidx.compose.material3.AlertDialog(
AlertDialog(
onDismissRequest = { confirming = false },
title = { Text(stringRes(R.string.calendar_collection_delete_confirm_title)) },
text = { Text(stringRes(R.string.calendar_collection_delete_confirm_message)) },
confirmButton = {
androidx.compose.material3.TextButton(onClick = {
TextButton(onClick = {
confirming = false
accountViewModel.launchSigner {
if (vm.deleteLoaded()) onDeleted()
@@ -170,7 +171,7 @@ private fun DeleteCalendarRow(
}
},
dismissButton = {
androidx.compose.material3.TextButton(onClick = { confirming = false }) {
TextButton(onClick = { confirming = false }) {
Text(stringRes(R.string.cancel))
}
},
@@ -33,26 +33,41 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
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.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size30dp
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@@ -227,8 +242,8 @@ private fun ImageRow(
vm: NewCalendarEventViewModel,
accountViewModel: AccountViewModel,
) {
val context = androidx.compose.ui.platform.LocalContext.current
val scope = androidx.compose.runtime.rememberCoroutineScope()
val context = LocalContext.current
val scope = rememberCoroutineScope()
val launcher =
androidx.activity.compose.rememberLauncherForActivityResult(
contract =
@@ -241,8 +256,8 @@ private fun ImageRow(
val ok = vm.uploadAndSetImage(uri, mime, context)
if (!ok) {
accountViewModel.toastManager.toast(
com.vitorpamplona.amethyst.R.string.calendar_event_image_upload_failed,
com.vitorpamplona.amethyst.R.string.calendar_event_image_upload_failed_body,
R.string.calendar_event_image_upload_failed,
R.string.calendar_event_image_upload_failed_body,
)
}
}
@@ -258,14 +273,14 @@ private fun ImageRow(
enabled = !vm.isUploadingImage.value,
)
if (vm.isUploadingImage.value) {
androidx.compose.material3.CircularProgressIndicator(
CircularProgressIndicator(
modifier = Modifier.padding(start = 8.dp).size(20.dp),
strokeWidth = 2.dp,
)
} else {
androidx.compose.material3.IconButton(onClick = { launcher.launch("image/*") }) {
com.vitorpamplona.amethyst.commons.icons.symbols.Icon(
symbol = com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols.AddPhotoAlternate,
IconButton(onClick = { launcher.launch("image/*") }) {
Icon(
symbol = MaterialSymbols.AddPhotoAlternate,
contentDescription = stringRes(R.string.calendar_event_pick_image),
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary,
@@ -289,10 +304,8 @@ private fun ParticipantsRow(
vm: NewCalendarEventViewModel,
accountViewModel: AccountViewModel,
) {
var draft by androidx.compose.runtime.saveable
.rememberSaveable { androidx.compose.runtime.mutableStateOf("") }
var error by androidx.compose.runtime.saveable
.rememberSaveable { androidx.compose.runtime.mutableStateOf<String?>(null) }
var draft by rememberSaveable { mutableStateOf("") }
var error by rememberSaveable { mutableStateOf<String?>(null) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
FieldLabel(stringRes(R.string.calendar_event_participants_section, vm.participants.size))
@@ -308,7 +321,7 @@ private fun ParticipantsRow(
singleLine = true,
isError = error != null,
)
androidx.compose.material3.TextButton(
TextButton(
onClick = {
val resolved = resolvePubKeyOrNull(draft.trim())
if (resolved == null) {
@@ -320,7 +333,7 @@ private fun ParticipantsRow(
},
enabled = draft.isNotBlank(),
) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.add))
Text(stringRes(R.string.add))
}
}
if (error != null) {
@@ -332,17 +345,14 @@ private fun ParticipantsRow(
}
vm.participants.forEach { pubKey ->
Row(verticalAlignment = Alignment.CenterVertically) {
com.vitorpamplona.amethyst.ui.note.ClickableUserPicture(
ClickableUserPicture(
baseUserHex = pubKey,
size = com.vitorpamplona.amethyst.ui.theme.Size30dp,
size = Size30dp,
accountViewModel = accountViewModel,
)
com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser(
baseUserHex = pubKey,
accountViewModel = accountViewModel,
) { user ->
LoadUser(baseUserHex = pubKey, accountViewModel = accountViewModel) { user ->
if (user != null) {
com.vitorpamplona.amethyst.ui.note.UsernameDisplay(
UsernameDisplay(
baseUser = user,
weight = Modifier.weight(1f).padding(horizontal = 8.dp),
accountViewModel = accountViewModel,
@@ -355,9 +365,9 @@ private fun ParticipantsRow(
)
}
}
androidx.compose.material3.IconButton(onClick = { vm.removeParticipant(pubKey) }) {
com.vitorpamplona.amethyst.commons.icons.symbols.Icon(
symbol = com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols.Close,
IconButton(onClick = { vm.removeParticipant(pubKey) }) {
Icon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(R.string.calendar_event_participant_remove),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -378,11 +388,7 @@ private fun resolvePubKeyOrNull(input: String): String? {
}
if (input.startsWith("npub")) {
return runCatching {
(
com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
.uriToRoute(input)
?.entity as? com.vitorpamplona.quartz.nip19Bech32.entities.NPub
)?.hex
(Nip19Parser.uriToRoute(input)?.entity as? NPub)?.hex
}.getOrNull()
}
return null
@@ -24,6 +24,7 @@ import android.content.Context
import android.net.Uri
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.parseIsoDateToUnixSeconds
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
@@ -31,7 +32,6 @@ import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.parseIsoDateToUnixSeconds
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.upcomingFirstCalendarOrder
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -61,6 +61,8 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.IcsExport
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
@@ -73,8 +75,6 @@ import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.types.CalendarRsvpRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.addToPhoneCalendar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.IcsExport
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.appointmentView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatCalendarRange
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.relativeTimeLabel
+11
View File
@@ -1969,6 +1969,17 @@
<string name="calendar_continues">Continues</string>
<string name="calendar_day_of_total">Day %1$d of %2$d</string>
<string name="calendar_add_to_phone_calendar">Add to phone calendar</string>
<string name="calendar_nav_jump_to_today">Jump to today</string>
<plurals name="calendar_day_a11y_events">
<item quantity="zero">%1$s, no events</item>
<item quantity="one">%1$s, %2$d event</item>
<item quantity="other">%1$s, %2$d events</item>
</plurals>
<string name="calendar_day_a11y_today_suffix">today</string>
<string name="calendar_day_a11y_selected_suffix">selected</string>
<string name="calendar_fab_new_event">Create a new calendar event</string>
<string name="calendar_fab_new_collection">Create a new calendar collection</string>
<string name="calendar_fab_toggle">Show create options</string>
<string name="calendar_untitled">(untitled)</string>
<string name="calendar_all_day">All-day</string>
<string name="calendar_rsvp_going_prefixed">✓ Going</string>
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.calendar
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarLocalDayKeyRange
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKey
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpanded
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.calendarLocalDayKeyRange
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKey
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKeyExpanded
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.partitionUpcomingPast
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
@@ -20,13 +20,13 @@
*/
package com.vitorpamplona.amethyst.calendar
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarEndSeconds
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarLocalDayKey
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.calendarStartSeconds
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.parseIsoDateToUnixSeconds
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.upcomingFirstCalendarOrder
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.appointmentView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.calendarEndSeconds
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.calendarLocalDayKey
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.calendarStartSeconds
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.parseIsoDateToUnixSeconds
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.upcomingFirstCalendarOrder
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import org.junit.Assert.assertEquals
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.calendar
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.computeMonthGridBars
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.computeMonthGridBars
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
@@ -18,10 +18,10 @@
* 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.dal
package com.vitorpamplona.amethyst.commons.model.nip52Calendar
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
@@ -18,9 +18,9 @@
* 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.dal
package com.vitorpamplona.amethyst.commons.model.nip52Calendar
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -18,7 +18,7 @@
* 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.dal
package com.vitorpamplona.amethyst.commons.model.nip52Calendar
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
@@ -18,10 +18,10 @@
* 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.dal
package com.vitorpamplona.amethyst.commons.model.nip52Calendar
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.commons.model.Note
/**
* One bar drawn into one day cell, with `lane` controlling its vertical position so two
@@ -18,9 +18,8 @@
* 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.calendar
package com.vitorpamplona.amethyst.commons.model.nip52Calendar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.IcsExport
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent