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