mvp shell

This commit is contained in:
2026-03-22 14:45:37 -05:00
parent e8b4298b7f
commit 284c873a3d
38 changed files with 2682 additions and 26 deletions
@@ -0,0 +1,64 @@
package com.postcard.ui.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.postcard.data.repository.AuthRepository
import com.postcard.data.repository.LoginRequest
import com.postcard.data.repository.RegisterRequest
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AuthViewModel @Inject constructor(
private val authRepository: AuthRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<AuthState>(AuthState.Idle)
val uiState: StateFlow<AuthState> = _uiState.asStateFlow()
fun login(username: String, passwordHash: String) {
viewModelScope.launch {
_uiState.value = AuthState.Loading
val result = authRepository.login(LoginRequest(username, passwordHash))
if (result.isSuccess) {
_uiState.value = AuthState.Success
} else {
_uiState.value = AuthState.Error(result.exceptionOrNull()?.message ?: "Login failed")
}
}
}
fun register(username: String, displayName: String, passwordHash: String) {
viewModelScope.launch {
_uiState.value = AuthState.Loading
val result = authRepository.register(RegisterRequest(username, displayName, passwordHash))
if (result.isSuccess) {
_uiState.value = AuthState.Success
} else {
_uiState.value = AuthState.Error(result.exceptionOrNull()?.message ?: "Registration failed")
}
}
}
fun isLoggedIn(): Boolean = authRepository.isLoggedIn()
fun logout() {
authRepository.logout()
_uiState.value = AuthState.Idle
}
fun resetState() {
_uiState.value = AuthState.Idle
}
}
sealed class AuthState {
object Idle : AuthState()
object Loading : AuthState()
object Success : AuthState()
data class Error(val message: String) : AuthState()
}
@@ -0,0 +1,83 @@
package com.postcard.ui.auth
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@Composable
fun LoginScreen(
onNavigateToRegister: () -> Unit,
onLoginSuccess: () -> Unit,
viewModel: AuthViewModel = hiltViewModel()
) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(uiState) {
if (uiState is AuthState.Success) {
onLoginSuccess()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("Login", style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
if (uiState is AuthState.Error) {
Text(
text = (uiState as AuthState.Error).message,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
}
Button(
onClick = { viewModel.login(username, password) },
modifier = Modifier.fillMaxWidth(),
enabled = uiState !is AuthState.Loading && username.isNotBlank() && password.isNotBlank()
) {
if (uiState is AuthState.Loading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary)
} else {
Text("Login")
}
}
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = {
viewModel.resetState()
onNavigateToRegister()
}) {
Text("Don't have an account? Register")
}
}
}
@@ -0,0 +1,92 @@
package com.postcard.ui.auth
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@Composable
fun RegisterScreen(
onNavigateToLogin: () -> Unit,
onRegisterSuccess: () -> Unit,
viewModel: AuthViewModel = hiltViewModel()
) {
var username by remember { mutableStateOf("") }
var displayName by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(uiState) {
if (uiState is AuthState.Success) {
onRegisterSuccess()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("Register", style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = { Text("Display Name") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
if (uiState is AuthState.Error) {
Text(
text = (uiState as AuthState.Error).message,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
}
Button(
onClick = { viewModel.register(username, displayName, password) },
modifier = Modifier.fillMaxWidth(),
enabled = uiState !is AuthState.Loading && username.isNotBlank() && displayName.isNotBlank() && password.isNotBlank()
) {
if (uiState is AuthState.Loading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary)
} else {
Text("Register")
}
}
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = {
viewModel.resetState()
onNavigateToLogin()
}) {
Text("Already have an account? Login")
}
}
}
@@ -0,0 +1,17 @@
package com.postcard.ui.conversations
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun ConversationListScreen(
onLogout: () -> Unit
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Conversation List")
}
}
@@ -0,0 +1,11 @@
package com.postcard.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,57 @@
package com.postcard.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
)
@Composable
fun PostcardTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}