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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user