diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/auth/LoginScreen.kt b/composeApp/src/androidMain/kotlin/friends/mobile/auth/LoginScreen.kt index 72e487d..3f31dfd 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/auth/LoginScreen.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/auth/LoginScreen.kt @@ -1,24 +1,26 @@ package friends.mobile.auth +import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row 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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import friends.mobile.R import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -29,9 +31,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import friends.mobile.designkit.DesignTheme +import friends.mobile.designkit.FormErrorMessage +import friends.mobile.designkit.FormSecureField +import friends.mobile.designkit.FormTextField +import friends.mobile.designkit.PrimaryButton import friends.mobile.feature.auth.domain.model.AuthSession import friends.mobile.feature.auth.presentation.login.LoginAction import friends.mobile.feature.auth.presentation.login.LoginEvent @@ -112,61 +118,95 @@ private fun LoginForm( onEvent: (LoginEvent) -> Unit, onRegisterClick: () -> Unit ) { - Column( + LazyColumn( modifier = Modifier .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally + .padding(horizontal = DesignTheme.Spacing.xl), + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.xxxl), + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = androidx.compose.foundation.layout.PaddingValues( + vertical = DesignTheme.Spacing.lg + ) ) { - Text(text = "Welcome Back", style = MaterialTheme.typography.headlineLarge) - Spacer(modifier = Modifier.height(32.dp)) + item { + Spacer(modifier = Modifier.height(DesignTheme.Spacing.md)) + } - OutlinedTextField( - value = state.username, - onValueChange = { onEvent(LoginEvent.OnUsernameChanged(it)) }, - label = { Text("Username") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false + item { + Image( + painter = painterResource(id = R.drawable.onboarding_image), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .padding(horizontal = DesignTheme.Spacing.sm) ) - ) + } - Spacer(modifier = Modifier.height(16.dp)) + item { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.lg), + horizontalAlignment = Alignment.CenterHorizontally + ) { + FormTextField( + value = state.username, + onValueChange = { onEvent(LoginEvent.OnUsernameChanged(it)) }, + placeholder = "Username", + modifier = Modifier + .fillMaxWidth() + .height(54.dp), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false + ) + ) - OutlinedTextField( - value = state.password, - onValueChange = { onEvent(LoginEvent.OnPasswordChanged(it)) }, - label = { Text("Password") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) - ) + FormSecureField( + value = state.password, + onValueChange = { onEvent(LoginEvent.OnPasswordChanged(it)) }, + placeholder = "Password", + modifier = Modifier + .fillMaxWidth() + .height(54.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) + ) + } + } - Spacer(modifier = Modifier.height(24.dp)) + item { + PrimaryButton( + text = if (state.isLoggingIn) "Logging in..." else "Login", + onClick = { onEvent(LoginEvent.OnLoginClick) }, + modifier = Modifier.fillMaxWidth(), + isLoading = state.isLoggingIn, + isEnabled = state.username.isNotBlank() && state.password.isNotBlank() + ) + } - Button( - onClick = { onEvent(LoginEvent.OnLoginClick) }, - modifier = Modifier.fillMaxWidth().height(50.dp), - enabled = !state.isLoggingIn && state.username.isNotBlank() && state.password.isNotBlank() - ) { - if (state.isLoggingIn) { - CircularProgressIndicator(modifier = Modifier.size(24.dp), color = Color.White) - } else { - Text("Login") + item { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "Don't have an account?", + style = DesignTheme.Typography.caption, + color = Color.Gray + ) + Spacer(modifier = Modifier.width(DesignTheme.Spacing.xs)) + Text( + text = "Sign up", + modifier = Modifier.clickable { onRegisterClick() }, + style = DesignTheme.Typography.captionSemibold, + color = DesignTheme.Colors.primaryHex + ) } } - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = "Don't have an account? Register", - modifier = Modifier.clickable { onRegisterClick() }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) + item { + Spacer(modifier = Modifier.height(DesignTheme.Spacing.xl)) + } } } diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/auth/RegisterBottomSheet.kt b/composeApp/src/androidMain/kotlin/friends/mobile/auth/RegisterBottomSheet.kt index d2a0213..03b740f 100644 --- a/composeApp/src/androidMain/kotlin/friends/mobile/auth/RegisterBottomSheet.kt +++ b/composeApp/src/androidMain/kotlin/friends/mobile/auth/RegisterBottomSheet.kt @@ -1,20 +1,23 @@ package friends.mobile.auth +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Button +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -22,12 +25,16 @@ import androidx.compose.runtime.LaunchedEffect 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.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import friends.mobile.designkit.DesignTheme +import friends.mobile.designkit.FormErrorMessage +import friends.mobile.designkit.FormInfoMessage +import friends.mobile.designkit.FormSecureField +import friends.mobile.designkit.FormTextField +import friends.mobile.designkit.PrimaryButton import friends.mobile.feature.auth.presentation.register.RegisterAction import friends.mobile.feature.auth.presentation.register.RegisterEvent import friends.mobile.feature.auth.presentation.register.RegisterViewModel @@ -58,30 +65,42 @@ fun RegisterBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, ) { - Box(modifier = Modifier.fillMaxWidth().padding(bottom = 32.dp)) { - when (val currentState = state) { - is RegisterViewState.Loading -> { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center).padding(32.dp)) + when (val currentState = state) { + is RegisterViewState.Loading -> { + Column( + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + .padding(bottom = DesignTheme.Spacing.xxxl), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator() } - is RegisterViewState.Error -> { - Column( - modifier = Modifier.fillMaxWidth().padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(text = currentState.message, color = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { viewModel.obtainEvent(RegisterEvent.OnUsernameChanged("")) }) { - Text("Try Again") - } - } - } - is RegisterViewState.Content -> { - RegisterForm( - state = currentState, - onEvent = viewModel::obtainEvent + } + is RegisterViewState.Error -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(DesignTheme.Spacing.xl), + horizontalAlignment = Alignment.CenterHorizontally + ) { + FormErrorMessage(message = currentState.message) + Spacer(modifier = Modifier.height(DesignTheme.Spacing.lg)) + PrimaryButton( + text = "Try Again", + onClick = { viewModel.obtainEvent(RegisterEvent.OnUsernameChanged("")) }, + modifier = Modifier.fillMaxWidth() ) } } + is RegisterViewState.Content -> { + RegisterForm( + state = currentState, + onEvent = viewModel::obtainEvent, + onDismiss = onDismiss + ) + } } } } @@ -89,50 +108,106 @@ fun RegisterBottomSheet( @Composable private fun RegisterForm( state: RegisterViewState.Content, - onEvent: (RegisterEvent) -> Unit + onEvent: (RegisterEvent) -> Unit, + onDismiss: () -> Unit ) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(16.dp), + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = DesignTheme.Spacing.xl), + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.lg), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + vertical = DesignTheme.Spacing.lg, + horizontal = 0.dp + ) ) { - Text(text = "Create Account", style = MaterialTheme.typography.headlineSmall) + item { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Create Account", + style = DesignTheme.Typography.heading, + color = androidx.compose.ui.graphics.Color.Black + ) + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + tint = androidx.compose.ui.graphics.Color.Gray.copy(alpha = 0.5f) + ) + } + } + } - OutlinedTextField( - value = state.username, - onValueChange = { onEvent(RegisterEvent.OnUsernameChanged(it)) }, - label = { Text("Username") }, - singleLine = true, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false - ), - modifier = Modifier.fillMaxWidth(), - ) + item { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(DesignTheme.Spacing.lg), + horizontalAlignment = Alignment.CenterHorizontally + ) { + FormTextField( + value = state.username, + onValueChange = { onEvent(RegisterEvent.OnUsernameChanged(it)) }, + placeholder = "Username", + modifier = Modifier + .fillMaxWidth() + .height(54.dp), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false + ) + ) - OutlinedTextField( - value = state.password, - onValueChange = { onEvent(RegisterEvent.OnPasswordChanged(it)) }, - label = { Text("Password") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - modifier = Modifier.fillMaxWidth(), - ) + FormSecureField( + value = state.password, + onValueChange = { onEvent(RegisterEvent.OnPasswordChanged(it)) }, + placeholder = "Password", + modifier = Modifier + .fillMaxWidth() + .height(54.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) + ) + + FormInfoMessage(message = "At least 8 characters") + } + } - Button( - onClick = { onEvent(RegisterEvent.OnRegisterClick) }, - enabled = !state.isRegistering && state.username.isNotBlank() && state.password.isNotBlank(), - modifier = Modifier.fillMaxWidth().height(50.dp), - ) { - if (state.isRegistering) { - CircularProgressIndicator( - color = Color.White, - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp + item { + PrimaryButton( + text = if (state.isRegistering) "Creating account..." else "Create Account", + onClick = { onEvent(RegisterEvent.OnRegisterClick) }, + modifier = Modifier.fillMaxWidth(), + isLoading = state.isRegistering, + isEnabled = state.username.isNotBlank() && state.password.isNotBlank() + ) + } + + item { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "Already have an account?", + style = DesignTheme.Typography.caption, + color = androidx.compose.ui.graphics.Color.Gray + ) + Spacer(modifier = Modifier.width(DesignTheme.Spacing.xs)) + Text( + text = "Login", + modifier = Modifier.clickable { onDismiss() }, + style = DesignTheme.Typography.captionSemibold, + color = DesignTheme.Colors.primaryHex ) - } else { - Text("Register") } } + + item { + Spacer(modifier = Modifier.height(DesignTheme.Spacing.xl)) + } } } diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/designkit/DesignTheme.kt b/composeApp/src/androidMain/kotlin/friends/mobile/designkit/DesignTheme.kt new file mode 100644 index 0000000..0b2302f --- /dev/null +++ b/composeApp/src/androidMain/kotlin/friends/mobile/designkit/DesignTheme.kt @@ -0,0 +1,69 @@ +package friends.mobile.designkit + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +object DesignTheme { + object Colors { + val primary = Color(0xFF007AFF) + val primaryHex = Color(red = 0.0f, green = 0.48f, blue = 1.0f) + val error = Color(0xFFFF3B30) + val errorLight = Color.Red.copy(alpha = 0.08f) + val infoLight = primaryHex.copy(alpha = 0.06f) + val textField = Color(0xFFF2F2F7) + } + + object Spacing { + val xs = 4.dp + val sm = 8.dp + val md = 12.dp + val lg = 16.dp + val xl = 20.dp + val xxl = 28.dp + val xxxl = 32.dp + } + + object Typography { + val body = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Normal) + val bodySmall = TextStyle(fontSize = 14.sp, fontWeight = FontWeight.Normal) + val bodySmallest = TextStyle(fontSize = 13.sp, fontWeight = FontWeight.Normal) + val button = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + val caption = TextStyle(fontSize = 15.sp, fontWeight = FontWeight.Normal) + val captionSemibold = TextStyle(fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + val heading = TextStyle(fontSize = 28.sp, fontWeight = FontWeight.Bold) + } + + object CornerRadius { + val small = 10.dp + val medium = 12.dp + val capsule = 27.dp + } +} + +@Composable +fun FriendsAppTheme( + content: @Composable () -> Unit +) { + val lightColorScheme = lightColorScheme( + primary = DesignTheme.Colors.primary, + onPrimary = Color.White, + background = Color.White, + surface = Color.White, + onSurface = Color.Black, + error = DesignTheme.Colors.error, + onError = Color.White + ) + + MaterialTheme( + colorScheme = lightColorScheme, + typography = androidx.compose.material3.Typography(), + content = content + ) +} diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/designkit/FormFields.kt b/composeApp/src/androidMain/kotlin/friends/mobile/designkit/FormFields.kt new file mode 100644 index 0000000..e91ebab --- /dev/null +++ b/composeApp/src/androidMain/kotlin/friends/mobile/designkit/FormFields.kt @@ -0,0 +1,77 @@ +package friends.mobile.designkit + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FormTextField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default +) { + TextField( + value = value, + onValueChange = onValueChange, + placeholder = { Text(placeholder, style = DesignTheme.Typography.body) }, + modifier = modifier + .background(DesignTheme.Colors.textField, RoundedCornerShape(DesignTheme.CornerRadius.medium)) + .border(0.dp, Color.Transparent), + colors = TextFieldDefaults.colors( + unfocusedContainerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = DesignTheme.Colors.primary.copy(alpha = 0.5f), + focusedLabelColor = DesignTheme.Colors.primary + ), + singleLine = true, + keyboardOptions = keyboardOptions, + textStyle = DesignTheme.Typography.body + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FormSecureField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default +) { + TextField( + value = value, + onValueChange = onValueChange, + placeholder = { Text(placeholder, style = DesignTheme.Typography.body) }, + modifier = modifier + .background(DesignTheme.Colors.textField, RoundedCornerShape(DesignTheme.CornerRadius.medium)) + .border(0.dp, Color.Transparent), + colors = TextFieldDefaults.colors( + unfocusedContainerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = DesignTheme.Colors.primary.copy(alpha = 0.5f), + focusedLabelColor = DesignTheme.Colors.primary + ), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = keyboardOptions, + textStyle = DesignTheme.Typography.body + ) +} diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/designkit/FormMessages.kt b/composeApp/src/androidMain/kotlin/friends/mobile/designkit/FormMessages.kt new file mode 100644 index 0000000..35cbc93 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/friends/mobile/designkit/FormMessages.kt @@ -0,0 +1,78 @@ +package friends.mobile.designkit + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +@Composable +fun FormErrorMessage( + message: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .background(DesignTheme.Colors.errorLight, RoundedCornerShape(DesignTheme.CornerRadius.small)) + .padding(DesignTheme.Spacing.md) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(DesignTheme.Spacing.sm) + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = DesignTheme.Colors.error + ) + Text( + message, + style = DesignTheme.Typography.bodySmall, + color = DesignTheme.Colors.error + ) + } + } +} + +@Composable +fun FormInfoMessage( + message: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .background(DesignTheme.Colors.infoLight, RoundedCornerShape(DesignTheme.CornerRadius.small)) + .padding(DesignTheme.Spacing.md) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(DesignTheme.Spacing.sm) + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = DesignTheme.Colors.primaryHex, + modifier = Modifier + ) + Text( + message, + style = DesignTheme.Typography.bodySmallest, + color = DesignTheme.Colors.primaryHex.copy(alpha = 0.8f) + ) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/friends/mobile/designkit/PrimaryButton.kt b/composeApp/src/androidMain/kotlin/friends/mobile/designkit/PrimaryButton.kt new file mode 100644 index 0000000..6739e46 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/friends/mobile/designkit/PrimaryButton.kt @@ -0,0 +1,62 @@ +package friends.mobile.designkit + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp + +@Composable +fun PrimaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isLoading: Boolean = false, + isEnabled: Boolean = true, + icon: @Composable (() -> Unit)? = null +) { + Button( + onClick = onClick, + modifier = modifier + .fillMaxWidth() + .height(54.dp) + .alpha(if (isEnabled) 1f else 0.3f), + enabled = isEnabled && !isLoading, + shape = RoundedCornerShape(DesignTheme.CornerRadius.capsule), + colors = ButtonDefaults.buttonColors( + containerColor = DesignTheme.Colors.primary, + contentColor = Color.White, + disabledContainerColor = DesignTheme.Colors.primary, + disabledContentColor = Color.White + ) + ) { + if (isLoading) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(DesignTheme.Spacing.sm) + ) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = Color.White, + strokeWidth = 2.dp + ) + Text(text, style = DesignTheme.Typography.button) + } + } else { + Text(text, style = DesignTheme.Typography.button) + } + } +} diff --git a/composeApp/src/androidMain/res/drawable/onboarding_image.png b/composeApp/src/androidMain/res/drawable/onboarding_image.png new file mode 100644 index 0000000..cb6202b Binary files /dev/null and b/composeApp/src/androidMain/res/drawable/onboarding_image.png differ diff --git a/iosApp/iosApp/Application/Assets.xcassets/OnboardingImage.imageset/Component 6.png b/iosApp/iosApp/Application/Assets.xcassets/OnboardingImage.imageset/Component 6.png new file mode 100644 index 0000000..cb6202b Binary files /dev/null and b/iosApp/iosApp/Application/Assets.xcassets/OnboardingImage.imageset/Component 6.png differ diff --git a/iosApp/iosApp/Application/Assets.xcassets/OnboardingImage.imageset/Component 7.png b/iosApp/iosApp/Application/Assets.xcassets/OnboardingImage.imageset/Component 7.png new file mode 100644 index 0000000..cb6202b Binary files /dev/null and b/iosApp/iosApp/Application/Assets.xcassets/OnboardingImage.imageset/Component 7.png differ diff --git a/iosApp/iosApp/Application/Assets.xcassets/OnboardingImage.imageset/Component 8.png b/iosApp/iosApp/Application/Assets.xcassets/OnboardingImage.imageset/Component 8.png new file mode 100644 index 0000000..cb6202b Binary files /dev/null and b/iosApp/iosApp/Application/Assets.xcassets/OnboardingImage.imageset/Component 8.png differ diff --git a/iosApp/iosApp/Application/Assets.xcassets/OnboardingImage.imageset/Contents.json b/iosApp/iosApp/Application/Assets.xcassets/OnboardingImage.imageset/Contents.json new file mode 100644 index 0000000..3b50b62 --- /dev/null +++ b/iosApp/iosApp/Application/Assets.xcassets/OnboardingImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Component 6.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Component 7.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Component 8.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/DesignKit/DesignTheme.swift b/iosApp/iosApp/DesignKit/DesignTheme.swift new file mode 100644 index 0000000..16d5a04 --- /dev/null +++ b/iosApp/iosApp/DesignKit/DesignTheme.swift @@ -0,0 +1,91 @@ +// +// DesignTheme.swift +// iosApp +// +// Created by Данил Забинский on 21.05.2026. +// + +import SwiftUI +import UIKit + +enum DesignTheme { + static let accentColor = Color.blue + static let accentColorHex = Color(red: 0.0, green: 0.48, blue: 1.0) + + enum Spacing { + static let xs: CGFloat = 4 + static let sm: CGFloat = 8 + static let md: CGFloat = 12 + static let lg: CGFloat = 16 + static let xl: CGFloat = 20 + static let xxl: CGFloat = 28 + static let xxxl: CGFloat = 32 + } + + enum Typography { + static let body = Font.system(size: 16, weight: .regular, design: .default) + static let bodySmall = Font.system(size: 14, weight: .regular, design: .default) + static let bodySmallest = Font.system(size: 13, weight: .regular, design: .default) + static let button = Font.system(size: 16, weight: .semibold, design: .default) + static let caption = Font.system(size: 15, weight: .regular, design: .default) + static let captionSemibold = Font.system(size: 15, weight: .semibold, design: .default) + static let heading = Font.system(size: 28, weight: .bold, design: .default) + } + + enum CornerRadius { + static let small: CGFloat = 10 + static let medium: CGFloat = 12 + static let capsule: CGFloat = 27 + } +} + +struct FormTextField: ViewModifier { + func body(content: Content) -> some View { + content + .textFieldStyle(.plain) + .padding(DesignTheme.Spacing.md) + .background(Color(UIColor.systemGray6)) + .cornerRadius(DesignTheme.CornerRadius.medium) + .font(DesignTheme.Typography.body) + } +} + +struct FormSecureField: ViewModifier { + func body(content: Content) -> some View { + content + .textFieldStyle(.plain) + .padding(DesignTheme.Spacing.md) + .background(Color(UIColor.systemGray6)) + .cornerRadius(DesignTheme.CornerRadius.medium) + .font(DesignTheme.Typography.body) + } +} + +struct PrimaryButton: ViewModifier { + let isLoading: Bool + let isEnabled: Bool + + func body(content: Content) -> some View { + content + .frame(maxWidth: .infinity) + .frame(height: 54) + .foregroundColor(.white) + .background(DesignTheme.accentColor, in: .capsule) + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.3) + } +} + +extension View { + func formTextField() -> some View { + modifier(FormTextField()) + } + + func formSecureField() -> some View { + modifier(FormSecureField()) + } + + func primaryButton(isLoading: Bool = false, isEnabled: Bool = true) -> some View { + modifier(PrimaryButton(isLoading: isLoading, isEnabled: isEnabled)) + } +} diff --git a/iosApp/iosApp/DesignKit/FormMessages.swift b/iosApp/iosApp/DesignKit/FormMessages.swift new file mode 100644 index 0000000..e998cea --- /dev/null +++ b/iosApp/iosApp/DesignKit/FormMessages.swift @@ -0,0 +1,45 @@ +// +// FormMessages.swift +// iosApp +// +// Created by Данил Забинский on 21.05.2026. +// + +import SwiftUI + +struct FormErrorMessage: View { + let message: String + + var body: some View { + HStack(spacing: DesignTheme.Spacing.sm) { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(.red) + Text(message) + .font(DesignTheme.Typography.bodySmall) + .foregroundColor(.red) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(DesignTheme.Spacing.md) + .background(Color.red.opacity(0.08)) + .cornerRadius(DesignTheme.CornerRadius.small) + } +} + +struct FormInfoMessage: View { + let message: String + + var body: some View { + HStack(spacing: DesignTheme.Spacing.sm) { + Image(systemName: "info.circle.fill") + .font(.system(size: 13)) + .foregroundColor(DesignTheme.accentColorHex) + Text(message) + .font(DesignTheme.Typography.bodySmallest) + .foregroundColor(DesignTheme.accentColorHex.opacity(0.8)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(DesignTheme.Spacing.md) + .background(DesignTheme.accentColorHex.opacity(0.06)) + .cornerRadius(DesignTheme.CornerRadius.small) + } +} diff --git a/iosApp/iosApp/Modules/Login/LoginView.swift b/iosApp/iosApp/Modules/Login/LoginView.swift index 3940fd4..c8e3901 100644 --- a/iosApp/iosApp/Modules/Login/LoginView.swift +++ b/iosApp/iosApp/Modules/Login/LoginView.swift @@ -15,41 +15,78 @@ struct LoginView: View { @State private var isRegisterPresented = false var body: some View { - VStack(spacing: 16) { - TextField("Username", text: .init( - get: { reducer.username }, - set: { reducer.setUsername($0) } - )) - - SecureField("Password", text: .init( - get: { reducer.password }, - set: { reducer.setPassword($0) } - )) - - if let error = reducer.errorMessage { - Text(error).foregroundColor(.red).font(.caption) - } - - Button("Login") { - reducer.login() - } - .disabled(reducer.isLoading) - - Button("Register now") { - isRegisterPresented = true - } - - if reducer.isLoading { - ProgressView() + ScrollView { + VStack(spacing: DesignTheme.Spacing.xxxl) { + Image("OnboardingImage") + .resizable() + .scaledToFit() + .padding(.top, DesignTheme.Spacing.sm) + .padding(.horizontal, DesignTheme.Spacing.sm) + .frame(maxWidth: .infinity) + + VStack(spacing: DesignTheme.Spacing.lg) { + TextField("Username", text: .init( + get: { reducer.username }, + set: { reducer.setUsername($0) } + )) + .formTextField() + + SecureField("Password", text: .init( + get: { reducer.password }, + set: { reducer.setPassword($0) } + )) + .formSecureField() + + if let error = reducer.errorMessage { + FormErrorMessage(message: error) + } + } + .padding(.horizontal, DesignTheme.Spacing.xl) + + Button(action: { + reducer.login() + }) { + if reducer.isLoading { + HStack(spacing: DesignTheme.Spacing.sm) { + ProgressView() + .scaleEffect(0.9) + .tint(.white) + Text("Logging in...") + .font(DesignTheme.Typography.button) + } + } else { + Text("Login") + .font(DesignTheme.Typography.button) + } + } + .primaryButton(isLoading: reducer.isLoading, isEnabled: !(reducer.isLoading || reducer.username.isEmpty || reducer.password.isEmpty)) + .padding(.horizontal, DesignTheme.Spacing.xl) + + HStack(spacing: DesignTheme.Spacing.xs) { + Text("Don't have an account?") + .font(DesignTheme.Typography.caption) + .foregroundColor(.gray) + + Button(action: { isRegisterPresented = true }) { + Text("Sign up") + .font(DesignTheme.Typography.captionSemibold) + .foregroundColor(DesignTheme.accentColorHex) + } + } + .frame(maxWidth: .infinity, alignment: .center) + + Spacer(minLength: DesignTheme.Spacing.xl) } + .padding(.top, DesignTheme.Spacing.md) } .onAppear { reducer.onLoginSuccess = onLoginSuccess } .sheet(isPresented: $isRegisterPresented) { - RegisterView { + RegisterView(onRegisterSuccess: { isRegisterPresented = false - } + }) + .presentationDetents([.medium, .large]) } } } diff --git a/iosApp/iosApp/Modules/Register/RegisterView.swift b/iosApp/iosApp/Modules/Register/RegisterView.swift index 0c6cc9c..5cd3472 100644 --- a/iosApp/iosApp/Modules/Register/RegisterView.swift +++ b/iosApp/iosApp/Modules/Register/RegisterView.swift @@ -11,30 +11,81 @@ struct RegisterView: View { let onRegisterSuccess: () -> Void @State private var reducer = RegisterReducer() + @Environment(\.dismiss) var dismiss var body: some View { - VStack(spacing: 16) { - TextField("Username", text: .init( - get: { reducer.username }, - set: { reducer.setUsername($0) } - )) - - SecureField("Password", text: .init( - get: { reducer.password }, - set: { reducer.setPassword($0) } - )) - - if let error = reducer.errorMessage { - Text(error).foregroundColor(.red).font(.caption) - } - - Button("Register") { - reducer.register() - } - .disabled(reducer.isLoading) - - if reducer.isLoading { - ProgressView() + ScrollView { + VStack(spacing: DesignTheme.Spacing.xxl) { + HStack { + VStack(alignment: .leading, spacing: 6) { + Text("Create Account") + .font(DesignTheme.Typography.heading) + .foregroundColor(.black) + } + Spacer() + Button(action: { dismiss() }) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 28)) + .foregroundColor(.gray.opacity(0.5)) + } + } + .padding(.horizontal, DesignTheme.Spacing.xl) + .padding(.top, DesignTheme.Spacing.lg) + + VStack(spacing: DesignTheme.Spacing.lg) { + TextField("Username", text: .init( + get: { reducer.username }, + set: { reducer.setUsername($0) } + )) + .formTextField() + + SecureField("Password", text: .init( + get: { reducer.password }, + set: { reducer.setPassword($0) } + )) + .formSecureField() + + FormInfoMessage(message: "At least 8 characters") + + if let error = reducer.errorMessage { + FormErrorMessage(message: error) + } + } + .padding(.horizontal, DesignTheme.Spacing.xl) + + Button(action: { + reducer.register() + }) { + if reducer.isLoading { + HStack(spacing: DesignTheme.Spacing.sm) { + ProgressView() + .scaleEffect(0.9) + .tint(.white) + Text("Creating account...") + .font(DesignTheme.Typography.button) + } + } else { + Text("Create Account") + .font(DesignTheme.Typography.button) + } + } + .primaryButton(isLoading: reducer.isLoading, isEnabled: !(reducer.isLoading || reducer.username.isEmpty || reducer.password.isEmpty)) + .padding(.horizontal, DesignTheme.Spacing.xl) + + HStack(spacing: DesignTheme.Spacing.xs) { + Text("Already have an account?") + .font(DesignTheme.Typography.caption) + .foregroundColor(.gray) + + Button(action: { dismiss() }) { + Text("Login") + .font(DesignTheme.Typography.captionSemibold) + .foregroundColor(DesignTheme.accentColorHex) + } + } + .frame(maxWidth: .infinity, alignment: .center) + + Spacer(minLength: DesignTheme.Spacing.xl) } } .onAppear {