Merge branch 'feature/account-management'

Gestione account MVP: profilo, cambio password e forgot password su web/Android/iOS.
This commit is contained in:
2026-08-08 10:39:08 +02:00
46 changed files with 1591 additions and 34 deletions
@@ -0,0 +1,57 @@
module Api
module V1
class AccountsController < ApplicationController
def show
render json: user_json(current_user)
end
def update
name = params[:name].to_s.strip
if name.blank?
return render json: { error: "Name is required" }, status: :unprocessable_entity
end
if current_user.update(name: name)
render json: user_json(current_user)
else
render json: { error: current_user.errors.full_messages.join(", ") }, status: :unprocessable_entity
end
end
def password
result = Users::ChangePassword.call(
user: current_user,
current_password: params[:current_password],
password: params[:password],
password_confirmation: params[:password_confirmation]
)
unless result.ok?
return render json: { error: password_error_message(result.error) }, status: :unprocessable_entity
end
render json: { message: "Password updated" }
end
private
def user_json(user)
{
id: user.id,
email: user.email,
name: user.name,
role: user.role
}
end
def password_error_message(code)
case code
when :current_incorrect then "Current password is incorrect"
when :too_short then "Password must be at least 8 characters"
when :mismatch then "Passwords do not match"
else "Unable to update password"
end
end
end
end
end
@@ -1,7 +1,7 @@
module Api
module V1
class AuthController < ApplicationController
skip_before_action :authenticate_request!, only: %i[login refresh register]
skip_before_action :authenticate_request!, only: %i[login refresh register forgot_password]
def register
user = User.new(
@@ -42,6 +42,13 @@ module Api
render json: user_json(current_user)
end
def forgot_password
Users::RequestPasswordReset.call(email: params[:email])
render json: {
message: "If the email is registered, you will receive a password reset link shortly."
}
end
private
def token_response(user)
@@ -0,0 +1,39 @@
module Public
class AccountsController < WebBaseController
before_action :require_login!
def show
end
def update
name = params[:name].to_s.strip
if name.blank?
flash.now[:alert] = t("flash.accounts.name_required")
return render :show, status: :unprocessable_entity
end
if current_user.update(name: name)
redirect_to public_account_path, notice: t("flash.accounts.profile_updated")
else
flash.now[:alert] = current_user.errors.full_messages.to_sentence
render :show, status: :unprocessable_entity
end
end
def update_password
result = Users::ChangePassword.call(
user: current_user,
current_password: params[:current_password],
password: params[:password],
password_confirmation: params[:password_confirmation]
)
unless result.ok?
flash.now[:alert] = t("flash.accounts.password_#{result.error}")
return render :show, status: :unprocessable_entity
end
redirect_to public_account_path, notice: t("flash.accounts.password_updated")
end
end
end
@@ -4,11 +4,7 @@ module Public
end
def create
user = User.find_by(email: params[:email]&.downcase&.strip)
if user
token = user.generate_password_reset!
UserMailer.password_reset(user, token).deliver_now
end
Users::RequestPasswordReset.call(email: params[:email])
redirect_to public_login_path,
notice: t("flash.password_resets.email_sent")
@@ -0,0 +1,39 @@
module Users
class ChangePassword
Result = Struct.new(:ok?, :error, keyword_init: true)
def self.call(user:, current_password:, password:, password_confirmation:)
new(
user: user,
current_password: current_password,
password: password,
password_confirmation: password_confirmation
).call
end
def initialize(user:, current_password:, password:, password_confirmation:)
@user = user
@current_password = current_password.to_s
@password = password.to_s
@password_confirmation = password_confirmation.to_s
end
def call
unless @user.authenticate(@current_password)
return Result.new(ok?: false, error: :current_incorrect)
end
if @password.blank? || @password.length < 8
return Result.new(ok?: false, error: :too_short)
end
if @password != @password_confirmation
return Result.new(ok?: false, error: :mismatch)
end
@user.update!(password: @password)
@user.clear_password_reset!
Result.new(ok?: true)
end
end
end
@@ -0,0 +1,19 @@
module Users
class RequestPasswordReset
def self.call(email:)
new(email: email).call
end
def initialize(email:)
@email = email.to_s.downcase.strip
end
def call
user = User.find_by(email: @email)
return if user.nil?
token = user.generate_password_reset!
UserMailer.password_reset(user, token).deliver_now
end
end
end
@@ -41,6 +41,7 @@
<% elsif current_user.manageable_teams.any? %>
· <%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first) %>
<% end %>
· <%= link_to t("nav.account"), public_account_path %>
· <%= button_to t("nav.logout"), public_logout_path, method: :delete, form: { style: "display:inline" }, class: "btn btn-secondary", style: "padding:6px 12px;font-size:0.85rem" %>
<% else %>
· <%= link_to t("nav.login"), public_login_path %>
@@ -0,0 +1,55 @@
<% content_for :title, t("auth.account.meta_title") %>
<% content_for :meta_description, t("auth.account.meta_description") %>
<% content_for :robots, "noindex, nofollow" %>
<section class="auth-page">
<h1><%= t("auth.account.title") %></h1>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:1.1rem;margin-top:0"><%= t("auth.account.profile_heading") %></h2>
<%= form_with url: public_account_path, method: :patch, local: true do %>
<p>
<label for="account_email"><%= t("auth.email") %></label><br>
<input type="email" id="account_email" value="<%= current_user.email %>" readonly
class="input" autocomplete="username"
style="width:100%;opacity:0.75;cursor:not-allowed">
</p>
<p class="muted" style="margin-top:-0.5rem;font-size:0.9rem">
<%= t("auth.account.role_label", role: current_user.role) %>
</p>
<%= render "shared/input_toggle",
name: :name,
label: t("auth.account.name_label"),
value: current_user.name,
required: true,
autocomplete: "name",
masked: false %>
<%= submit_tag t("auth.account.save_profile"), class: "btn btn-primary" %>
<% end %>
</div>
<div class="card">
<h2 style="font-size:1.1rem;margin-top:0"><%= t("auth.account.password_heading") %></h2>
<%= form_with url: public_account_password_path, method: :patch, local: true do %>
<%= render "shared/input_toggle",
name: :current_password,
label: t("auth.account.current_password_label"),
input_type: "password",
required: true,
autocomplete: "current-password" %>
<%= render "shared/input_toggle",
name: :password,
label: t("auth.account.new_password_label"),
input_type: "password",
required: true,
autocomplete: "new-password" %>
<%= render "shared/input_toggle",
name: :password_confirmation,
label: t("auth.password_confirmation"),
input_type: "password",
required: true,
autocomplete: "new-password" %>
<%= submit_tag t("auth.account.save_password"), class: "btn btn-primary" %>
<% end %>
</div>
</section>
@@ -34,6 +34,7 @@
<%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first), class: "nav-link-item" %>
<% end %>
<% end %>
<%= link_to t("nav.account"), public_account_path, class: (request.path == "/account" ? "nav-link-item nav-active" : "nav-link-item") %>
<%= button_to t("nav.logout"), public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>
<% else %>
<%= link_to t("nav.login"), public_login_path, class: "nav-link-item" %>
+7
View File
@@ -606,6 +606,13 @@ de:
password_min_length: Das Passwort muss mindestens 8 Zeichen lang sein
password_mismatch: Die Passwörter stimmen nicht überein
password_updated: Passwort aktualisiert. Du kannst dich jetzt anmelden.
accounts:
name_required: Der Name ist erforderlich
profile_updated: Profil aktualisiert.
password_current_incorrect: Das aktuelle Passwort ist falsch
password_too_short: Das Passwort muss mindestens 8 Zeichen haben
password_mismatch: Die Passwörter stimmen nicht überein
password_updated: Passwort aktualisiert.
replay:
download_unavailable: Download nicht verfügbar
not_available: Replay nicht verfügbar
+7
View File
@@ -606,6 +606,13 @@ en:
password_min_length: Password must be at least 8 characters
password_mismatch: Passwords don't match
password_updated: Password updated. You can now log in.
accounts:
name_required: Name is required
profile_updated: Profile updated.
password_current_incorrect: Current password is incorrect
password_too_short: Password must be at least 8 characters
password_mismatch: Passwords do not match
password_updated: Password updated.
replay:
download_unavailable: Download not available
not_available: Replay not available
+7
View File
@@ -606,6 +606,13 @@ es:
password_min_length: La contraseña debe tener al menos 8 caracteres
password_mismatch: Las contraseñas no coinciden
password_updated: Contraseña actualizada. Ya puedes iniciar sesión.
accounts:
name_required: El nombre es obligatorio
profile_updated: Perfil actualizado.
password_current_incorrect: La contraseña actual no es correcta
password_too_short: La contraseña debe tener al menos 8 caracteres
password_mismatch: Las contraseñas no coinciden
password_updated: Contraseña actualizada.
replay:
download_unavailable: Descarga no disponible
not_available: Repetición no disponible
+7
View File
@@ -606,6 +606,13 @@ fr:
password_min_length: Le mot de passe doit comporter au moins 8 caractères
password_mismatch: Les mots de passe ne correspondent pas
password_updated: Mot de passe mis à jour. Tu peux maintenant te connecter.
accounts:
name_required: Le nom est obligatoire
profile_updated: Profil mis à jour.
password_current_incorrect: Le mot de passe actuel est incorrect
password_too_short: Le mot de passe doit contenir au moins 8 caractères
password_mismatch: Les mots de passe ne correspondent pas
password_updated: Mot de passe mis à jour.
replay:
download_unavailable: Téléchargement non disponible
not_available: Replay non disponible
+7
View File
@@ -606,6 +606,13 @@ it:
password_min_length: La password deve avere almeno 8 caratteri
password_mismatch: Le password non coincidono
password_updated: Password aggiornata. Ora puoi accedere.
accounts:
name_required: Il nome è obbligatorio
profile_updated: Profilo aggiornato.
password_current_incorrect: La password attuale non è corretta
password_too_short: La password deve avere almeno 8 caratteri
password_mismatch: Le password non coincidono
password_updated: Password aggiornata.
replay:
download_unavailable: Download non disponibile
not_available: Replay non disponibile
+13
View File
@@ -16,6 +16,7 @@ de:
my_team: Mein Team
login: Anmelden
logout: Abmelden
account: Konto
signup: Team registrieren
footer:
tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können
@@ -118,6 +119,18 @@ de:
signup_link: registrieren Sie sich
mobile_app_label: "Mobile App:"
open_in_app: Einladung in der App öffnen
account:
meta_title: "Dein Konto — Match Live TV"
meta_description: Name und Passwort deines Match Live TV-Kontos verwalten.
title: Dein Konto
profile_heading: Profil
password_heading: Passwort ändern
name_label: Name
role_label: "Rolle: %{role}"
current_password_label: Aktuelles Passwort
new_password_label: "Neues Passwort (mind. 8 Zeichen)"
save_profile: Profil speichern
save_password: Passwort aktualisieren
common:
privacy: Datenschutz
cookies: Cookies
+13
View File
@@ -16,6 +16,7 @@ en:
my_team: My team
login: Log in
logout: Log out
account: Account
signup: Register a team
footer:
tagline: Every match, every event, for those who can't be there
@@ -118,6 +119,18 @@ en:
signup_link: sign up
mobile_app_label: "Mobile app:"
open_in_app: Open invitation in the app
account:
meta_title: "Your account — Match Live TV"
meta_description: Manage your Match Live TV account name and password.
title: Your account
profile_heading: Profile
password_heading: Change password
name_label: Name
role_label: "Role: %{role}"
current_password_label: Current password
new_password_label: "New password (min. 8 characters)"
save_profile: Save profile
save_password: Update password
common:
privacy: Privacy
cookies: Cookies
+13
View File
@@ -16,6 +16,7 @@ es:
my_team: Mi equipo
login: Acceder
logout: Salir
account: Cuenta
signup: Registrar equipo
footer:
tagline: Cada partido, cada evento, para quien no puede estar
@@ -118,6 +119,18 @@ es:
signup_link: regístrate
mobile_app_label: "App móvil:"
open_in_app: Abrir invitación en la app
account:
meta_title: "Tu cuenta — Match Live TV"
meta_description: Gestiona el nombre y la contraseña de tu cuenta Match Live TV.
title: Tu cuenta
profile_heading: Perfil
password_heading: Cambiar contraseña
name_label: Nombre
role_label: "Rol: %{role}"
current_password_label: Contraseña actual
new_password_label: "Nueva contraseña (mín. 8 caracteres)"
save_profile: Guardar perfil
save_password: Actualizar contraseña
common:
privacy: Privacidad
cookies: Cookies
+13
View File
@@ -16,6 +16,7 @@ fr:
my_team: Mon équipe
login: Connexion
logout: Déconnexion
account: Compte
signup: Inscrire une équipe
footer:
tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là
@@ -118,6 +119,18 @@ fr:
signup_link: inscrivez-vous
mobile_app_label: "Application mobile :"
open_in_app: Ouvrir l'invitation dans l'application
account:
meta_title: "Votre compte — Match Live TV"
meta_description: Gérez le nom et le mot de passe de votre compte Match Live TV.
title: Votre compte
profile_heading: Profil
password_heading: Changer le mot de passe
name_label: Nom
role_label: "Rôle : %{role}"
current_password_label: Mot de passe actuel
new_password_label: "Nouveau mot de passe (min. 8 caractères)"
save_profile: Enregistrer le profil
save_password: Mettre à jour le mot de passe
common:
privacy: Confidentialité
cookies: Cookies
+13
View File
@@ -16,6 +16,7 @@ it:
my_team: La mia squadra
login: Accedi
logout: Esci
account: Account
signup: Registra squadra
footer:
tagline: Ogni partita, ogni evento, per chi non può esserci
@@ -118,6 +119,18 @@ it:
signup_link: registrati
mobile_app_label: "App mobile:"
open_in_app: Apri invito nell'app
account:
meta_title: "Il tuo account — Match Live TV"
meta_description: Gestisci nome e password del tuo account Match Live TV.
title: Il tuo account
profile_heading: Profilo
password_heading: Cambia password
name_label: Nome
role_label: "Ruolo: %{role}"
current_password_label: Password attuale
new_password_label: "Nuova password (min. 8 caratteri)"
save_profile: Salva profilo
save_password: Aggiorna password
common:
privacy: Privacy
cookies: Cookie
+7
View File
@@ -11,6 +11,10 @@ Rails.application.routes.draw do
post "auth/logout", to: "auth#logout"
post "auth/refresh", to: "auth#refresh"
get "auth/me", to: "auth#me"
post "auth/password/forgot", to: "auth#forgot_password"
get "account", to: "accounts#show"
patch "account", to: "accounts#update"
patch "account/password", to: "accounts#password"
get "sports", to: "sports#index"
get "invitations/:token", to: "invitations#show"
@@ -163,6 +167,9 @@ Rails.application.routes.draw do
post "password/forgot", to: "password_resets#create"
get "password/reset", to: "password_resets#edit", as: :password_reset
patch "password/reset", to: "password_resets#update"
get "account", to: "accounts#show", as: :account
patch "account", to: "accounts#update"
patch "account/password", to: "accounts#update_password", as: :account_password
get "clubs/new", to: "clubs#new", as: :new_club
post "clubs", to: "clubs#create"
get "clubs/:id", to: "clubs#show", as: :club
+81
View File
@@ -0,0 +1,81 @@
require "rails_helper"
RSpec.describe "Account API", type: :request do
let!(:user) { User.create!(email: "account@example.com", name: "Account User", password: "password123", role: "coach") }
let(:auth_headers) do
post "/api/v1/auth/login", params: { email: user.email, password: "password123" }
token = JSON.parse(response.body).fetch("access_token")
{ "Authorization" => "Bearer #{token}" }
end
describe "GET /api/v1/account" do
it "returns the current user profile" do
get "/api/v1/account", headers: auth_headers
expect(response).to have_http_status(:ok)
body = JSON.parse(response.body)
expect(body).to include("email" => user.email, "name" => "Account User", "role" => "coach")
end
it "requires authentication" do
get "/api/v1/account"
expect(response).to have_http_status(:unauthorized)
end
end
describe "PATCH /api/v1/account" do
it "updates the name" do
patch "/api/v1/account", params: { name: "Nuovo Nome" }, headers: auth_headers
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body)["name"]).to eq("Nuovo Nome")
expect(user.reload.name).to eq("Nuovo Nome")
end
it "rejects a blank name" do
patch "/api/v1/account", params: { name: " " }, headers: auth_headers
expect(response).to have_http_status(:unprocessable_entity)
end
end
describe "PATCH /api/v1/account/password" do
it "changes the password with the current password" do
patch "/api/v1/account/password",
params: {
current_password: "password123",
password: "newpass123",
password_confirmation: "newpass123"
},
headers: auth_headers
expect(response).to have_http_status(:ok)
expect(user.reload.authenticate("newpass123")).to be_truthy
end
it "rejects an incorrect current password" do
patch "/api/v1/account/password",
params: {
current_password: "wrong",
password: "newpass123",
password_confirmation: "newpass123"
},
headers: auth_headers
expect(response).to have_http_status(:unprocessable_entity)
expect(user.reload.authenticate("password123")).to be_truthy
end
end
describe "POST /api/v1/auth/password/forgot" do
it "always returns ok and sends mail when the user exists" do
expect {
post "/api/v1/auth/password/forgot", params: { email: user.email }
}.to change { ActionMailer::Base.deliveries.size }.by(1)
expect(response).to have_http_status(:ok)
expect(user.reload.password_reset_digest).to be_present
end
it "returns the same message for unknown emails" do
expect {
post "/api/v1/auth/password/forgot", params: { email: "nobody@example.com" }
}.not_to change { ActionMailer::Base.deliveries.size }
expect(response).to have_http_status(:ok)
end
end
end
@@ -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,
@@ -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>
@@ -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()
@@ -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
}
@@ -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
}
},
)
}
}
}
@@ -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(
@@ -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,
)
}
@@ -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) {
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 }
}
},
)
@@ -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 le-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 le-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 lemail dellaccount: 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 lemail è 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)
}