1
0
mirror of https://github.com/avinal/nikki.git synced 2026-07-03 21:40:09 +05:30

Add navigation and Metro-style login screen

Navigation:
- Type-safe routes: Login, Main, MemoDetail, MemoEditor
- Slide + fade transitions (300ms) for forward/back navigation
- Login uses fade-only transition
- Auth-gated start destination (isLoggedIn flow)

Login screen (Metro design):
- "sign in" title in 54sp Light weight, left-aligned
- Underline-only text fields with accent color focus indicator
- Solid accent-colored sign-in button
- PAT-based authentication against any Memos server URL

Co-Authored-By: Claude Opus 4.6 (1M context)

Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
This commit is contained in:
2026-05-19 17:10:39 +05:30
parent 3b4e45484f
commit 0377095eaa
4 changed files with 300 additions and 0 deletions
@@ -0,0 +1,130 @@
package com.avinal.memos.ui.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.avinal.memos.AppDependencies
import com.avinal.memos.ui.theme.LocalAccentColor
@Composable
fun LoginScreen(
deps: AppDependencies,
onLoginSuccess: () -> Unit,
) {
val viewModel = viewModel { LoginViewModel(deps.authRepository) }
val uiState by viewModel.uiState.collectAsState()
val accent = LocalAccentColor.current
LaunchedEffect(uiState.isSuccess) {
if (uiState.isSuccess) onLoginSuccess()
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.statusBarsPadding()
.padding(start = 24.dp, end = 24.dp, top = 48.dp),
) {
Text(
text = "sign in",
fontSize = 54.sp,
fontWeight = FontWeight.Light,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(36.dp))
TextField(
value = uiState.serverUrl,
onValueChange = viewModel::updateServerUrl,
modifier = Modifier.fillMaxWidth(),
label = { Text("server url", fontSize = 13.sp) },
placeholder = { Text("memos.example.com", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
textStyle = MaterialTheme.typography.bodyMedium,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = accent,
unfocusedIndicatorColor = MaterialTheme.colorScheme.outline,
focusedLabelColor = accent,
),
)
Spacer(Modifier.height(18.dp))
TextField(
value = uiState.token,
onValueChange = viewModel::updateToken,
modifier = Modifier.fillMaxWidth(),
label = { Text("access token", fontSize = 13.sp) },
placeholder = { Text("memos_pat_...", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
textStyle = MaterialTheme.typography.bodyMedium,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = accent,
unfocusedIndicatorColor = MaterialTheme.colorScheme.outline,
focusedLabelColor = accent,
),
)
if (uiState.error != null) {
Spacer(Modifier.height(12.dp))
Text(
uiState.error!!,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.error,
)
}
Spacer(Modifier.height(30.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.background(if (uiState.isLoading) accent.copy(alpha = 0.6f) else accent)
.then(if (!uiState.isLoading) Modifier.clickable(onClick = viewModel::login) else Modifier),
contentAlignment = Alignment.Center,
) {
if (uiState.isLoading) {
CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp)
} else {
Text("sign in", fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
}
}
}
}
@@ -0,0 +1,62 @@
package com.avinal.memos.ui.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.avinal.memos.api.ApiResult
import com.avinal.memos.domain.AuthRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class LoginUiState(
val serverUrl: String = "",
val token: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val isSuccess: Boolean = false,
)
class LoginViewModel(private val authRepository: AuthRepository) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
fun updateServerUrl(url: String) {
_uiState.update { it.copy(serverUrl = url, error = null) }
}
fun updateToken(token: String) {
_uiState.update { it.copy(token = token, error = null) }
}
fun login() {
val state = _uiState.value
if (state.serverUrl.isBlank() || state.token.isBlank()) {
_uiState.update { it.copy(error = "Server URL and token are required") }
return
}
val url = state.serverUrl.let {
if (!it.startsWith("http://") && !it.startsWith("https://")) "https://$it" else it
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
when (val result = authRepository.login(url, state.token)) {
is ApiResult.Success -> {
_uiState.update { it.copy(isLoading = false, isSuccess = true) }
}
is ApiResult.Error -> {
_uiState.update { it.copy(isLoading = false, error = "Login failed: ${result.message}") }
}
is ApiResult.NetworkError -> {
_uiState.update {
it.copy(isLoading = false, error = "Connection failed: ${result.exception.message}")
}
}
}
}
}
}
@@ -0,0 +1,97 @@
package com.avinal.memos.ui.navigation
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.avinal.memos.AppDependencies
import com.avinal.memos.ui.auth.LoginScreen
import com.avinal.memos.ui.memos.MainScreen
import com.avinal.memos.ui.memos.MemoDetailScreen
import com.avinal.memos.ui.memos.MemoEditorScreen
private const val ANIM_DURATION = 300
@Composable
fun AppNavHost(deps: AppDependencies) {
val navController = rememberNavController()
val isLoggedIn by deps.authRepository.isLoggedIn.collectAsState(initial = false)
NavHost(
navController = navController,
startDestination = if (isLoggedIn) Route.Main else Route.Login,
enterTransition = {
slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, tween(ANIM_DURATION)) + fadeIn(tween(ANIM_DURATION))
},
exitTransition = {
slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, tween(ANIM_DURATION)) + fadeOut(tween(ANIM_DURATION))
},
popEnterTransition = {
slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, tween(ANIM_DURATION)) + fadeIn(tween(ANIM_DURATION))
},
popExitTransition = {
slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, tween(ANIM_DURATION)) + fadeOut(tween(ANIM_DURATION))
},
) {
composable<Route.Login>(
enterTransition = { fadeIn(tween(ANIM_DURATION)) },
exitTransition = { fadeOut(tween(ANIM_DURATION)) },
) {
LoginScreen(
deps = deps,
onLoginSuccess = {
navController.navigate(Route.Main) {
popUpTo<Route.Login> { inclusive = true }
}
},
)
}
composable<Route.Main>(
enterTransition = { fadeIn(tween(ANIM_DURATION)) },
exitTransition = { fadeOut(tween(150)) },
popEnterTransition = {
slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, tween(ANIM_DURATION)) + fadeIn(tween(ANIM_DURATION))
},
) {
MainScreen(
deps = deps,
onMemoClick = { memoId -> navController.navigate(Route.MemoDetail(memoId)) },
onCreateMemo = { navController.navigate(Route.MemoEditor()) },
onLogout = {
navController.navigate(Route.Login) {
popUpTo(0) { inclusive = true }
}
},
)
}
composable<Route.MemoDetail> { backStackEntry ->
val route = backStackEntry.toRoute<Route.MemoDetail>()
MemoDetailScreen(
memoId = route.memoId,
deps = deps,
onBack = { navController.popBackStack() },
onEdit = { navController.navigate(Route.MemoEditor(route.memoId)) },
onDeleted = { navController.popBackStack() },
)
}
composable<Route.MemoEditor> { backStackEntry ->
val route = backStackEntry.toRoute<Route.MemoEditor>()
MemoEditorScreen(
memoId = route.memoId.ifEmpty { null },
deps = deps,
onBack = { navController.popBackStack() },
onSaved = { navController.popBackStack() },
)
}
}
}
@@ -0,0 +1,11 @@
package com.avinal.memos.ui.navigation
import kotlinx.serialization.Serializable
@Serializable
sealed interface Route {
@Serializable data object Login : Route
@Serializable data object Main : Route
@Serializable data class MemoDetail(val memoId: String) : Route
@Serializable data class MemoEditor(val memoId: String = "") : Route
}