Aggiunge gestione account con cambio password su web e app.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,6 +13,22 @@ data class LoginRequest(val email: String, val password: String)
|
||||
|
||||
data class RefreshRequest(@Json(name = "refresh_token") val refreshToken: String)
|
||||
|
||||
data class ForgotPasswordRequest(val email: String)
|
||||
|
||||
data class ForgotPasswordResponse(val message: String)
|
||||
|
||||
data class UpdateAccountRequest(val name: String)
|
||||
|
||||
data class ChangePasswordRequest(
|
||||
@Json(name = "current_password") val currentPassword: String,
|
||||
val password: String,
|
||||
@Json(name = "password_confirmation") val passwordConfirmation: String,
|
||||
)
|
||||
|
||||
data class MessageResponse(val message: String)
|
||||
|
||||
data class ApiErrorResponse(val error: String? = null)
|
||||
|
||||
data class LoginResponse(
|
||||
val user: UserDto,
|
||||
@Json(name = "access_token") val accessToken: String,
|
||||
|
||||
+12
@@ -23,6 +23,18 @@ interface MatchLiveApi {
|
||||
@POST("auth/logout")
|
||||
suspend fun logout()
|
||||
|
||||
@POST("auth/password/forgot")
|
||||
suspend fun forgotPassword(@Body body: ForgotPasswordRequest): ForgotPasswordResponse
|
||||
|
||||
@GET("account")
|
||||
suspend fun account(): UserDto
|
||||
|
||||
@PATCH("account")
|
||||
suspend fun updateAccount(@Body body: UpdateAccountRequest): UserDto
|
||||
|
||||
@PATCH("account/password")
|
||||
suspend fun changePassword(@Body body: ChangePasswordRequest): MessageResponse
|
||||
|
||||
@GET("sports")
|
||||
suspend fun sports(): List<SportDto>
|
||||
|
||||
|
||||
+31
@@ -2,9 +2,13 @@ package com.matchlivetv.match_live_tv.data.repository
|
||||
|
||||
import com.matchlivetv.match_live_tv.core.StoredSession
|
||||
import com.matchlivetv.match_live_tv.core.TokenStore
|
||||
import com.matchlivetv.match_live_tv.data.api.ChangePasswordRequest
|
||||
import com.matchlivetv.match_live_tv.data.api.ForgotPasswordRequest
|
||||
import com.matchlivetv.match_live_tv.data.api.LoginRequest
|
||||
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
|
||||
import com.matchlivetv.match_live_tv.data.api.RefreshRequest
|
||||
import com.matchlivetv.match_live_tv.data.api.UpdateAccountRequest
|
||||
import com.matchlivetv.match_live_tv.domain.User
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
class AuthRepository(
|
||||
@@ -46,6 +50,33 @@ class AuthRepository(
|
||||
return session
|
||||
}
|
||||
|
||||
suspend fun forgotPassword(email: String): String {
|
||||
return api.forgotPassword(ForgotPasswordRequest(email.trim())).message
|
||||
}
|
||||
|
||||
suspend fun fetchAccount(): User = api.account().toDomain()
|
||||
|
||||
suspend fun updateName(name: String): User {
|
||||
val user = api.updateAccount(UpdateAccountRequest(name.trim())).toDomain()
|
||||
val stored = currentSession() ?: return user
|
||||
tokenStore.saveSession(stored.copy(user = user))
|
||||
return user
|
||||
}
|
||||
|
||||
suspend fun changePassword(
|
||||
currentPassword: String,
|
||||
password: String,
|
||||
passwordConfirmation: String,
|
||||
) {
|
||||
api.changePassword(
|
||||
ChangePasswordRequest(
|
||||
currentPassword = currentPassword,
|
||||
password = password,
|
||||
passwordConfirmation = passwordConfirmation,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun logout() {
|
||||
runCatching { api.logout() }
|
||||
tokenStore.clear()
|
||||
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
package com.matchlivetv.match_live_tv.ui.account
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.matchlivetv.match_live_tv.R
|
||||
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
|
||||
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AccountScreen(
|
||||
container: AppContainer,
|
||||
onBack: () -> Unit,
|
||||
onLoggedOut: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var email by remember { mutableStateOf("") }
|
||||
var role by remember { mutableStateOf("") }
|
||||
var name by remember { mutableStateOf("") }
|
||||
var currentPassword by remember { mutableStateOf("") }
|
||||
var newPassword by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
var loadingProfile by remember { mutableStateOf(true) }
|
||||
var savingProfile by remember { mutableStateOf(false) }
|
||||
var savingPassword by remember { mutableStateOf(false) }
|
||||
var loggingOut by remember { mutableStateOf(false) }
|
||||
var profileMessage by remember { mutableStateOf<String?>(null) }
|
||||
var profileError by remember { mutableStateOf<String?>(null) }
|
||||
var passwordMessage by remember { mutableStateOf<String?>(null) }
|
||||
var passwordError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val errGeneric = stringResource(R.string.common_error_generic)
|
||||
val profileSaved = stringResource(R.string.account_profile_saved)
|
||||
val passwordSaved = stringResource(R.string.account_password_saved)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { container.authRepository.fetchAccount() }
|
||||
.onSuccess { user ->
|
||||
email = user.email
|
||||
name = user.name
|
||||
role = user.role
|
||||
}
|
||||
.onFailure {
|
||||
profileError = apiErrorMessage(it) ?: errGeneric
|
||||
}
|
||||
loadingProfile = false
|
||||
}
|
||||
|
||||
MatchScreenScaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.account_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.common_close),
|
||||
tint = MatchColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MatchColors.Background,
|
||||
titleContentColor = Color.White,
|
||||
),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.account_profile_heading),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color.White,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = {},
|
||||
enabled = false,
|
||||
label = { Text(stringResource(R.string.login_email)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = accountFieldColors(),
|
||||
)
|
||||
if (role.isNotBlank()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.account_role, role),
|
||||
color = MatchColors.TextSecondary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text(stringResource(R.string.account_name_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
enabled = !loadingProfile && !savingProfile,
|
||||
colors = accountFieldColors(),
|
||||
)
|
||||
profileError?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, color = MatchColors.PrimaryRed)
|
||||
}
|
||||
profileMessage?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, color = MatchColors.TextSecondary)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
MatchPrimaryButton(
|
||||
label = stringResource(R.string.account_save_profile),
|
||||
loading = savingProfile,
|
||||
enabled = !loadingProfile && name.isNotBlank() && !savingProfile,
|
||||
onClick = {
|
||||
savingProfile = true
|
||||
profileError = null
|
||||
profileMessage = null
|
||||
scope.launch {
|
||||
runCatching { container.authRepository.updateName(name) }
|
||||
.onSuccess {
|
||||
name = it.name
|
||||
profileMessage = profileSaved
|
||||
}
|
||||
.onFailure {
|
||||
profileError = apiErrorMessage(it) ?: errGeneric
|
||||
}
|
||||
savingProfile = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.account_password_heading),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color.White,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
PasswordField(
|
||||
value = currentPassword,
|
||||
onValueChange = { currentPassword = it },
|
||||
label = stringResource(R.string.account_current_password),
|
||||
visible = passwordVisible,
|
||||
onToggleVisible = { passwordVisible = !passwordVisible },
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
PasswordField(
|
||||
value = newPassword,
|
||||
onValueChange = { newPassword = it },
|
||||
label = stringResource(R.string.account_new_password),
|
||||
visible = passwordVisible,
|
||||
onToggleVisible = { passwordVisible = !passwordVisible },
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
PasswordField(
|
||||
value = confirmPassword,
|
||||
onValueChange = { confirmPassword = it },
|
||||
label = stringResource(R.string.account_confirm_password),
|
||||
visible = passwordVisible,
|
||||
onToggleVisible = { passwordVisible = !passwordVisible },
|
||||
)
|
||||
passwordError?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, color = MatchColors.PrimaryRed)
|
||||
}
|
||||
passwordMessage?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, color = MatchColors.TextSecondary)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
MatchPrimaryButton(
|
||||
label = stringResource(R.string.account_save_password),
|
||||
loading = savingPassword,
|
||||
enabled = currentPassword.isNotBlank() &&
|
||||
newPassword.isNotBlank() &&
|
||||
confirmPassword.isNotBlank() &&
|
||||
!savingPassword,
|
||||
onClick = {
|
||||
savingPassword = true
|
||||
passwordError = null
|
||||
passwordMessage = null
|
||||
scope.launch {
|
||||
runCatching {
|
||||
container.authRepository.changePassword(
|
||||
currentPassword = currentPassword,
|
||||
password = newPassword,
|
||||
passwordConfirmation = confirmPassword,
|
||||
)
|
||||
}.onSuccess {
|
||||
currentPassword = ""
|
||||
newPassword = ""
|
||||
confirmPassword = ""
|
||||
passwordMessage = passwordSaved
|
||||
}.onFailure {
|
||||
passwordError = apiErrorMessage(it) ?: errGeneric
|
||||
}
|
||||
savingPassword = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(40.dp))
|
||||
MatchSecondaryButton(
|
||||
label = stringResource(R.string.action_logout),
|
||||
enabled = !loggingOut,
|
||||
onClick = {
|
||||
loggingOut = true
|
||||
scope.launch {
|
||||
container.authRepository.logout()
|
||||
onLoggedOut()
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
visible: Boolean,
|
||||
onToggleVisible: () -> Unit,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(label) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = onToggleVisible) {
|
||||
Icon(
|
||||
imageVector = if (visible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
|
||||
contentDescription = null,
|
||||
tint = MatchColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
colors = accountFieldColors(),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun accountFieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White,
|
||||
disabledTextColor = MatchColors.TextSecondary,
|
||||
focusedBorderColor = MatchColors.PrimaryRed,
|
||||
unfocusedBorderColor = MatchColors.Outline,
|
||||
disabledBorderColor = MatchColors.Outline,
|
||||
focusedLabelColor = MatchColors.PrimaryRed,
|
||||
unfocusedLabelColor = MatchColors.TextSecondary,
|
||||
disabledLabelColor = MatchColors.TextSecondary,
|
||||
cursorColor = MatchColors.PrimaryRed,
|
||||
)
|
||||
|
||||
private fun apiErrorMessage(error: Throwable): String? {
|
||||
if (error is HttpException) {
|
||||
val body = error.response()?.errorBody()?.string().orEmpty()
|
||||
val match = Regex("\"error\"\\s*:\\s*\"([^\"]+)\"").find(body)
|
||||
if (match != null) return match.groupValues[1]
|
||||
}
|
||||
return error.message
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package com.matchlivetv.match_live_tv.ui.login
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.matchlivetv.match_live_tv.R
|
||||
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
|
||||
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ForgotPasswordScreen(
|
||||
container: AppContainer,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var email by remember { mutableStateOf("") }
|
||||
var loading by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var success by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val errGeneric = stringResource(R.string.common_error_generic)
|
||||
val successMessage = stringResource(R.string.forgot_password_success)
|
||||
|
||||
MatchScreenScaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.forgot_password_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.common_close),
|
||||
tint = MatchColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MatchColors.Background,
|
||||
titleContentColor = Color.White,
|
||||
),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.forgot_password_lead),
|
||||
color = MatchColors.TextSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = { Text(stringResource(R.string.login_email)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
enabled = !success,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { }),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White,
|
||||
focusedBorderColor = MatchColors.PrimaryRed,
|
||||
unfocusedBorderColor = MatchColors.Outline,
|
||||
focusedLabelColor = MatchColors.PrimaryRed,
|
||||
unfocusedLabelColor = MatchColors.TextSecondary,
|
||||
cursorColor = MatchColors.PrimaryRed,
|
||||
),
|
||||
)
|
||||
error?.let {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(it, color = MatchColors.PrimaryRed, textAlign = TextAlign.Center)
|
||||
}
|
||||
if (success) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(successMessage, color = MatchColors.TextSecondary, textAlign = TextAlign.Center)
|
||||
}
|
||||
Spacer(Modifier.height(32.dp))
|
||||
MatchPrimaryButton(
|
||||
label = stringResource(R.string.forgot_password_submit),
|
||||
loading = loading,
|
||||
enabled = email.isNotBlank() && !loading && !success,
|
||||
onClick = {
|
||||
loading = true
|
||||
error = null
|
||||
scope.launch {
|
||||
runCatching { container.authRepository.forgotPassword(email) }
|
||||
.onSuccess { success = true }
|
||||
.onFailure { error = it.message ?: errGeneric }
|
||||
loading = false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -1,5 +1,6 @@
|
||||
package com.matchlivetv.match_live_tv.ui.login
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -38,6 +39,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.matchlivetv.match_live_tv.R
|
||||
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||
@@ -53,6 +55,7 @@ import kotlinx.coroutines.launch
|
||||
fun LoginScreen(
|
||||
container: AppContainer,
|
||||
onLoggedIn: () -> Unit,
|
||||
onForgotPassword: () -> Unit,
|
||||
) {
|
||||
var email by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
@@ -170,6 +173,16 @@ fun LoginScreen(
|
||||
keyboardActions = KeyboardActions(onDone = { submitLogin() }),
|
||||
colors = matchTextFieldColors(),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.login_forgot_password),
|
||||
color = MatchColors.TextSecondary,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.clickable(onClick = onForgotPassword)
|
||||
.padding(vertical = 4.dp),
|
||||
)
|
||||
error?.let {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
|
||||
+5
-10
@@ -17,9 +17,9 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.DeleteOutline
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -67,7 +67,7 @@ fun MatchesScreen(
|
||||
container: AppContainer,
|
||||
onOpenSetup: (matchId: String) -> Unit,
|
||||
onOpenBroadcast: (sessionId: String) -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onOpenAccount: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -179,15 +179,10 @@ fun MatchesScreen(
|
||||
tint = MatchColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
container.authRepository.logout()
|
||||
onLogout()
|
||||
}
|
||||
}) {
|
||||
IconButton(onClick = onOpenAccount) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Logout,
|
||||
contentDescription = stringResource(R.string.action_logout),
|
||||
imageVector = Icons.Filled.Person,
|
||||
contentDescription = stringResource(R.string.account_title),
|
||||
tint = MatchColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
+30
-7
@@ -7,7 +7,9 @@ import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||
import com.matchlivetv.match_live_tv.ui.account.AccountScreen
|
||||
import com.matchlivetv.match_live_tv.ui.broadcast.BroadcastScreen
|
||||
import com.matchlivetv.match_live_tv.ui.login.ForgotPasswordScreen
|
||||
import com.matchlivetv.match_live_tv.ui.login.LoginScreen
|
||||
import com.matchlivetv.match_live_tv.ui.matches.MatchesScreen
|
||||
import com.matchlivetv.match_live_tv.ui.splash.SplashScreen
|
||||
@@ -34,11 +36,23 @@ fun AppNavHost(container: AppContainer) {
|
||||
)
|
||||
}
|
||||
composable(Routes.Login) {
|
||||
LoginScreen(container) {
|
||||
navController.navigate(Routes.Matches) {
|
||||
popUpTo(Routes.Login) { inclusive = true }
|
||||
}
|
||||
}
|
||||
LoginScreen(
|
||||
container = container,
|
||||
onLoggedIn = {
|
||||
navController.navigate(Routes.Matches) {
|
||||
popUpTo(Routes.Login) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onForgotPassword = {
|
||||
navController.navigate(Routes.ForgotPassword)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Routes.ForgotPassword) {
|
||||
ForgotPasswordScreen(
|
||||
container = container,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(Routes.Matches) {
|
||||
MatchesScreen(
|
||||
@@ -49,9 +63,18 @@ fun AppNavHost(container: AppContainer) {
|
||||
onOpenBroadcast = { sessionId ->
|
||||
navController.navigate(Routes.broadcast(sessionId))
|
||||
},
|
||||
onLogout = {
|
||||
onOpenAccount = {
|
||||
navController.navigate(Routes.Account)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Routes.Account) {
|
||||
AccountScreen(
|
||||
container = container,
|
||||
onBack = { navController.popBackStack() },
|
||||
onLoggedOut = {
|
||||
navController.navigate(Routes.Login) {
|
||||
popUpTo(Routes.Matches) { inclusive = true }
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
+2
@@ -3,7 +3,9 @@ package com.matchlivetv.match_live_tv.ui.navigation
|
||||
object Routes {
|
||||
const val Splash = "splash"
|
||||
const val Login = "login"
|
||||
const val ForgotPassword = "forgot_password"
|
||||
const val Matches = "matches"
|
||||
const val Account = "account"
|
||||
const val Setup = "setup/{matchId}/{step}"
|
||||
const val Broadcast = "broadcast/{sessionId}"
|
||||
|
||||
|
||||
@@ -241,4 +241,21 @@ Dies kann nicht rückgängig gemacht werden.</string>
|
||||
<string name="login_email_placeholder">coach@team.com</string>
|
||||
<string name="action_delete">Löschen</string>
|
||||
<string name="wizard_color_hue">Farbton</string>
|
||||
<string name="login_forgot_password">Passwort vergessen?</string>
|
||||
<string name="forgot_password_title">Passwort vergessen</string>
|
||||
<string name="forgot_password_lead">Gib die E-Mail des Kontos ein: Wir senden dir einen Link zum Zurücksetzen des Passworts.</string>
|
||||
<string name="forgot_password_submit">Reset-Link senden</string>
|
||||
<string name="forgot_password_success">Wenn die E-Mail registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen.</string>
|
||||
<string name="account_title">Konto</string>
|
||||
<string name="account_profile_heading">Profil</string>
|
||||
<string name="account_password_heading">Passwort ändern</string>
|
||||
<string name="account_name_label">Name</string>
|
||||
<string name="account_role">Rolle: %1$s</string>
|
||||
<string name="account_current_password">Aktuelles Passwort</string>
|
||||
<string name="account_new_password">Neues Passwort (mind. 8 Zeichen)</string>
|
||||
<string name="account_confirm_password">Passwort bestätigen</string>
|
||||
<string name="account_save_profile">Profil speichern</string>
|
||||
<string name="account_save_password">Passwort aktualisieren</string>
|
||||
<string name="account_profile_saved">Profil aktualisiert</string>
|
||||
<string name="account_password_saved">Passwort aktualisiert</string>
|
||||
</resources>
|
||||
|
||||
@@ -241,4 +241,21 @@ This cannot be undone.</string>
|
||||
<string name="login_email_placeholder">coach@team.com</string>
|
||||
<string name="action_delete">Delete</string>
|
||||
<string name="wizard_color_hue">Hue</string>
|
||||
<string name="login_forgot_password">Forgot password?</string>
|
||||
<string name="forgot_password_title">Forgot password</string>
|
||||
<string name="forgot_password_lead">Enter your account email: we will send you a link to reset your password.</string>
|
||||
<string name="forgot_password_submit">Send reset link</string>
|
||||
<string name="forgot_password_success">If the email is registered, you will receive a password reset link shortly.</string>
|
||||
<string name="account_title">Account</string>
|
||||
<string name="account_profile_heading">Profile</string>
|
||||
<string name="account_password_heading">Change password</string>
|
||||
<string name="account_name_label">Name</string>
|
||||
<string name="account_role">Role: %1$s</string>
|
||||
<string name="account_current_password">Current password</string>
|
||||
<string name="account_new_password">New password (min. 8 characters)</string>
|
||||
<string name="account_confirm_password">Confirm password</string>
|
||||
<string name="account_save_profile">Save profile</string>
|
||||
<string name="account_save_password">Update password</string>
|
||||
<string name="account_profile_saved">Profile updated</string>
|
||||
<string name="account_password_saved">Password updated</string>
|
||||
</resources>
|
||||
|
||||
@@ -241,4 +241,21 @@ Esta acción no se puede deshacer.</string>
|
||||
<string name="login_email_placeholder">coach@equipo.com</string>
|
||||
<string name="action_delete">Eliminar</string>
|
||||
<string name="wizard_color_hue">Tono</string>
|
||||
<string name="login_forgot_password">¿Olvidaste la contraseña?</string>
|
||||
<string name="forgot_password_title">Contraseña olvidada</string>
|
||||
<string name="forgot_password_lead">Introduce el email de la cuenta: te enviaremos un enlace para restablecer la contraseña.</string>
|
||||
<string name="forgot_password_submit">Enviar enlace</string>
|
||||
<string name="forgot_password_success">Si el email está registrado, recibirás en breve un enlace para restablecer la contraseña.</string>
|
||||
<string name="account_title">Cuenta</string>
|
||||
<string name="account_profile_heading">Perfil</string>
|
||||
<string name="account_password_heading">Cambiar contraseña</string>
|
||||
<string name="account_name_label">Nombre</string>
|
||||
<string name="account_role">Rol: %1$s</string>
|
||||
<string name="account_current_password">Contraseña actual</string>
|
||||
<string name="account_new_password">Nueva contraseña (mín. 8 caracteres)</string>
|
||||
<string name="account_confirm_password">Confirmar contraseña</string>
|
||||
<string name="account_save_profile">Guardar perfil</string>
|
||||
<string name="account_save_password">Actualizar contraseña</string>
|
||||
<string name="account_profile_saved">Perfil actualizado</string>
|
||||
<string name="account_password_saved">Contraseña actualizada</string>
|
||||
</resources>
|
||||
|
||||
@@ -241,4 +241,21 @@ Cette action est irréversible.</string>
|
||||
<string name="login_email_placeholder">coach@equipe.com</string>
|
||||
<string name="action_delete">Supprimer</string>
|
||||
<string name="wizard_color_hue">Teinte</string>
|
||||
<string name="login_forgot_password">Mot de passe oublié ?</string>
|
||||
<string name="forgot_password_title">Mot de passe oublié</string>
|
||||
<string name="forgot_password_lead">Saisissez l’e-mail du compte : nous vous enverrons un lien pour réinitialiser le mot de passe.</string>
|
||||
<string name="forgot_password_submit">Envoyer le lien</string>
|
||||
<string name="forgot_password_success">Si l’e-mail est enregistré, vous recevrez bientôt un lien de réinitialisation.</string>
|
||||
<string name="account_title">Compte</string>
|
||||
<string name="account_profile_heading">Profil</string>
|
||||
<string name="account_password_heading">Changer le mot de passe</string>
|
||||
<string name="account_name_label">Nom</string>
|
||||
<string name="account_role">Rôle : %1$s</string>
|
||||
<string name="account_current_password">Mot de passe actuel</string>
|
||||
<string name="account_new_password">Nouveau mot de passe (min. 8 caractères)</string>
|
||||
<string name="account_confirm_password">Confirmer le mot de passe</string>
|
||||
<string name="account_save_profile">Enregistrer le profil</string>
|
||||
<string name="account_save_password">Mettre à jour le mot de passe</string>
|
||||
<string name="account_profile_saved">Profil mis à jour</string>
|
||||
<string name="account_password_saved">Mot de passe mis à jour</string>
|
||||
</resources>
|
||||
|
||||
@@ -241,4 +241,21 @@ L\'operazione non si può annullare.</string>
|
||||
<string name="login_email_placeholder">coach@team.com</string>
|
||||
<string name="action_delete">Elimina</string>
|
||||
<string name="wizard_color_hue">Tonalità</string>
|
||||
<string name="login_forgot_password">Password dimenticata?</string>
|
||||
<string name="forgot_password_title">Password dimenticata</string>
|
||||
<string name="forgot_password_lead">Inserisci l’email dell’account: ti invieremo un link per reimpostare la password.</string>
|
||||
<string name="forgot_password_submit">Invia link di reset</string>
|
||||
<string name="forgot_password_success">Se l’email è registrata, riceverai a breve un link per reimpostare la password.</string>
|
||||
<string name="account_title">Account</string>
|
||||
<string name="account_profile_heading">Profilo</string>
|
||||
<string name="account_password_heading">Cambia password</string>
|
||||
<string name="account_name_label">Nome</string>
|
||||
<string name="account_role">Ruolo: %1$s</string>
|
||||
<string name="account_current_password">Password attuale</string>
|
||||
<string name="account_new_password">Nuova password (min. 8 caratteri)</string>
|
||||
<string name="account_confirm_password">Conferma password</string>
|
||||
<string name="account_save_profile">Salva profilo</string>
|
||||
<string name="account_save_password">Aggiorna password</string>
|
||||
<string name="account_profile_saved">Profilo aggiornato</string>
|
||||
<string name="account_password_saved">Password aggiornata</string>
|
||||
</resources>
|
||||
|
||||
@@ -55,6 +55,8 @@
|
||||
D7434175C1E849D7A8308896 /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = "<group>"; };
|
||||
3DE0F41386A741B2A6FC538B /* MatchSecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSecondaryButton.swift; path = MatchLiveTv/UI/Components/MatchSecondaryButton.swift; sourceTree = "<group>"; };
|
||||
1F6A499FE7D94EF498DA9A84 /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = "<group>"; };
|
||||
E13972C3F4664A89AE13D5D8 /* AccountScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AccountScreen.swift; path = MatchLiveTv/UI/Account/AccountScreen.swift; sourceTree = "<group>"; };
|
||||
DDF38B8E56394DEBA022F812 /* ForgotPasswordScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ForgotPasswordScreen.swift; path = MatchLiveTv/UI/Login/ForgotPasswordScreen.swift; sourceTree = "<group>"; };
|
||||
DA51BC65490E421AA3D16F02 /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LoginScreen.swift; path = MatchLiveTv/UI/Login/LoginScreen.swift; sourceTree = "<group>"; };
|
||||
0A439BB96CCB4C038EEF44DD /* MatchesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchesScreen.swift; path = MatchLiveTv/UI/Matches/MatchesScreen.swift; sourceTree = "<group>"; };
|
||||
5DD00428364442C6B8C55DDF /* AppNavHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppNavHost.swift; path = MatchLiveTv/UI/Navigation/AppNavHost.swift; sourceTree = "<group>"; };
|
||||
@@ -143,6 +145,8 @@
|
||||
D71CB837FE844932AA590D30 /* MatchScreenScaffold.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7434175C1E849D7A8308896 /* MatchScreenScaffold.swift */; };
|
||||
52806453AD4C4E59BA2F05DE /* MatchSecondaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE0F41386A741B2A6FC538B /* MatchSecondaryButton.swift */; };
|
||||
FA601701F8684EE2BFDBBBFD /* MatchStatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6A499FE7D94EF498DA9A84 /* MatchStatusBadge.swift */; };
|
||||
A85ED43E903E4983B1438D11 /* AccountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13972C3F4664A89AE13D5D8 /* AccountScreen.swift */; };
|
||||
195AF301A0AC4BA58F8EDE95 /* ForgotPasswordScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF38B8E56394DEBA022F812 /* ForgotPasswordScreen.swift */; };
|
||||
8C0225C7F87A4F9A8A0C2919 /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA51BC65490E421AA3D16F02 /* LoginScreen.swift */; };
|
||||
7293CD3C1B634333A59FA782 /* MatchesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A439BB96CCB4C038EEF44DD /* MatchesScreen.swift */; };
|
||||
465980490F2D417E87241EBA /* AppNavHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DD00428364442C6B8C55DDF /* AppNavHost.swift */; };
|
||||
@@ -176,10 +180,10 @@
|
||||
9246A8532B66443A83535192 /* de.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 85B0F928887C44128434974E; };
|
||||
5DA941A5928A4F638223030C /* es.lproj in Resources */ = {isa = PBXBuildFile; fileRef = E907C350C3E5410FB4A2038D; };
|
||||
B6A69164398F4F9FB84EC475 = {isa = PBXGroup; children = (86BA4622CE11450BAD7902B9, E068E1D4709041789B1E8F5A, 2165E0C34A8B43F28AC2B21C); sourceTree = "<group>"; };
|
||||
86BA4622CE11450BAD7902B9 = {isa = PBXGroup; children = (4FC590728C2F47DE94AEADA3, 69F95F7CAF894139BA244AFF, 5E30D25B02704FAE9C0A4040, A59093CB86BB41FE9DF37182, C00F6C82C9A04A50B1D34BA3, 6F83B04D0BC84879A07C9BFE, 33DEE4675BAB413E9F69549B, 14FE2B5F844A482C91245755, AA9DF2F1C5F04EA198286266, 8EEFEB75DDB04C4986774D4B, C84694361A004982B4007CA5, 1AD71EE7F4A54818A6C427C9, 610902FBCEB7478286C88C08, DD9687B43B5840DD96FAD1C9, 905A6416560A4442AA7D2FCF, 9299E98557E04C5F8DC736F8, 49E1D454901048A58DCE7C41, A90696A40A584C5794938FDA, 2C3F8293D48D44A6A569F452, 347DADA50AE44484ABF19903, 69794703A6D74DB9AA3D16A2, D274E6DA2B7B47C9822FB517, E4BAEE4AB7834E19BA57C4A1, 1060DB32269548BE895EFA7F, CA265A4F2CAA41DA8F68A35C, 0381775B0B1D4378A8BF289A, 85D4CBF80A8F447790235E7C, 2EA97648294C4D98B3D85EFD, FA4A1042B44B41E0AE768E0C, 97DF04B6B21743BC9615E634, 1B031A4D51634E96BBD3D8DA, BC442280384D4194A66F25D2, EF6E8429A9DF475E9C964A2A, AA419130C5A248DCA3B4A016, 8CF76F8D690C405D9FCC4B8E, E1A1C8A803FB4CA1BA741E57, 195EFB33D08D484489FD721B, DB0DF408F60149008C9644C0, E9F0583912AA42F787751A7F, FB19CB746D6146D9ABD1A1C4, CD8C48EA12DB45FF978FA049, 838A41F3BF1E42BDAA2CE2E6, 2074992AD4E94430B9524FB3, B51507FF41FB4B80831D70E3, 8EA7E4972BB74FDD884370D0, CC2B124ACD024538B20863B8, 31B0AEBA3A0140D2BC9698DC, 0C84054AB3574CC5B8056694, D7434175C1E849D7A8308896, 3DE0F41386A741B2A6FC538B, 1F6A499FE7D94EF498DA9A84, DA51BC65490E421AA3D16F02, 0A439BB96CCB4C038EEF44DD, 5DD00428364442C6B8C55DDF, 9F958AB307244896BD8948D2, 21C2A6AB0E164C8CBDF9CF7F, 1702AF57020940DEBF0F9BA0, 3004C07E608644AFB636C0C5, DC39718A2EC74B078A0C5DE9, BCEB22897600469E93762209, 13BC17CE70034FEE97707B21, AAAD219E4FA94CF885472711, 29F84FF3F1E74E49A3EA5101, 1875AE0A77B94DF0921EE457, 3168221266004A33BA35042C, C97603C3A54342799CBD0C03, 5AFEAFB3E8F047B9A2B0DD04, CE7A5A0E6D9A49EA95918E79, 68CB1416AC24491CAA4CE87A, F68654552B254000B6F0BE97, 1255FF9134D246ECBE1DF72C, 6DFD25C0855F482DBEFD8757, 12C3FA28B28146B9A8E3A70A, 5D760AAFB9C34DACBFC2387A, 85B0F928887C44128434974E, E907C350C3E5410FB4A2038D); name = MatchLiveTv; sourceTree = "<group>"; };
|
||||
86BA4622CE11450BAD7902B9 = {isa = PBXGroup; children = (4FC590728C2F47DE94AEADA3, 69F95F7CAF894139BA244AFF, 5E30D25B02704FAE9C0A4040, A59093CB86BB41FE9DF37182, C00F6C82C9A04A50B1D34BA3, 6F83B04D0BC84879A07C9BFE, 33DEE4675BAB413E9F69549B, 14FE2B5F844A482C91245755, AA9DF2F1C5F04EA198286266, 8EEFEB75DDB04C4986774D4B, C84694361A004982B4007CA5, 1AD71EE7F4A54818A6C427C9, 610902FBCEB7478286C88C08, DD9687B43B5840DD96FAD1C9, 905A6416560A4442AA7D2FCF, 9299E98557E04C5F8DC736F8, 49E1D454901048A58DCE7C41, A90696A40A584C5794938FDA, 2C3F8293D48D44A6A569F452, 347DADA50AE44484ABF19903, 69794703A6D74DB9AA3D16A2, D274E6DA2B7B47C9822FB517, E4BAEE4AB7834E19BA57C4A1, 1060DB32269548BE895EFA7F, CA265A4F2CAA41DA8F68A35C, 0381775B0B1D4378A8BF289A, 85D4CBF80A8F447790235E7C, 2EA97648294C4D98B3D85EFD, FA4A1042B44B41E0AE768E0C, 97DF04B6B21743BC9615E634, 1B031A4D51634E96BBD3D8DA, BC442280384D4194A66F25D2, EF6E8429A9DF475E9C964A2A, AA419130C5A248DCA3B4A016, 8CF76F8D690C405D9FCC4B8E, E1A1C8A803FB4CA1BA741E57, 195EFB33D08D484489FD721B, DB0DF408F60149008C9644C0, E9F0583912AA42F787751A7F, FB19CB746D6146D9ABD1A1C4, CD8C48EA12DB45FF978FA049, 838A41F3BF1E42BDAA2CE2E6, 2074992AD4E94430B9524FB3, B51507FF41FB4B80831D70E3, 8EA7E4972BB74FDD884370D0, CC2B124ACD024538B20863B8, 31B0AEBA3A0140D2BC9698DC, 0C84054AB3574CC5B8056694, D7434175C1E849D7A8308896, 3DE0F41386A741B2A6FC538B, 1F6A499FE7D94EF498DA9A84, E13972C3F4664A89AE13D5D8, DDF38B8E56394DEBA022F812, DA51BC65490E421AA3D16F02, 0A439BB96CCB4C038EEF44DD, 5DD00428364442C6B8C55DDF, 9F958AB307244896BD8948D2, 21C2A6AB0E164C8CBDF9CF7F, 1702AF57020940DEBF0F9BA0, 3004C07E608644AFB636C0C5, DC39718A2EC74B078A0C5DE9, BCEB22897600469E93762209, 13BC17CE70034FEE97707B21, AAAD219E4FA94CF885472711, 29F84FF3F1E74E49A3EA5101, 1875AE0A77B94DF0921EE457, 3168221266004A33BA35042C, C97603C3A54342799CBD0C03, 5AFEAFB3E8F047B9A2B0DD04, CE7A5A0E6D9A49EA95918E79, 68CB1416AC24491CAA4CE87A, F68654552B254000B6F0BE97, 1255FF9134D246ECBE1DF72C, 6DFD25C0855F482DBEFD8757, 12C3FA28B28146B9A8E3A70A, 5D760AAFB9C34DACBFC2387A, 85B0F928887C44128434974E, E907C350C3E5410FB4A2038D); name = MatchLiveTv; sourceTree = "<group>"; };
|
||||
E068E1D4709041789B1E8F5A = {isa = PBXGroup; children = (73BB032A0DD74458B0E716C6, C186E88B35A0443494AE6E8E, 5751DB993C52460D960B8067, 0E243C00009048F6A778323C, 634AD5C2E53B4C098582284A, B7DE055717014393B3F62D5A, C3C1AC6562ED4A908D7C8B0A, 610A4D03FA384F0287AAF825); name = MatchLiveTvTests; sourceTree = "<group>"; };
|
||||
2165E0C34A8B43F28AC2B21C = {isa = PBXGroup; children = (6FF24BB96E9C40F6A9F6E25A, 8F679C9C03AE47D6A028DBAC); name = Products; sourceTree = "<group>"; };
|
||||
3B35A518C56047A48825F0F8 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (F7624153A1C84C218B602FFB, D973F2B503854C6AA61A1559, FE2D8C8359CC4DA189DF5DC6, B607A146AE754F6A819FB186, B0D93B493FD0427CA72F5862, 3C223B19BA2149C5B7E5CC5A, 3D119DE97DE5452D903F364C, 8ABF00BDC15C400A9089FC28, 7D2649B9131F4DDA83F68B72, AB71730B7CA24D3E8667BCFF, ADA58448C2344DADAA90F212, 95F7871858C44379A62012BA, 3FBBEF8DFF5E4F5D9E34C860, 91694276C76B438F8B52448F, 51D849AFAC8D4F57BEF05CAB, D771BABE58A3439CA1768F7F, 23C149E317BD416D9CF0B171, 43C57FF23CB347AC99284468, 8661E674C82545C4AEC0A7CE, E133D004CF0A4FF7A51A47B1, 72090ACA64CB4885BE7AB333, D80261301CA740568D8807D2, 9ECD8B486DF84890BD39B108, C217B8AD49D04976A6D7FC7E, 978AAF829F654537AC473F52, 032C790773A848B7A0434C67, A37BC192D6C9433F83FABAA8, 99F1D8A337EE46C8BDD34F60, F024262D0D0F4B759E1A4F14, FE9E0B76650246E490722317, BD642A0BD19D424985FBF1A3, B48421E7BD5D48E499948E2E, C6207D98BB484493BEDD4BBB, E715CA8C60E6439683E1DA0E, 35F5E2E5FB0D4B9FA475ADA4, 6D8B0F1792E440C19F49AD03, CC563D93928C438AB0B78E99, 9343740F4EB94F609A4D1A51, D71C1DFF234742DA81D23AF7, DADD5004759C409F82D4FEE2, 957381C0C3A447E7A3240ACD, 326AEF0FBFE74201BE70D2DD, DCC6F79099C641DF9EA6F36F, CA82A2382C4A45BE88276507, 37E8D25C18014E0C8139D84D, 67A37D0BB28F4BD1872BACA6, 7AA96446B33C4ECCB90DDFD0, 599290557AE4430EA30582D7, D71CB837FE844932AA590D30, 52806453AD4C4E59BA2F05DE, FA601701F8684EE2BFDBBBFD, 8C0225C7F87A4F9A8A0C2919, 7293CD3C1B634333A59FA782, 465980490F2D417E87241EBA, 044E04EEE71D472EA524C555, 52CEBF8B18834CB8B650FE16, 94ADCCD464DD43149D0C0E60, F23C2967ED7140EB8DCB2D2A, 47AD4ADC4ACE4F6DA7AD708C, 8B0A9A9DBAF346199EBA21B3, D826E98233304F5FB27B27C3, 579A3A8D49DF43E7B11942C6, D0BEA6E726C04A95A8CC0018, 15DA64BB40B14C9B899A3729, 4DF6B21EFDD54DF999385511, AEF63662B6B5454F92986710, 78179BA533074F1ABBB7391E, 45E14846E79845CE9C20C5EC, 2049733B63794135A4BFBB53); runOnlyForDeploymentPostprocessing = 0; };
|
||||
3B35A518C56047A48825F0F8 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (F7624153A1C84C218B602FFB, D973F2B503854C6AA61A1559, FE2D8C8359CC4DA189DF5DC6, B607A146AE754F6A819FB186, B0D93B493FD0427CA72F5862, 3C223B19BA2149C5B7E5CC5A, 3D119DE97DE5452D903F364C, 8ABF00BDC15C400A9089FC28, 7D2649B9131F4DDA83F68B72, AB71730B7CA24D3E8667BCFF, ADA58448C2344DADAA90F212, 95F7871858C44379A62012BA, 3FBBEF8DFF5E4F5D9E34C860, 91694276C76B438F8B52448F, 51D849AFAC8D4F57BEF05CAB, D771BABE58A3439CA1768F7F, 23C149E317BD416D9CF0B171, 43C57FF23CB347AC99284468, 8661E674C82545C4AEC0A7CE, E133D004CF0A4FF7A51A47B1, 72090ACA64CB4885BE7AB333, D80261301CA740568D8807D2, 9ECD8B486DF84890BD39B108, C217B8AD49D04976A6D7FC7E, 978AAF829F654537AC473F52, 032C790773A848B7A0434C67, A37BC192D6C9433F83FABAA8, 99F1D8A337EE46C8BDD34F60, F024262D0D0F4B759E1A4F14, FE9E0B76650246E490722317, BD642A0BD19D424985FBF1A3, B48421E7BD5D48E499948E2E, C6207D98BB484493BEDD4BBB, E715CA8C60E6439683E1DA0E, 35F5E2E5FB0D4B9FA475ADA4, 6D8B0F1792E440C19F49AD03, CC563D93928C438AB0B78E99, 9343740F4EB94F609A4D1A51, D71C1DFF234742DA81D23AF7, DADD5004759C409F82D4FEE2, 957381C0C3A447E7A3240ACD, 326AEF0FBFE74201BE70D2DD, DCC6F79099C641DF9EA6F36F, CA82A2382C4A45BE88276507, 37E8D25C18014E0C8139D84D, 67A37D0BB28F4BD1872BACA6, 7AA96446B33C4ECCB90DDFD0, 599290557AE4430EA30582D7, D71CB837FE844932AA590D30, 52806453AD4C4E59BA2F05DE, FA601701F8684EE2BFDBBBFD, A85ED43E903E4983B1438D11, 195AF301A0AC4BA58F8EDE95, 8C0225C7F87A4F9A8A0C2919, 7293CD3C1B634333A59FA782, 465980490F2D417E87241EBA, 044E04EEE71D472EA524C555, 52CEBF8B18834CB8B650FE16, 94ADCCD464DD43149D0C0E60, F23C2967ED7140EB8DCB2D2A, 47AD4ADC4ACE4F6DA7AD708C, 8B0A9A9DBAF346199EBA21B3, D826E98233304F5FB27B27C3, 579A3A8D49DF43E7B11942C6, D0BEA6E726C04A95A8CC0018, 15DA64BB40B14C9B899A3729, 4DF6B21EFDD54DF999385511, AEF63662B6B5454F92986710, 78179BA533074F1ABBB7391E, 45E14846E79845CE9C20C5EC, 2049733B63794135A4BFBB53); runOnlyForDeploymentPostprocessing = 0; };
|
||||
729C230D5EB64A76BCD517B4 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (6B75AFC14BBF4E2B97762ECB, 9BA38C8D635F4770A232AE85, 1889CDB168794D81A5B24699, E25CB4FB944141D0AAB57EDF, 6BE3B2533E6A4E3DA32EA972, B88859D9B34E4F289913DDFC, B8CC446DB3AE4BABB4D739D1, 6CCB67439127403180D9C697); runOnlyForDeploymentPostprocessing = 0; };
|
||||
6B4D5383389142E7AE9C4F41 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (C44DD19CDB6545A381B61D20, 7C8A2DFFAB6B4CB8AA3E4FE8, 8D479F8873F94073938D737E, 9A894DBC632C4904BC5D9A78, 9246A8532B66443A83535192, 5DA941A5928A4F638223030C); runOnlyForDeploymentPostprocessing = 0; };
|
||||
AA7CFC56BCC040A4AB487E2C = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
|
||||
|
||||
@@ -78,6 +78,23 @@ enum L10n {
|
||||
"action.exit": "Esci",
|
||||
"action.login": "Accedi",
|
||||
"action.logout": "Esci",
|
||||
"account.confirm.password": "Conferma password",
|
||||
"account.current.password": "Password attuale",
|
||||
"account.name.label": "Nome",
|
||||
"account.new.password": "Nuova password (min. 8 caratteri)",
|
||||
"account.password.heading": "Cambia password",
|
||||
"account.password.saved": "Password aggiornata",
|
||||
"account.profile.heading": "Profilo",
|
||||
"account.profile.saved": "Profilo aggiornato",
|
||||
"account.role": "Ruolo: %1$@",
|
||||
"account.save.password": "Aggiorna password",
|
||||
"account.save.profile": "Salva profilo",
|
||||
"account.title": "Account",
|
||||
"forgot.password.lead": "Inserisci l'email dell'account: ti invieremo un link per reimpostare la password.",
|
||||
"forgot.password.submit": "Invia link di reset",
|
||||
"forgot.password.success": "Se l'email è registrata, riceverai a breve un link per reimpostare la password.",
|
||||
"forgot.password.title": "Password dimenticata",
|
||||
"login.forgot.password": "Password dimenticata?",
|
||||
"action.ok": "OK",
|
||||
"action.save": "Salva",
|
||||
"api.error.decoding": "Risposta del server non valida",
|
||||
@@ -352,6 +369,23 @@ enum L10n {
|
||||
"action.exit": "Exit",
|
||||
"action.login": "Log in",
|
||||
"action.logout": "Log out",
|
||||
"account.confirm.password": "Confirm password",
|
||||
"account.current.password": "Current password",
|
||||
"account.name.label": "Name",
|
||||
"account.new.password": "New password (min. 8 characters)",
|
||||
"account.password.heading": "Change password",
|
||||
"account.password.saved": "Password updated",
|
||||
"account.profile.heading": "Profile",
|
||||
"account.profile.saved": "Profile updated",
|
||||
"account.role": "Role: %1$@",
|
||||
"account.save.password": "Update password",
|
||||
"account.save.profile": "Save profile",
|
||||
"account.title": "Account",
|
||||
"forgot.password.lead": "Enter your account email: we'll send you a link to reset your password.",
|
||||
"forgot.password.submit": "Send reset link",
|
||||
"forgot.password.success": "If the email is registered, you will receive a password reset link shortly.",
|
||||
"forgot.password.title": "Forgot password",
|
||||
"login.forgot.password": "Forgot password?",
|
||||
"action.ok": "OK",
|
||||
"action.save": "Save",
|
||||
"api.error.decoding": "Invalid server response",
|
||||
@@ -626,6 +660,23 @@ enum L10n {
|
||||
"action.exit": "Quitter",
|
||||
"action.login": "Connexion",
|
||||
"action.logout": "Déconnexion",
|
||||
"account.confirm.password": "Confirmer le mot de passe",
|
||||
"account.current.password": "Mot de passe actuel",
|
||||
"account.name.label": "Nom",
|
||||
"account.new.password": "Nouveau mot de passe (min. 8 caractères)",
|
||||
"account.password.heading": "Changer le mot de passe",
|
||||
"account.password.saved": "Mot de passe mis à jour",
|
||||
"account.profile.heading": "Profil",
|
||||
"account.profile.saved": "Profil mis à jour",
|
||||
"account.role": "Rôle : %1$@",
|
||||
"account.save.password": "Mettre à jour le mot de passe",
|
||||
"account.save.profile": "Enregistrer le profil",
|
||||
"account.title": "Compte",
|
||||
"forgot.password.lead": "Saisissez l'e-mail du compte : nous vous enverrons un lien pour réinitialiser le mot de passe.",
|
||||
"forgot.password.submit": "Envoyer le lien",
|
||||
"forgot.password.success": "Si l'e-mail est enregistré, vous recevrez bientôt un lien de réinitialisation.",
|
||||
"forgot.password.title": "Mot de passe oublié",
|
||||
"login.forgot.password": "Mot de passe oublié ?",
|
||||
"action.ok": "OK",
|
||||
"action.save": "Enregistrer",
|
||||
"api.error.decoding": "Réponse du serveur non valide",
|
||||
@@ -900,6 +951,23 @@ enum L10n {
|
||||
"action.exit": "Beenden",
|
||||
"action.login": "Anmelden",
|
||||
"action.logout": "Abmelden",
|
||||
"account.confirm.password": "Passwort bestätigen",
|
||||
"account.current.password": "Aktuelles Passwort",
|
||||
"account.name.label": "Name",
|
||||
"account.new.password": "Neues Passwort (mind. 8 Zeichen)",
|
||||
"account.password.heading": "Passwort ändern",
|
||||
"account.password.saved": "Passwort aktualisiert",
|
||||
"account.profile.heading": "Profil",
|
||||
"account.profile.saved": "Profil aktualisiert",
|
||||
"account.role": "Rolle: %1$@",
|
||||
"account.save.password": "Passwort aktualisieren",
|
||||
"account.save.profile": "Profil speichern",
|
||||
"account.title": "Konto",
|
||||
"forgot.password.lead": "Gib die E-Mail des Kontos ein: Wir senden dir einen Link zum Zurücksetzen des Passworts.",
|
||||
"forgot.password.submit": "Reset-Link senden",
|
||||
"forgot.password.success": "Wenn die E-Mail registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen.",
|
||||
"forgot.password.title": "Passwort vergessen",
|
||||
"login.forgot.password": "Passwort vergessen?",
|
||||
"action.ok": "OK",
|
||||
"action.save": "Speichern",
|
||||
"api.error.decoding": "Ungültige Serverantwort",
|
||||
@@ -1174,6 +1242,23 @@ enum L10n {
|
||||
"action.exit": "Salir",
|
||||
"action.login": "Acceder",
|
||||
"action.logout": "Salir",
|
||||
"account.confirm.password": "Confirmar contraseña",
|
||||
"account.current.password": "Contraseña actual",
|
||||
"account.name.label": "Nombre",
|
||||
"account.new.password": "Nueva contraseña (mín. 8 caracteres)",
|
||||
"account.password.heading": "Cambiar contraseña",
|
||||
"account.password.saved": "Contraseña actualizada",
|
||||
"account.profile.heading": "Perfil",
|
||||
"account.profile.saved": "Perfil actualizado",
|
||||
"account.role": "Rol: %1$@",
|
||||
"account.save.password": "Actualizar contraseña",
|
||||
"account.save.profile": "Guardar perfil",
|
||||
"account.title": "Cuenta",
|
||||
"forgot.password.lead": "Introduce el email de la cuenta: te enviaremos un enlace para restablecer la contraseña.",
|
||||
"forgot.password.submit": "Enviar enlace",
|
||||
"forgot.password.success": "Si el email está registrado, recibirás en breve un enlace para restablecer la contraseña.",
|
||||
"forgot.password.title": "Contraseña olvidada",
|
||||
"login.forgot.password": "¿Olvidaste la contraseña?",
|
||||
"action.ok": "OK",
|
||||
"action.save": "Guardar",
|
||||
"api.error.decoding": "Respuesta del servidor no válida",
|
||||
|
||||
@@ -13,6 +13,34 @@ struct RefreshRequest: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
struct ForgotPasswordRequest: Encodable {
|
||||
let email: String
|
||||
}
|
||||
|
||||
struct ForgotPasswordResponse: Decodable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct UpdateAccountRequest: Encodable {
|
||||
let name: String
|
||||
}
|
||||
|
||||
struct ChangePasswordRequest: Encodable {
|
||||
let currentPassword: String
|
||||
let password: String
|
||||
let passwordConfirmation: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case currentPassword = "current_password"
|
||||
case password
|
||||
case passwordConfirmation = "password_confirmation"
|
||||
}
|
||||
}
|
||||
|
||||
struct MessageResponse: Decodable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct LoginResponse: Decodable {
|
||||
let user: UserDto
|
||||
let accessToken: String
|
||||
|
||||
@@ -73,6 +73,33 @@ final class MatchLiveAPI: @unchecked Sendable {
|
||||
try await postVoid("auth/logout")
|
||||
}
|
||||
|
||||
func forgotPassword(email: String) async throws -> ForgotPasswordResponse {
|
||||
try await post("auth/password/forgot", body: ForgotPasswordRequest(email: email))
|
||||
}
|
||||
|
||||
func account() async throws -> UserDto {
|
||||
try await get("account")
|
||||
}
|
||||
|
||||
func updateAccount(name: String) async throws -> UserDto {
|
||||
try await patch("account", body: UpdateAccountRequest(name: name))
|
||||
}
|
||||
|
||||
func changePassword(
|
||||
currentPassword: String,
|
||||
password: String,
|
||||
passwordConfirmation: String
|
||||
) async throws -> MessageResponse {
|
||||
try await patch(
|
||||
"account/password",
|
||||
body: ChangePasswordRequest(
|
||||
currentPassword: currentPassword,
|
||||
password: password,
|
||||
passwordConfirmation: passwordConfirmation
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Sports & Teams
|
||||
|
||||
func sports() async throws -> [SportDto] {
|
||||
@@ -267,6 +294,7 @@ final class MatchLiveAPI: @unchecked Sendable {
|
||||
return path.hasSuffix("/auth/login")
|
||||
|| path.hasSuffix("/auth/refresh")
|
||||
|| path.hasSuffix("/auth/register")
|
||||
|| path.hasSuffix("/auth/password/forgot")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,31 @@ final class AuthRepository {
|
||||
return session
|
||||
}
|
||||
|
||||
func forgotPassword(email: String) async throws -> String {
|
||||
try await api.forgotPassword(email: email.trimmingCharacters(in: .whitespacesAndNewlines)).message
|
||||
}
|
||||
|
||||
func fetchAccount() async throws -> User {
|
||||
try await api.account().toDomain()
|
||||
}
|
||||
|
||||
func updateName(_ name: String) async throws -> User {
|
||||
let user = try await api.updateAccount(name: name.trimmingCharacters(in: .whitespacesAndNewlines)).toDomain()
|
||||
if var stored = tokenStore.session {
|
||||
stored = StoredSession(user: user, accessToken: stored.accessToken, refreshToken: stored.refreshToken)
|
||||
tokenStore.saveSession(stored)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func changePassword(currentPassword: String, password: String, passwordConfirmation: String) async throws {
|
||||
_ = try await api.changePassword(
|
||||
currentPassword: currentPassword,
|
||||
password: password,
|
||||
passwordConfirmation: passwordConfirmation
|
||||
)
|
||||
}
|
||||
|
||||
func logout() async {
|
||||
try? await api.logout()
|
||||
tokenStore.clear()
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AccountScreen: View {
|
||||
@ObservedObject var container: AppContainer
|
||||
let onBack: () -> Void
|
||||
let onLoggedOut: () -> Void
|
||||
|
||||
@State private var email = ""
|
||||
@State private var role = ""
|
||||
@State private var name = ""
|
||||
@State private var currentPassword = ""
|
||||
@State private var newPassword = ""
|
||||
@State private var confirmPassword = ""
|
||||
@State private var passwordVisible = false
|
||||
@State private var loadingProfile = true
|
||||
@State private var savingProfile = false
|
||||
@State private var savingPassword = false
|
||||
@State private var loggingOut = false
|
||||
@State private var profileMessage: String?
|
||||
@State private var profileError: String?
|
||||
@State private var passwordMessage: String?
|
||||
@State private var passwordError: String?
|
||||
@State private var languageTick = 0
|
||||
|
||||
var body: some View {
|
||||
MatchScreenScaffold(
|
||||
topBar: {
|
||||
HStack {
|
||||
Button(action: onBack) {
|
||||
Image(systemName: "chevron.left")
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
Text(L10n.t("account.title"))
|
||||
.font(MatchTypography.headlineMedium)
|
||||
.foregroundStyle(.white)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
},
|
||||
content: {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(L10n.t("account.profile.heading"))
|
||||
.font(MatchTypography.titleMedium)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
disabledField(title: L10n.t("login.email"), value: email)
|
||||
if !role.isEmpty {
|
||||
Text(L10n.t("account.role", role))
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
MatchAccountTextField(title: L10n.t("account.name.label"), text: $name)
|
||||
|
||||
if let profileError {
|
||||
Text(profileError).foregroundStyle(MatchColors.primaryRed)
|
||||
}
|
||||
if let profileMessage {
|
||||
Text(profileMessage).foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
|
||||
MatchPrimaryButton(
|
||||
label: L10n.t("account.save.profile"),
|
||||
action: saveProfile,
|
||||
enabled: !loadingProfile && !name.trimmingCharacters(in: .whitespaces).isEmpty && !savingProfile,
|
||||
loading: savingProfile
|
||||
)
|
||||
|
||||
Text(L10n.t("account.password.heading"))
|
||||
.font(MatchTypography.titleMedium)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.top, 16)
|
||||
|
||||
MatchAccountSecureField(
|
||||
title: L10n.t("account.current.password"),
|
||||
text: $currentPassword,
|
||||
visible: $passwordVisible
|
||||
)
|
||||
MatchAccountSecureField(
|
||||
title: L10n.t("account.new.password"),
|
||||
text: $newPassword,
|
||||
visible: $passwordVisible
|
||||
)
|
||||
MatchAccountSecureField(
|
||||
title: L10n.t("account.confirm.password"),
|
||||
text: $confirmPassword,
|
||||
visible: $passwordVisible
|
||||
)
|
||||
|
||||
if let passwordError {
|
||||
Text(passwordError).foregroundStyle(MatchColors.primaryRed)
|
||||
}
|
||||
if let passwordMessage {
|
||||
Text(passwordMessage).foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
|
||||
MatchPrimaryButton(
|
||||
label: L10n.t("account.save.password"),
|
||||
action: savePassword,
|
||||
enabled: !currentPassword.isEmpty && !newPassword.isEmpty && !confirmPassword.isEmpty && !savingPassword,
|
||||
loading: savingPassword
|
||||
)
|
||||
|
||||
MatchSecondaryButton(
|
||||
label: L10n.t("action.logout"),
|
||||
action: logout,
|
||||
enabled: !loggingOut
|
||||
)
|
||||
.padding(.top, 24)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
)
|
||||
.task { await loadAccount() }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
|
||||
languageTick += 1
|
||||
}
|
||||
.id(languageTick)
|
||||
}
|
||||
|
||||
private func disabledField(title: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
Text(value.isEmpty ? "—" : value)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAccount() async {
|
||||
loadingProfile = true
|
||||
do {
|
||||
let user = try await container.authRepository.fetchAccount()
|
||||
email = user.email
|
||||
name = user.name
|
||||
role = user.role
|
||||
} catch {
|
||||
profileError = UserFacingError.message(for: error) ?? L10n.t("common.error.generic")
|
||||
}
|
||||
loadingProfile = false
|
||||
}
|
||||
|
||||
private func saveProfile() {
|
||||
savingProfile = true
|
||||
profileError = nil
|
||||
profileMessage = nil
|
||||
Task {
|
||||
do {
|
||||
let user = try await container.authRepository.updateName(name)
|
||||
name = user.name
|
||||
profileMessage = L10n.t("account.profile.saved")
|
||||
} catch {
|
||||
profileError = UserFacingError.message(for: error) ?? L10n.t("common.error.generic")
|
||||
}
|
||||
savingProfile = false
|
||||
}
|
||||
}
|
||||
|
||||
private func savePassword() {
|
||||
savingPassword = true
|
||||
passwordError = nil
|
||||
passwordMessage = nil
|
||||
Task {
|
||||
do {
|
||||
try await container.authRepository.changePassword(
|
||||
currentPassword: currentPassword,
|
||||
password: newPassword,
|
||||
passwordConfirmation: confirmPassword
|
||||
)
|
||||
currentPassword = ""
|
||||
newPassword = ""
|
||||
confirmPassword = ""
|
||||
passwordMessage = L10n.t("account.password.saved")
|
||||
} catch {
|
||||
passwordError = UserFacingError.message(for: error) ?? L10n.t("common.error.generic")
|
||||
}
|
||||
savingPassword = false
|
||||
}
|
||||
}
|
||||
|
||||
private func logout() {
|
||||
loggingOut = true
|
||||
Task {
|
||||
await container.authRepository.logout()
|
||||
onLoggedOut()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MatchAccountTextField: View {
|
||||
let title: String
|
||||
@Binding var text: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
TextField("", text: $text)
|
||||
.padding(12)
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MatchAccountSecureField: View {
|
||||
let title: String
|
||||
@Binding var text: String
|
||||
@Binding var visible: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
HStack {
|
||||
Group {
|
||||
if visible {
|
||||
TextField("", text: $text)
|
||||
} else {
|
||||
SecureField("", text: $text)
|
||||
}
|
||||
}
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
Button(action: { visible.toggle() }) {
|
||||
Image(systemName: visible ? "eye.slash" : "eye")
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ForgotPasswordScreen: View {
|
||||
@ObservedObject var container: AppContainer
|
||||
let onBack: () -> Void
|
||||
|
||||
@State private var email = ""
|
||||
@State private var loading = false
|
||||
@State private var error: String?
|
||||
@State private var success = false
|
||||
@State private var languageTick = 0
|
||||
|
||||
var body: some View {
|
||||
MatchScreenScaffold(
|
||||
topBar: {
|
||||
HStack {
|
||||
Button(action: onBack) {
|
||||
Image(systemName: "chevron.left")
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
Text(L10n.t("forgot.password.title"))
|
||||
.font(MatchTypography.headlineMedium)
|
||||
.foregroundStyle(.white)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
},
|
||||
content: {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
Text(L10n.t("forgot.password.lead"))
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(L10n.t("login.email"))
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
TextField("", text: $email)
|
||||
.textInputAutocapitalization(.never)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocorrectionDisabled()
|
||||
.disabled(success)
|
||||
.padding(12)
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
||||
}
|
||||
|
||||
if let error {
|
||||
Text(error)
|
||||
.foregroundStyle(MatchColors.primaryRed)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
if success {
|
||||
Text(L10n.t("forgot.password.success"))
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
MatchPrimaryButton(
|
||||
label: L10n.t("forgot.password.submit"),
|
||||
action: submit,
|
||||
enabled: !email.trimmingCharacters(in: .whitespaces).isEmpty && !loading && !success,
|
||||
loading: loading
|
||||
)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
|
||||
languageTick += 1
|
||||
}
|
||||
.id(languageTick)
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
loading = true
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
_ = try await container.authRepository.forgotPassword(email: email)
|
||||
success = true
|
||||
} catch {
|
||||
self.error = UserFacingError.message(for: error) ?? L10n.t("common.error.generic")
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct LoginScreen: View {
|
||||
@ObservedObject var container: AppContainer
|
||||
let onLoggedIn: () -> Void
|
||||
let onForgotPassword: () -> Void
|
||||
|
||||
@State private var email = ""
|
||||
@State private var password = ""
|
||||
@@ -37,6 +38,16 @@ struct LoginScreen: View {
|
||||
MatchSecureField(title: L10n.t("login.password"), text: $password, visible: $passwordVisible)
|
||||
}
|
||||
.padding(.top, 32)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: onForgotPassword) {
|
||||
Text(L10n.t("login.forgot.password"))
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.underline()
|
||||
}
|
||||
}
|
||||
.padding(.top, 12)
|
||||
if let error {
|
||||
Text(error)
|
||||
.foregroundStyle(MatchColors.primaryRed)
|
||||
|
||||
@@ -5,7 +5,7 @@ struct MatchesScreen: View {
|
||||
var refreshToken: Int = 0
|
||||
let onOpenSetup: (String) -> Void
|
||||
let onOpenBroadcast: (String) -> Void
|
||||
let onLogout: () -> Void
|
||||
let onOpenAccount: () -> Void
|
||||
|
||||
@State private var loading = true
|
||||
@State private var refreshing = false
|
||||
@@ -37,15 +37,12 @@ struct MatchesScreen: View {
|
||||
}
|
||||
.accessibilityLabel(L10n.t("language.label"))
|
||||
Button {
|
||||
Task {
|
||||
await container.authRepository.logout()
|
||||
onLogout()
|
||||
}
|
||||
onOpenAccount()
|
||||
} label: {
|
||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||
Image(systemName: "person.crop.circle")
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
.accessibilityLabel(L10n.t("action.logout"))
|
||||
.accessibilityLabel(L10n.t("account.title"))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
@@ -27,7 +27,16 @@ struct AppNavHost: View {
|
||||
case .splash:
|
||||
EmptyView()
|
||||
case .login:
|
||||
LoginScreen(container: container) { path = [.matches] }
|
||||
LoginScreen(
|
||||
container: container,
|
||||
onLoggedIn: { path = [.matches] },
|
||||
onForgotPassword: { path.append(.forgotPassword) }
|
||||
)
|
||||
case .forgotPassword:
|
||||
ForgotPasswordScreen(
|
||||
container: container,
|
||||
onBack: { path.removeLast() }
|
||||
)
|
||||
case .matches:
|
||||
MatchesScreen(
|
||||
container: container,
|
||||
@@ -38,9 +47,15 @@ struct AppNavHost: View {
|
||||
onOpenBroadcast: { sessionId in
|
||||
broadcastRoute = BroadcastRoute(sessionId: sessionId)
|
||||
},
|
||||
onLogout: { path = [.login] }
|
||||
onOpenAccount: { path.append(.account) }
|
||||
)
|
||||
.lockPortraitOrientation()
|
||||
case .account:
|
||||
AccountScreen(
|
||||
container: container,
|
||||
onBack: { path.removeLast() },
|
||||
onLoggedOut: { path = [.login] }
|
||||
)
|
||||
case .setup, .broadcast:
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import Foundation
|
||||
enum Routes: Hashable {
|
||||
case splash
|
||||
case login
|
||||
case forgotPassword
|
||||
case matches
|
||||
case account
|
||||
case setup(matchId: String, step: Int)
|
||||
case broadcast(sessionId: String)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user