refactor(week): polish timeline — rounded viewport, scroll persistence, week badge
All checks were successful
CI / ci (push) Successful in 11m40s
All checks were successful
CI / ci (push) Successful in 11m40s
- Rounded, permanently-soft day-column scroll viewport via two viewports sharing one scroll state (gutter + columns stay aligned); plain rectangular column cards inside - Vertical scroll position now persists across week swipes; noon-centring only runs on first entry into the week view (from month/day) - All-day strip height is hoisted + animated, shared by both swipe pages, so it slides along and resizes smoothly instead of jumping - Multi-line event time label so the end time isn't clipped in narrow columns; hour labels centred in the gutter - Calendar-week (ISO) badge in the header gutter, aligned with the date numbers; dropped the redundant "All-day" gutter label - Small breathing room between the top section and the timeline Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ package de.jeanlucmakiola.calendula.ui.week
|
|||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.animateContentSize
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.offset
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -57,7 +58,9 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
@@ -65,8 +68,8 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -94,6 +97,14 @@ private val HOUR_HEIGHT = 56.dp
|
|||||||
private val GUTTER_WIDTH = 48.dp
|
private val GUTTER_WIDTH = 48.dp
|
||||||
private val MIN_EVENT_HEIGHT = 24.dp
|
private val MIN_EVENT_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
|
|
||||||
|
/** Total all-day strip height for a week (0 when there are no all-day events). */
|
||||||
|
private fun WeekUiState.Success.allDayStripHeight(): Dp {
|
||||||
|
if (allDaySpans.isEmpty()) return 0.dp
|
||||||
|
val lanes = allDaySpans.maxOf { it.lane } + 1
|
||||||
|
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -200,6 +211,29 @@ private fun WeekContent(
|
|||||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
|
||||||
|
// Hoisted above the per-week AnimatedContent so the vertical scroll position
|
||||||
|
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on
|
||||||
|
// noon once, on first entry into the week view (i.e. when arriving from the
|
||||||
|
// month/day view), not on every swipe.
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||||
|
val maxV = scrollState.maxValue
|
||||||
|
val target = with(density) {
|
||||||
|
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
|
||||||
|
}.coerceIn(0, maxV)
|
||||||
|
scrollState.scrollTo(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||||
|
// week during a swipe, so the strip slides along but never jumps in height;
|
||||||
|
// it just springs smoothly from the old to the new size.
|
||||||
|
val targetAllDayHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp
|
||||||
|
val allDayHeight by animateDpAsState(
|
||||||
|
targetValue = targetAllDayHeight,
|
||||||
|
label = "all-day-strip-height",
|
||||||
|
)
|
||||||
|
|
||||||
// Whole-page horizontal swipe. It sits one level above the timeline's
|
// Whole-page horizontal swipe. It sits one level above the timeline's
|
||||||
// vertical scroll: a horizontal drag only crosses *this* detector's slop,
|
// vertical scroll: a horizontal drag only crosses *this* detector's slop,
|
||||||
// while a vertical drag is consumed by the inner scroll first — so the two
|
// while a vertical drag is consumed by the inner scroll first — so the two
|
||||||
@@ -235,27 +269,36 @@ private fun WeekContent(
|
|||||||
when (s) {
|
when (s) {
|
||||||
WeekUiState.Loading -> WeekLoading()
|
WeekUiState.Loading -> WeekLoading()
|
||||||
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
is WeekUiState.Success -> WeekSuccess(state = s, topSectionColor = topSectionColor)
|
is WeekUiState.Success -> WeekSuccess(
|
||||||
|
state = s,
|
||||||
|
topSectionColor = topSectionColor,
|
||||||
|
scrollState = scrollState,
|
||||||
|
allDayHeight = allDayHeight,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekSuccess(state: WeekUiState.Success, topSectionColor: Color) {
|
private fun WeekSuccess(
|
||||||
|
state: WeekUiState.Success,
|
||||||
|
topSectionColor: Color,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
allDayHeight: Dp,
|
||||||
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(topSectionColor)
|
.background(topSectionColor),
|
||||||
.animateContentSize(),
|
|
||||||
) {
|
) {
|
||||||
WeekDayHeader(days = state.days, today = state.today)
|
WeekDayHeader(days = state.days, today = state.today)
|
||||||
AllDayStrip(state = state)
|
AllDayStrip(state = state, height = allDayHeight)
|
||||||
}
|
}
|
||||||
// Breathing room between the (colour-shifting) top section and the
|
// Breathing room between the (colour-shifting) top section and the
|
||||||
// scrolling timeline below.
|
// scrolling timeline below.
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Timeline(state = state)
|
Timeline(state = state, scrollState = scrollState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,12 +346,26 @@ private fun WeekTopBar(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
|
private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
|
val weekStart = days.first()
|
||||||
|
val weekNumber = remember(weekStart) {
|
||||||
|
java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, weekStart.day)
|
||||||
|
.get(java.time.temporal.IsoFields.WEEK_OF_WEEK_BASED_YEAR)
|
||||||
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 4.dp, bottom = 8.dp),
|
.padding(top = 4.dp, bottom = 8.dp),
|
||||||
) {
|
) {
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
// Mirror the day-column layout (empty weekday line + spacer) so the
|
||||||
|
// badge lines up vertically with the date numbers.
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.width(GUTTER_WIDTH),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(text = " ", style = MaterialTheme.typography.labelSmall)
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
WeekNumberBadge(weekNumber = weekNumber)
|
||||||
|
}
|
||||||
days.forEach { date ->
|
days.forEach { date ->
|
||||||
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
|
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
|
||||||
val isToday = date == today
|
val isToday = date == today
|
||||||
@@ -355,35 +412,48 @@ private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Calendar-week badge shown in the header gutter, deliberately set apart with a
|
||||||
|
* filled box and bold number. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun AllDayStrip(state: WeekUiState.Success) {
|
private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
|
||||||
if (state.allDaySpans.isEmpty()) return
|
val label = stringResource(R.string.week_number_label)
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = modifier.semantics { contentDescription = "$label $weekNumber" },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = weekNumber.toString(),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AllDayStrip(state: WeekUiState.Success, height: Dp) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val lanes = state.allDaySpans.maxOf { it.lane } + 1
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 6.dp),
|
// Height is hoisted + animated so it slides and resizes smoothly;
|
||||||
|
// padding sits inside it so the content area is lanes * row height.
|
||||||
|
.height(height)
|
||||||
|
.padding(vertical = ALL_DAY_VERTICAL_PADDING),
|
||||||
) {
|
) {
|
||||||
Box(
|
// Keep the gutter-width offset so the bars line up with the day columns.
|
||||||
modifier = Modifier.width(GUTTER_WIDTH),
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
contentAlignment = Alignment.TopEnd,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.week_all_day),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.End,
|
|
||||||
modifier = Modifier.padding(top = 2.dp, end = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Span bars are positioned absolutely so a multi-day event is one
|
// Span bars are positioned absolutely so a multi-day event is one
|
||||||
// connected bar across columns rather than a chip per day.
|
// connected bar across columns rather than a chip per day. clipToBounds
|
||||||
|
// keeps bars from spilling out while the height animates.
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.height(ALL_DAY_ROW_HEIGHT * lanes),
|
.fillMaxHeight()
|
||||||
|
.clipToBounds(),
|
||||||
) {
|
) {
|
||||||
val colWidth = maxWidth / 7
|
val colWidth = maxWidth / 7
|
||||||
state.allDaySpans.forEach { span ->
|
state.allDaySpans.forEach { span ->
|
||||||
@@ -426,39 +496,23 @@ private fun AllDayBar(event: EventInstance, dark: Boolean, modifier: Modifier =
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Timeline(state: WeekUiState.Success) {
|
private fun Timeline(state: WeekUiState.Success, scrollState: ScrollState) {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
val totalHeight = HOUR_HEIGHT * 24
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val density = LocalDensity.current
|
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
val scrollState = rememberScrollState()
|
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
||||||
|
// state, so they stay perfectly aligned. The day-column viewport is a
|
||||||
// Center the timeline on noon once the content is measured. Deriving the
|
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||||
// target from maxValue (known after layout) is reliable, unlike reading
|
// soft corners are permanent at any scroll position (not just at the
|
||||||
// the viewport height during the first composition.
|
// day's start/end).
|
||||||
LaunchedEffect(scrollState) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
// Hour gutter (scrolls in sync with the day columns)
|
||||||
val maxV = scrollState.maxValue
|
|
||||||
val target = with(density) {
|
|
||||||
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
|
|
||||||
}.coerceIn(0, maxV)
|
|
||||||
scrollState.scrollTo(target)
|
|
||||||
}
|
|
||||||
|
|
||||||
// One scroll container for the whole timeline. Gutter and day columns
|
|
||||||
// are siblings inside it, so they share the exact same scroll position
|
|
||||||
// and can never drift apart.
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.verticalScroll(scrollState),
|
|
||||||
) {
|
|
||||||
// Hour gutter
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(GUTTER_WIDTH)
|
.width(GUTTER_WIDTH)
|
||||||
.height(totalHeight),
|
.fillMaxHeight()
|
||||||
|
.verticalScroll(scrollState),
|
||||||
) {
|
) {
|
||||||
(0 until 24).forEach { h ->
|
(0 until 24).forEach { h ->
|
||||||
Box(
|
Box(
|
||||||
@@ -479,16 +533,18 @@ private fun Timeline(state: WeekUiState.Success) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Day columns bundled in a rounded container so the whole block has
|
// Day columns: rounded, clipped scroll viewport (permanent corners).
|
||||||
// soft corners (rounded at the day's start and end).
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.height(totalHeight)
|
.fillMaxHeight()
|
||||||
.clip(RoundedCornerShape(16.dp)),
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.verticalScroll(scrollState),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(totalHeight),
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
state.days.forEach { day ->
|
state.days.forEach { day ->
|
||||||
@@ -513,7 +569,9 @@ private fun DayColumnCard(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
shape = MaterialTheme.shapes.small,
|
// Plain rectangular columns — the soft corners come from the outer
|
||||||
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
|
shape = RectangleShape,
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
),
|
),
|
||||||
@@ -559,15 +617,19 @@ private fun EventBlock(
|
|||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
maxLines = if (showTime) 2 else 1,
|
maxLines = if (showTime) 1 else 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color.Black.copy(alpha = 0.85f),
|
color = Color.Black.copy(alpha = 0.85f),
|
||||||
)
|
)
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
|
// Narrow columns can't fit "13:00–14:00" on one line, so let it
|
||||||
|
// wrap to a second line (after the dash) instead of clipping the
|
||||||
|
// end time.
|
||||||
Text(
|
Text(
|
||||||
text = timeLabel,
|
text = timeLabel,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color.Black.copy(alpha = 0.6f),
|
color = Color.Black.copy(alpha = 0.6f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,8 @@
|
|||||||
<string name="month_a11y_today_prefix">Heute</string>
|
<string name="month_a11y_today_prefix">Heute</string>
|
||||||
|
|
||||||
<!-- Wochenansicht (S2) -->
|
<!-- Wochenansicht (S2) -->
|
||||||
<string name="week_all_day">Ganztägig</string>
|
|
||||||
<string name="week_today_action">Diese Woche</string>
|
<string name="week_today_action">Diese Woche</string>
|
||||||
|
<string name="week_number_label">KW</string>
|
||||||
|
|
||||||
<!-- Geteilte Event-Strings -->
|
<!-- Geteilte Event-Strings -->
|
||||||
<string name="event_untitled">(Ohne Titel)</string>
|
<string name="event_untitled">(Ohne Titel)</string>
|
||||||
|
|||||||
@@ -33,8 +33,8 @@
|
|||||||
<string name="month_a11y_today_prefix">Today</string>
|
<string name="month_a11y_today_prefix">Today</string>
|
||||||
|
|
||||||
<!-- Week view (S2) -->
|
<!-- Week view (S2) -->
|
||||||
<string name="week_all_day">All-day</string>
|
|
||||||
<string name="week_today_action">This week</string>
|
<string name="week_today_action">This week</string>
|
||||||
|
<string name="week_number_label">Wk</string>
|
||||||
|
|
||||||
<!-- Shared event strings -->
|
<!-- Shared event strings -->
|
||||||
<string name="event_untitled">(No title)</string>
|
<string name="event_untitled">(No title)</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user