diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/auth/LoginScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/auth/LoginScreen.kt new file mode 100644 index 0000000..ff8082a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/auth/LoginScreen.kt @@ -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) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/auth/LoginViewModel.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/auth/LoginViewModel.kt new file mode 100644 index 0000000..bda96f0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/auth/LoginViewModel.kt @@ -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 = _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}") + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/navigation/AppNavHost.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/navigation/AppNavHost.kt new file mode 100644 index 0000000..034debb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/navigation/AppNavHost.kt @@ -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( + enterTransition = { fadeIn(tween(ANIM_DURATION)) }, + exitTransition = { fadeOut(tween(ANIM_DURATION)) }, + ) { + LoginScreen( + deps = deps, + onLoginSuccess = { + navController.navigate(Route.Main) { + popUpTo { inclusive = true } + } + }, + ) + } + + composable( + 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 { backStackEntry -> + val route = backStackEntry.toRoute() + MemoDetailScreen( + memoId = route.memoId, + deps = deps, + onBack = { navController.popBackStack() }, + onEdit = { navController.navigate(Route.MemoEditor(route.memoId)) }, + onDeleted = { navController.popBackStack() }, + ) + } + + composable { backStackEntry -> + val route = backStackEntry.toRoute() + MemoEditorScreen( + memoId = route.memoId.ifEmpty { null }, + deps = deps, + onBack = { navController.popBackStack() }, + onSaved = { navController.popBackStack() }, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/navigation/NavRoutes.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/navigation/NavRoutes.kt new file mode 100644 index 0000000..48b4f00 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/navigation/NavRoutes.kt @@ -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 +}