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
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 307 KiB After Width: | Height: | Size: 305 KiB |
|
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 576 KiB After Width: | Height: | Size: 580 KiB |
|
Before Width: | Height: | Size: 318 KiB After Width: | Height: | Size: 320 KiB |
|
Before Width: | Height: | Size: 458 KiB After Width: | Height: | Size: 444 KiB |
|
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 245 KiB |