Last Week
After finally solving the Gradle and WSL issues on my Windows development environment, I could get back to actual development work. With a functional build pipeline, it was time to focus on what really matters – the user experience.
The Marketing Revelation
Last week brought an unexpected source of inspiration. YouTube’s algorithm served up a talk by a successful young entrepreneur, and his message hit home: while product quality matters, the communication between you and your customers often matters more. He called this “marketing” – a term that usually makes engineers like me cringe.
His prime example was Liquid Death, a company that literally sells water. Not special water, not enhanced water – just regular drinking water that you could get from your tap. Yet they’ve built a successful business around it. How? By mastering the art of communication and brand identity. They captured their audience’s imagination and created an emotional connection that transcends the product itself.
Now, I’m not planning to market Shokken like Liquid Death markets water. I genuinely believe this app solves real problems for small and boutique restaurants. But the lesson was clear: even the best product needs a thoughtful user experience, especially during those critical first moments.
First Impressions Matter
This realization led me to completely overhaul the onboarding experience. Before last week, signing up was functional but rough around the edges. Error handling was minimal, the UI felt clunky, and the flow lacked the polish that communicates “this is a professional product built with care.”
The new onboarding flow focuses on:
- Smooth animations and transitions
- Clear visual hierarchy and affordances
- Comprehensive error handling with helpful messages
- A polished look that conveys attention to detail
What does it mean in English?
Think of it like the difference between walking into a well-designed restaurant versus a cafeteria. Both serve food, but one immediately tells you “we care about your experience here.” The same principle applies to apps. When new users sign up for Shokken, I want them to feel confident they’re using professional software that will reliably manage their restaurant’s waitlist.
Good onboarding isn’t just about looking nice – it’s about reducing friction, preventing user frustration, and building trust from the very first interaction. If users struggle to sign up or encounter confusing errors, they’ll likely abandon the app before discovering its value.
Nerdy Details
The technical implementation involved significant work with Compose Multiplatform and platform-specific Android code. Here’s what went into the polish:
Animation System The new onboarding uses Compose’s animation APIs to create smooth transitions between screens. Each step of the signup process now flows seamlessly into the next:
val transition = updateTransition(
targetState = onboardingState,
label = "onboarding_transition"
)
val logoAlpha by transition.animateFloat(
label = "logo_alpha",
transitionSpec = {
tween(durationMillis = 600, easing = FastOutSlowInEasing)
}
) { state ->
when (state) {
OnboardingState.Initial -> 0f
OnboardingState.EmailInput -> 1f
OnboardingState.OtpVerification -> 0.7f
OnboardingState.Complete -> 0f
}
}
Platform-Specific Splash Screen Android 12 introduced new splash screen APIs that required platform-specific implementation. The challenge was integrating this with our Compose Multiplatform architecture:
// Android-specific splash screen setup
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen().apply {
setKeepOnScreenCondition {
// Keep splash screen visible until initial data loads
!viewModel.isReady.value
}
setOnExitAnimationListener { splashScreenView ->
// Custom exit animation
val slideUp = ObjectAnimator.ofFloat(
splashScreenView.view,
View.TRANSLATION_Y,
0f,
-splashScreenView.view.height.toFloat()
)
slideUp.duration = 300L
slideUp.start()
}
}
super.onCreate(savedInstanceState)
}
}
Enhanced Error Handling The new error handling system provides context-aware messages and recovery options:
sealed class OnboardingError {
data class NetworkError(val message: String) : OnboardingError()
data class InvalidEmail(val reason: String) : OnboardingError()
data class OtpExpired(val canResend: Boolean) : OnboardingError()
data class ServerError(val code: Int) : OnboardingError()
}
@Composable
fun ErrorDisplay(error: OnboardingError, onRetry: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
val (icon, title, message, action) = when (error) {
is OnboardingError.NetworkError -> ErrorContent(
icon = Icons.Default.WifiOff,
title = "Connection Problem",
message = "Check your internet connection and try again",
actionLabel = "Retry"
)
is OnboardingError.InvalidEmail -> ErrorContent(
icon = Icons.Default.Email,
title = "Invalid Email",
message = error.reason,
actionLabel = "Edit Email"
)
// ... other error types
}
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(title, style = MaterialTheme.typography.titleMedium)
}
Text(message)
Button(onClick = onRetry) {
Text(action)
}
}
}
}
State Management Architecture The onboarding flow uses a unidirectional data flow pattern with a centralized state machine:
class OnboardingViewModel : ViewModel() {
private val _state = MutableStateFlow(OnboardingUiState())
val state: StateFlow<OnboardingUiState> = _state.asStateFlow()
fun handleIntent(intent: OnboardingIntent) {
when (intent) {
is OnboardingIntent.EmailSubmitted -> validateAndSendOtp(intent.email)
is OnboardingIntent.OtpSubmitted -> verifyOtp(intent.code)
is OnboardingIntent.ResendOtp -> resendOtpCode()
OnboardingIntent.NavigateBack -> navigateToPreviousStep()
}
}
private fun validateAndSendOtp(email: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
emailValidator.validate(email)
.flatMap { validEmail ->
authService.sendOtp(validEmail)
}
.fold(
onSuccess = {
_state.update {
it.copy(
currentStep = OnboardingStep.OTP_VERIFICATION,
email = email,
isLoading = false
)
}
},
onFailure = { error ->
_state.update {
it.copy(
error = error.toOnboardingError(),
isLoading = false
)
}
}
)
}
}
}
Compose Multiplatform Challenges Working with Compose Multiplatform introduced interesting challenges around platform-specific UI requirements:
- Input Method Handling: Different platforms handle soft keyboards differently. The solution involved creating platform-specific implementations:
// Common code
expect fun Modifier.imePadding(): Modifier
// Android implementation
actual fun Modifier.imePadding(): Modifier =
this.imePadding() // Uses Android's built-in modifier
// iOS implementation
actual fun Modifier.imePadding(): Modifier =
this.onGloballyPositioned { coordinates ->
// Custom implementation for iOS keyboard handling
}
- Navigation Transitions: While Compose provides cross-platform animation APIs, navigation transitions needed platform-aware adjustments:
@Composable
fun OnboardingNavHost(
navController: NavHostController,
modifier: Modifier = Modifier
) {
NavHost(
navController = navController,
startDestination = "email_input",
modifier = modifier,
enterTransition = {
slideInHorizontally(
initialOffsetX = { fullWidth -> fullWidth },
animationSpec = tween(300)
) + fadeIn(animationSpec = tween(300))
},
exitTransition = {
slideOutHorizontally(
targetOffsetX = { fullWidth -> -fullWidth },
animationSpec = tween(300)
) + fadeOut(animationSpec = tween(300))
}
) {
// Navigation graph setup
}
}
The attention to these details transforms the onboarding from a mere functional requirement into a delightful experience that sets the tone for the entire app.
Next Week
The focus shifts to the main dashboard – where users spend most of their time managing their restaurant’s waitlist. This screen deserves the same level of polish and attention to detail. Additionally, I’m considering diving into the testing infrastructure. While the app hasn’t been widely released yet, establishing proper testing patterns now will pay dividends as the codebase grows.
The current testing setup is rudimentary at best, with minimal unit tests and no instrumented tests. Before the official app store release, I need to establish:
- Comprehensive unit test coverage
- UI testing with Compose Test APIs
- Integration tests for critical user flows
- Automated regression testing
Whether I tackle this next week or continue with UI improvements depends on user feedback priorities, but it’s definitely on the near-term roadmap.