feat: notification copy, error messages, animations, reduced-motion support (batch v0.2.2)

- Rewrite all notification titles/bodies + channel descriptions to warmer, partner-centric tone
- Update error messages across all screens for clarity and consistency
- Add AnimatedVisibility + reduced-motion detection (Settings.Global.ANIMATOR_DURATION_SCALE) to AnswerRevealScreen and LocalQuestionContent
- Polish settings copy (quiet hours, partner activity labels, info footer)
- Update all game error states with actionable language ('Go back and try again')
- Refresh docs screenshots
This commit is contained in:
null 2026-06-19 04:20:08 -05:00
parent 79a63629f1
commit 7552c451db
22 changed files with 86 additions and 55 deletions

View File

@ -98,28 +98,28 @@ class AppMessagingService : FirebaseMessagingService() {
}
private fun resolveTitle(type: String): String? = when (type) {
"daily_question" -> "Your daily question is waiting!"
"partner_answered" -> "Your partner just answered!"
"partner_left" -> "Your partner has left"
"streak" -> "Keep your streak going — answer today's question!"
"partner_started_game" -> "Partner is playing!"
"partner_finished_game" -> "Partner finished!"
"partner_waiting" -> "Partner waiting"
"memory_capsule_unlocked" -> "Your memory capsule opened"
"challenge_day_ready" -> "Today's challenge is ready"
"daily_question" -> "Today's question is here."
"partner_answered" -> "Your partner answered."
"partner_left" -> "You've been unlinked."
"streak" -> "A question is waiting for you."
"partner_started_game" -> "Your partner started a game."
"partner_finished_game" -> "Your partner finished the round."
"partner_waiting" -> "Your partner is waiting."
"memory_capsule_unlocked" -> "Your capsule just opened."
"challenge_day_ready" -> "A new connection moment is ready."
else -> null
}
private fun resolveBody(type: String): String? = when (type) {
"daily_question" -> "Tap to answer today's question together."
"partner_answered" -> "See what your partner shared."
"partner_left" -> "You are no longer paired. Tap to create a new invite."
"streak" -> "Don't break the chain. Open the app now."
"partner_started_game" -> "Your partner has started a game. Tap to join!"
"partner_finished_game" -> "Your partner has finished. Tap to see results!"
"partner_waiting" -> "Your partner is waiting for you to finish."
"memory_capsule_unlocked" -> "Open Memory Lane to read it together."
"challenge_day_ready" -> "Open your connection challenge for today's prompt."
"daily_question" -> "Take a moment to answer. Your partner's waiting too."
"partner_answered" -> "See what they shared — then reveal when you're ready."
"partner_left" -> "Your shared space has been closed. Create a new invite whenever you're ready."
"streak" -> "Answer today's question to keep your shared rhythm going."
"partner_started_game" -> "They're in — tap to join them."
"partner_finished_game" -> "Time to compare notes. See your results together."
"partner_waiting" -> "They finished their side. Whenever you're ready, complete yours."
"memory_capsule_unlocked" -> "Something you sealed together is ready to open."
"challenge_day_ready" -> "Your next connection challenge is here — open it together."
else -> null
}

View File

@ -24,7 +24,7 @@ object NotificationHelper {
"Daily reminders",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Daily question and streak reminders"
description = "Gentle nudges for today's question and shared moments"
}
)
nm.createNotificationChannel(
@ -33,16 +33,16 @@ object NotificationHelper {
"Partner activity",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "When your partner answers a question or plays a game"
description = "When your partner answers, reveals, or opens a conversation"
}
)
nm.createNotificationChannel(
NotificationChannel(
CHANNEL_GAME_ACTIVITY,
"Game activity",
"Games",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "When your partner starts or finishes a game session"
description = "When your partner starts or completes a game round"
}
)
}

View File

@ -1,6 +1,11 @@
package app.closer.ui.answers
import app.closer.ui.theme.closerBackgroundBrush
import android.provider.Settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.expandVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -28,6 +33,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
@ -78,6 +84,11 @@ private fun AnswerRevealContent(
onHistory: () -> Unit,
onHome: () -> Unit
) {
val context = LocalContext.current
val reducedMotion = Settings.Global.getFloat(
context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f
) == 0f
Box(
modifier = Modifier
.fillMaxSize()
@ -119,13 +130,19 @@ private fun AnswerRevealContent(
onAnswerQuestion = onAnswerQuestion,
onHome = onHome
)
state.answer.isRevealed -> RevealedState(
state.answer.isRevealed -> AnimatedVisibility(
visible = true,
enter = if (reducedMotion) fadeIn(tween(0))
else fadeIn(tween(380)) + expandVertically(tween(380))
) {
RevealedState(
answer = state.answer,
partnerAnswer = state.partnerAnswer,
question = state.question,
onHistory = onHistory,
onHome = onHome
)
}
else -> ReadyToRevealState(
answer = state.answer,
partnerAnswer = state.partnerAnswer,

View File

@ -13,7 +13,7 @@ import androidx.compose.ui.text.font.FontWeight
@Composable
fun ErrorState(
title: String = "Something went wrong",
title: String = "Didn't quite load",
message: String,
retryLabel: String = "Try again",
onRetry: (() -> Unit)? = null,

View File

@ -128,8 +128,8 @@ private fun DateMatchContent(
state.error != null -> {
ErrorState(
title = "Could not load ideas",
message = state.error ?: "Something went wrong.",
title = "Couldn't load date ideas",
message = state.error ?: "Tap below to try again.",
onRetry = onRetry,
modifier = Modifier.padding(top = 120.dp)
)

View File

@ -115,8 +115,8 @@ private fun DateMatchesContent(
state.error != null -> item {
ErrorState(
title = "Could not load matches",
message = state.error ?: "Something went wrong.",
title = "Couldn't load your matches",
message = state.error ?: "Pull down to try again.",
onRetry = onRetry,
modifier = Modifier.padding(top = 80.dp)
)

View File

@ -207,7 +207,7 @@ class DesireSyncViewModel @Inject constructor(
sessionId = existingSessionId
val byId = loadNeutralQuestions().associateBy { it.id }
val questions = questionIds.mapNotNull { byId[it] }
if (questions.isEmpty()) return fail("Could not load this game.")
if (questions.isEmpty()) return fail("Couldn't load this round's questions.")
_uiState.update { it.copy(phase = DesireSyncPhase.INTRO, questions = questions) }
observeReveal()
}
@ -374,7 +374,7 @@ fun DesireSyncScreen(
color = CloserPalette.Romantic
)
DesireSyncPhase.ERROR -> DSErrorScreen(
message = state.error ?: "Something went wrong.",
message = state.error ?: "Something didn't load. Go back and try again.",
onBack = viewModel::quit
)
DesireSyncPhase.SETUP -> DSSetupScreen(

View File

@ -138,7 +138,7 @@ class HomeViewModel @Inject constructor(
_uiState.update {
it.copy(
isLoading = false,
error = e.message ?: "Could not load your dashboard."
error = e.message ?: "Couldn't load your space right now."
).withHomeActions()
}
}

View File

@ -239,7 +239,7 @@ class HowWellViewModel @Inject constructor(
_uiState.update { it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER) }
else -> {
Log.w(TAG, "Could not start session", startResult.exceptionOrNull())
fail("Could not start the game.")
fail("Couldn't start this round. Go back and try again.")
}
}
}
@ -254,7 +254,7 @@ class HowWellViewModel @Inject constructor(
sessionId = existingSessionId
startedByUserId = startedBy
val questions = questionIds.mapNotNull { repository.getQuestionById(it) }
if (questions.isEmpty()) return fail("Could not load this game.")
if (questions.isEmpty()) return fail("Couldn't load this round's questions.")
_uiState.update {
it.copy(phase = HowWellPhase.INTRO, questions = questions, amSubject = startedBy == uid)
}
@ -418,7 +418,7 @@ fun HowWellScreen(
color = CloserPalette.PurpleDeep
)
HowWellPhase.ERROR -> HowWellErrorScreen(
message = state.error ?: "Something went wrong.",
message = state.error ?: "Something didn't load. Go back and try again.",
onBack = viewModel::quit
)
HowWellPhase.SETUP -> HWSetupScreen(

View File

@ -115,8 +115,8 @@ fun PaywallScreen(
modifier = Modifier.fillMaxWidth()
)
uiState.error != null -> ErrorState(
title = "Could not load plans",
message = uiState.error ?: "Something went wrong.",
title = "Couldn't load plans",
message = uiState.error ?: "Check your connection and tap to try again.",
retryLabel = "Try again",
onRetry = { viewModel.retry() },
modifier = Modifier.fillMaxWidth()

View File

@ -63,7 +63,7 @@ class DailyQuestionViewModel @Inject constructor(
crashReporter.recordException(e)
_uiState.value = LocalQuestionUiState(
isLoading = false,
error = e.message ?: "Could not load today's question."
error = e.message ?: "Couldn't open today's question."
)
}
}

View File

@ -1,6 +1,11 @@
package app.closer.ui.questions
import app.closer.ui.theme.closerBackgroundBrush
import android.provider.Settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -33,6 +38,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
@ -98,6 +104,10 @@ fun LocalQuestionContent(
)
else -> {
val question = state.question
val context = LocalContext.current
val reducedMotion = Settings.Global.getFloat(
context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f
) == 0f
var helpExpanded by remember(question.id) { mutableStateOf(false) }
QuestionMetaRow(question = question)
QuestionHeader(
@ -118,7 +128,11 @@ fun LocalQuestionContent(
isSubmitting = false
)
if (state.submitted) {
AnimatedVisibility(
visible = state.submitted,
enter = if (reducedMotion) fadeIn(tween(0))
else fadeIn(tween(260)) + slideInVertically(tween(260)) { it / 3 }
) {
SubmittedAnswerCard(question = question, state = state)
}

View File

@ -130,21 +130,21 @@ fun NotificationSettingsScreen(
Column {
NotifToggleRow(
label = "Daily question",
description = "Remind me to answer today's question",
description = "A gentle nudge when today's question is ready",
checked = state.dailyReminderEnabled,
onCheckedChange = viewModel::toggleDailyReminder
)
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
NotifToggleRow(
label = "Partner answered",
description = "Notify me when my partner answers",
description = "Let me know when they're ready to reveal",
checked = state.partnerAnsweredEnabled,
onCheckedChange = viewModel::togglePartnerAnswered
)
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
NotifToggleRow(
label = "Streak reminder",
description = "Nudge me before the streak resets",
label = "Shared rhythm reminder",
description = "Remind me to keep our shared rhythm going",
checked = state.streakReminderEnabled,
onCheckedChange = viewModel::toggleStreakReminder
)
@ -167,8 +167,8 @@ fun NotificationSettingsScreen(
) {
Column {
NotifToggleRow(
label = "Enable quiet hours",
description = "Suppress notifications 10 PM 8 AM",
label = "Quiet hours",
description = "Pause all notifications from 10 PM to 8 AM",
checked = state.quietHoursEnabled,
onCheckedChange = viewModel::toggleQuietHours
)
@ -178,7 +178,7 @@ fun NotificationSettingsScreen(
Spacer(Modifier.height(8.dp))
Text(
text = "These preferences shape the reminders you see from the app.",
text = "Notifications from Closer are gentle invitations, not alerts. You're always in control of when they arrive.",
style = MaterialTheme.typography.bodySmall,
color = SettingsMuted,
modifier = Modifier.padding(horizontal = 4.dp)

View File

@ -238,7 +238,7 @@ class ThisOrThatViewModel @Inject constructor(
_uiState.update { it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER) }
else -> {
Log.w(TAG, "Could not start session", startResult.exceptionOrNull())
fail("Could not start the game.")
fail("Couldn't start this round. Go back and try again.")
}
}
}
@ -250,7 +250,7 @@ class ThisOrThatViewModel @Inject constructor(
.getOrElse { emptyList() }
.associateBy { it.id }
val questions = questionIds.mapNotNull { byId[it] }
if (questions.isEmpty()) return fail("Could not load this game.")
if (questions.isEmpty()) return fail("Couldn't load this round's questions.")
_uiState.update { it.copy(phase = TotPhase.PLAYING, questions = questions) }
observeReveal()
}
@ -421,7 +421,7 @@ fun ThisOrThatScreen(
color = CloserPalette.PurpleDeep
)
TotPhase.ERROR -> ErrorState(
message = state.error ?: "Something went wrong.",
message = state.error ?: "Something didn't load. Go back and try again.",
onBack = viewModel::quit
)
TotPhase.WAITING -> WaitingForRevealScreen(

View File

@ -58,7 +58,7 @@ class WheelHistoryViewModel @Inject constructor(
_uiState.update { it.copy(isLoading = false, sessions = sessions) }
}
.onFailure { e ->
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Failed to load history") }
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't load your past games.") }
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 576 KiB

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 245 KiB