Compare commits

...
Author SHA1 Message Date
eminuxandCursor 1d1cbf9f3f Localizza errori API, sistema layout mobile e documenta allineamento iOS.
Gli header Accept-Language dalle app e i fix di padding/menu sul web chiudono il giro password-policy; il doc guida il lavoro su Mac.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 14:14:42 +02:00
eminuxandCursor dd9901519a Rifiuta il riuso della password attuale in cambio e reset.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:31:47 +02:00
eminuxandCursor 53449c5d3c Allinea le password di seed alla nuova policy di complessità.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:30:51 +02:00
eminuxandCursor db908e3109 Rafforza i requisiti password con regole di complessità di mercato.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:30:37 +02:00
eminux 5857f60d79 Merge branch 'feature/account-management'
Gestione account MVP: profilo, cambio password e forgot password su web/Android/iOS.
2026-08-08 10:39:08 +02:00
eminuxandCursor 83a804a709 Aggiunge gestione account con cambio password su web e app.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 10:38:57 +02:00
eminuxandCursor b8694a6b7b Corregge i simboli nativi AAB in formato .so.sym per Play Console.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:45:29 +02:00
eminuxandCursor 5daada81b0 Incorpora i simboli nativi nell’AAB per Play Console.
Le .so AndroidX sono già stripped: AGP non generava metadata; ora le iniettiamo nel bundle prima della firma (release 2.0.9 / 30).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:36:39 +02:00
eminuxandCursor d85bab30d1 Ignora i secret Garage locali nel deploy sync.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:27:25 +02:00
eminuxandCursor 7b3256c8a0 Alleggerisce il relay YouTube e rilascia Android 2.0.8.
ffmpeg fa solo remux copy A/V; le app fissano AAC 48 kHz mono; sync deploy non cancella più garage.prod.toml.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:25:40 +02:00
88 changed files with 2550 additions and 164 deletions
@@ -9,13 +9,23 @@ module Admin
return render :edit, status: :unprocessable_entity return render :edit, status: :unprocessable_entity
end end
if params[:password].blank? || params[:password].length < 8 if params[:password] != params[:password_confirmation]
flash.now[:alert] = t("admin.flash.password_too_short") flash.now[:alert] = t("admin.flash.password_mismatch")
return render :edit, status: :unprocessable_entity return render :edit, status: :unprocessable_entity
end end
if params[:password] != params[:password_confirmation] if (code = PasswordComplexity.violation(params[:password]))
flash.now[:alert] = t("admin.flash.password_mismatch") key = case code
when :blank, :too_short then :password_too_short
when :too_long then :password_too_long
else :password_too_weak
end
flash.now[:alert] = t("admin.flash.#{key}")
return render :edit, status: :unprocessable_entity
end
if PasswordComplexity.same_as_current?(current_admin_account, params[:password])
flash.now[:alert] = t("admin.flash.password_same_as_current")
return render :edit, status: :unprocessable_entity return render :edit, status: :unprocessable_entity
end end
@@ -0,0 +1,60 @@
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: I18n.t("flash.accounts.name_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: I18n.t("flash.accounts.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 I18n.t("flash.accounts.password_current_incorrect")
when :too_short then I18n.t("flash.accounts.password_too_short")
when :too_long then I18n.t("flash.accounts.password_too_long")
when :too_weak then I18n.t("flash.accounts.password_too_weak")
when :same_as_current then I18n.t("flash.accounts.password_same_as_current")
when :mismatch then I18n.t("flash.accounts.password_mismatch")
else I18n.t("flash.accounts.password_update_failed")
end
end
end
end
end
@@ -1,7 +1,7 @@
module Api module Api
module V1 module V1
class AuthController < ApplicationController 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 def register
user = User.new( user = User.new(
@@ -22,18 +22,18 @@ module Api
if user&.authenticate(params[:password]) if user&.authenticate(params[:password])
render json: token_response(user), status: :ok render json: token_response(user), status: :ok
else else
render json: { error: "Invalid credentials" }, status: :unauthorized render json: { error: I18n.t("flash.sessions.invalid_credentials") }, status: :unauthorized
end end
end end
def logout def logout
render json: { message: "Logged out" } render json: { message: I18n.t("flash.sessions.logged_out") }
end end
def refresh def refresh
payload = JsonWebToken.decode(params[:refresh_token] || bearer_token) payload = JsonWebToken.decode(params[:refresh_token] || bearer_token)
user = User.find_by(id: payload&.dig(:user_id)) user = User.find_by(id: payload&.dig(:user_id))
return render json: { error: "Invalid token" }, status: :unauthorized unless user return render json: { error: I18n.t("flash.sessions.invalid_token") }, status: :unauthorized unless user
render json: token_response(user) render json: token_response(user)
end end
@@ -42,6 +42,13 @@ module Api
render json: user_json(current_user) render json: user_json(current_user)
end end
def forgot_password
Users::RequestPasswordReset.call(email: params[:email])
render json: {
message: I18n.t("flash.password_resets.email_sent")
}
end
private private
def token_response(user) def token_response(user)
@@ -1,17 +1,25 @@
class ApplicationController < ActionController::API class ApplicationController < ActionController::API
include ActionController::HttpAuthentication::Token::ControllerMethods include ActionController::HttpAuthentication::Token::ControllerMethods
before_action :set_api_locale
before_action :authenticate_request! before_action :authenticate_request!
attr_reader :current_user attr_reader :current_user
private private
def set_api_locale
explicit = LocaleResolver.normalize(request.headers["X-Locale"])
I18n.locale = explicit ||
LocaleResolver.from_accept_language(request.headers["Accept-Language"]) ||
I18n.default_locale
end
def authenticate_request! def authenticate_request!
token = bearer_token token = bearer_token
payload = JsonWebToken.decode(token) payload = JsonWebToken.decode(token)
@current_user = User.find_by(id: payload[:user_id]) if payload @current_user = User.find_by(id: payload[:user_id]) if payload
render json: { error: "Unauthorized" }, status: :unauthorized unless @current_user render json: { error: I18n.t("flash.sessions.unauthorized") }, status: :unauthorized unless @current_user
end end
def bearer_token def bearer_token
@@ -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?
redirect_to public_account_path, alert: t("flash.accounts.password_#{result.error}")
return
end
redirect_to public_account_path, notice: t("flash.accounts.password_updated")
end
end
end
@@ -4,11 +4,7 @@ module Public
end end
def create def create
user = User.find_by(email: params[:email]&.downcase&.strip) Users::RequestPasswordReset.call(email: params[:email])
if user
token = user.generate_password_reset!
UserMailer.password_reset(user, token).deliver_now
end
redirect_to public_login_path, redirect_to public_login_path,
notice: t("flash.password_resets.email_sent") notice: t("flash.password_resets.email_sent")
@@ -17,7 +13,7 @@ module Public
def edit def edit
@user = User.find_by_password_reset_token(params[:token]) @user = User.find_by_password_reset_token(params[:token])
if @user.nil? || @user.password_reset_expired? if @user.nil? || @user.password_reset_expired?
redirect_to new_public_password_reset_path, redirect_to public_password_forgot_path,
alert: t("flash.password_resets.invalid_or_expired_link") alert: t("flash.password_resets.invalid_or_expired_link")
return return
end end
@@ -27,19 +23,25 @@ module Public
def update def update
@user = User.find_by_password_reset_token(params[:token]) @user = User.find_by_password_reset_token(params[:token])
if @user.nil? || @user.password_reset_expired? if @user.nil? || @user.password_reset_expired?
redirect_to new_public_password_reset_path, redirect_to public_password_forgot_path,
alert: t("flash.password_resets.invalid_or_expired_link") alert: t("flash.password_resets.invalid_or_expired_link")
return return
end end
if params[:password].blank? || params[:password].length < 8 if params[:password] != params[:password_confirmation]
flash.now[:alert] = t("flash.password_resets.password_min_length") flash.now[:alert] = t("flash.password_resets.password_mismatch")
@token = params[:token] @token = params[:token]
return render :edit, status: :unprocessable_entity return render :edit, status: :unprocessable_entity
end end
if params[:password] != params[:password_confirmation] if (code = PasswordComplexity.violation(params[:password]))
flash.now[:alert] = t("flash.password_resets.password_mismatch") flash.now[:alert] = t("flash.password_resets.password_#{code == :blank ? :too_short : code}")
@token = params[:token]
return render :edit, status: :unprocessable_entity
end
if PasswordComplexity.same_as_current?(@user, params[:password])
flash.now[:alert] = t("flash.password_resets.password_same_as_current")
@token = params[:token] @token = params[:token]
return render :edit, status: :unprocessable_entity return render :edit, status: :unprocessable_entity
end end
+2
View File
@@ -1,4 +1,6 @@
class AdminAccount < ApplicationRecord class AdminAccount < ApplicationRecord
include PasswordComplexity
has_secure_password has_secure_password
validates :username, presence: true, uniqueness: true validates :username, presence: true, uniqueness: true
@@ -0,0 +1,62 @@
# Criteri "di mercato" (stile Cognito/Auth0 bilanciato):
# - minimo 8 caratteri (max 72 per bcrypt)
# - almeno 3 classi su 4: minuscole, maiuscole, numeri, simboli
module PasswordComplexity
extend ActiveSupport::Concern
MIN_LENGTH = 8
MAX_LENGTH = 72
REQUIRED_CLASSES = 3
CLASS_CHECKS = {
lowercase: /[a-z]/,
uppercase: /[A-Z]/,
digit: /\d/,
symbol: /[^A-Za-z0-9]/
}.freeze
class << self
def violation(password)
value = password.to_s
return :blank if value.blank?
return :too_short if value.length < MIN_LENGTH
return :too_long if value.bytesize > MAX_LENGTH
return :too_weak unless strong_enough?(value)
nil
end
def strong_enough?(password)
matched = CLASS_CHECKS.count { |_, pattern| password.match?(pattern) }
matched >= REQUIRED_CLASSES
end
# Confronta con il digest già salvato (prima di assegnare la nuova password).
def same_as_current?(record, password)
return false if password.blank? || !record.respond_to?(:authenticate)
record.authenticate(password).present?
end
def requirement_summary
I18n.t("password_policy.hint")
end
end
included do
validate :password_meets_complexity_policy, if: -> { password.present? }
end
private
def password_meets_complexity_policy
case PasswordComplexity.violation(password)
when :too_short
errors.add(:password, :too_short, count: MIN_LENGTH)
when :too_long
errors.add(:password, :too_long, count: MAX_LENGTH)
when :too_weak
errors.add(:password, :complexity)
end
end
end
+2
View File
@@ -1,4 +1,6 @@
class User < ApplicationRecord class User < ApplicationRecord
include PasswordComplexity
ROLES = %w[admin coach parent volunteer].freeze ROLES = %w[admin coach parent volunteer].freeze
has_secure_password has_secure_password
+13 -14
View File
@@ -116,31 +116,30 @@ module Streams
raise Error, "ffmpeg non disponibile: #{e.message}" raise Error, "ffmpeg non disponibile: #{e.message}"
end end
# RTMP grezzo da telefono (overlay bruciato lato app); fallback HLS via proxy Rails. # Remux verso YouTube: video+audio copy (AAC già da app/slate a 48k mono).
# HLS (ADTS) → FLV richiede -bsf:a aac_adtstoasc; RTMP ha già ASC in FLV tags.
def youtube_ffmpeg_args(intake_source, output) def youtube_ffmpeg_args(intake_source, output)
mode, url = intake_source mode, url = intake_source
if mode == :rtmp common_head = [
return [
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning", "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
"-fflags", "+genpts+discardcorrupt", "-fflags", "+genpts+discardcorrupt"
]
copy_tail = ["-c:v", "copy", "-c:a", "copy", "-f", "flv", output]
if mode == :rtmp
return common_head + [
"-analyzeduration", "10000000", "-probesize", "10000000", "-analyzeduration", "10000000", "-probesize", "10000000",
"-rw_timeout", "15000000", "-rw_timeout", "15000000",
"-i", url, "-i", url
"-c:v", "copy", ] + copy_tail
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
"-bsf:a", "aac_adtstoasc",
"-f", "flv", output
]
end end
[ common_head + [
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
"-fflags", "+genpts+discardcorrupt",
"-rw_timeout", "15000000", "-rw_timeout", "15000000",
"-live_start_index", "-1", "-live_start_index", "-1",
"-i", url, "-i", url,
"-c:v", "copy", "-c:v", "copy",
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1", "-c:a", "copy",
"-bsf:a", "aac_adtstoasc", "-bsf:a", "aac_adtstoasc",
"-f", "flv", output "-f", "flv", output
] ]
@@ -0,0 +1,47 @@
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 != @password_confirmation
return Result.new(ok?: false, error: :mismatch)
end
if (code = PasswordComplexity.violation(@password))
return Result.new(ok?: false, error: code == :blank ? :too_short : code)
end
if PasswordComplexity.same_as_current?(@user, @password)
return Result.new(ok?: false, error: :same_as_current)
end
unless @user.update(password: @password)
complexity_error = @user.errors.details[:password]&.any? { |d| d[:error] == :complexity }
return Result.new(ok?: false, error: complexity_error ? :too_weak : :too_short)
end
@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
+1 -1
View File
@@ -8,7 +8,7 @@
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<%= yield :head %> <%= yield :head %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=44"> <link rel="stylesheet" href="/marketing.css?v=50">
</head> </head>
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>> <body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
<%= render "shared/cookie_banner" %> <%= render "shared/cookie_banner" %>
@@ -6,7 +6,7 @@
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title> <title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=44"> <link rel="stylesheet" href="/marketing.css?v=50">
<link rel="stylesheet" href="/live.css?v=26"> <link rel="stylesheet" href="/live.css?v=26">
<%= yield :head %> <%= yield :head %>
</head> </head>
@@ -41,6 +41,7 @@
<% elsif current_user.manageable_teams.any? %> <% elsif current_user.manageable_teams.any? %>
· <%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first) %> · <%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first) %>
<% end %> <% 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" %> · <%= 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 %> <% else %>
· <%= link_to t("nav.login"), public_login_path %> · <%= link_to t("nav.login"), public_login_path %>
@@ -0,0 +1,56 @@
<% 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>
<p class="muted" style="font-size:0.9rem"><%= t("password_policy.hint") %></p>
<%= 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>
@@ -2,7 +2,8 @@
<% content_for :meta_description, t("auth.invitation.meta_description") %> <% content_for :meta_description, t("auth.invitation.meta_description") %>
<% content_for :robots, "noindex, nofollow" %> <% content_for :robots, "noindex, nofollow" %>
<div class="card" style="max-width:480px"> <section class="auth-page">
<div class="card">
<h1><%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %></h1> <h1><%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %></h1>
<p><%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %></p> <p><%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %></p>
<p class="muted" style="font-size:0.9rem;margin-bottom:16px"> <p class="muted" style="font-size:0.9rem;margin-bottom:16px">
@@ -23,4 +24,5 @@
<%= t("auth.invitation.mobile_app_label") %> <%= t("auth.invitation.mobile_app_label") %>
<a href="matchlivetv://join/<%= @token %>"><%= t("auth.invitation.open_in_app") %></a> <a href="matchlivetv://join/<%= @token %>"><%= t("auth.invitation.open_in_app") %></a>
</p> </p>
</div> </div>
</section>
@@ -46,6 +46,7 @@
<h3><%= t("legal.cookies.s4_1_title") %></h3> <h3><%= t("legal.cookies.s4_1_title") %></h3>
<p><%= t("legal.cookies.s4_1_intro") %></p> <p><%= t("legal.cookies.s4_1_intro") %></p>
<div class="table-scroll">
<table class="legal-table"> <table class="legal-table">
<thead> <thead>
<tr><th><%= t("legal.cookies.table_col_name") %></th><th><%= t("legal.cookies.table_col_purpose") %></th><th><%= t("legal.cookies.table_col_duration") %></th><th><%= t("legal.cookies.table_col_provider") %></th></tr> <tr><th><%= t("legal.cookies.table_col_name") %></th><th><%= t("legal.cookies.table_col_purpose") %></th><th><%= t("legal.cookies.table_col_duration") %></th><th><%= t("legal.cookies.table_col_provider") %></th></tr>
@@ -65,6 +66,7 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
<h3><%= t("legal.cookies.s4_2_title") %></h3> <h3><%= t("legal.cookies.s4_2_title") %></h3>
<p> <p>
@@ -75,6 +77,7 @@
<% else %> <% else %>
<p class="muted"><%= t("legal.cookies.s4_2_inactive") %></p> <p class="muted"><%= t("legal.cookies.s4_2_inactive") %></p>
<% end %> <% end %>
<div class="table-scroll">
<table class="legal-table"> <table class="legal-table">
<thead> <thead>
<tr><th><%= t("legal.cookies.table2_col_name") %></th><th><%= t("legal.cookies.table_col_purpose") %></th><th><%= t("legal.cookies.table_col_duration") %></th><th><%= t("legal.cookies.table_col_provider") %></th></tr> <tr><th><%= t("legal.cookies.table2_col_name") %></th><th><%= t("legal.cookies.table_col_purpose") %></th><th><%= t("legal.cookies.table_col_duration") %></th><th><%= t("legal.cookies.table_col_provider") %></th></tr>
@@ -100,6 +103,7 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
<p> <p>
<%= raw t( <%= raw t(
"legal.cookies.s4_2_p2_html", "legal.cookies.s4_2_p2_html",
@@ -22,6 +22,7 @@
<%= render "shared/plan_cards" %> <%= render "shared/plan_cards" %>
<div class="table-scroll">
<table class="compare-table"> <table class="compare-table">
<thead> <thead>
<tr><th></th><th>Free</th><th>Premium Light</th><th>Premium Full</th></tr> <tr><th></th><th>Free</th><th>Premium Light</th><th>Premium Full</th></tr>
@@ -36,6 +37,7 @@
<tr><td><%= t("pages.pricing.table_price") %></td><td><%= t("pages.pricing.table_price_free") %></td><td><%= raw t("pages.pricing.table_price_light_html") %></td><td><%= raw t("pages.pricing.table_price_full_html") %></td></tr> <tr><td><%= t("pages.pricing.table_price") %></td><td><%= t("pages.pricing.table_price_free") %></td><td><%= raw t("pages.pricing.table_price_light_html") %></td><td><%= raw t("pages.pricing.table_price_full_html") %></td></tr>
</tbody> </tbody>
</table> </table>
</div>
<div class="card" style="margin-top:32px;text-align:center"> <div class="card" style="margin-top:32px;text-align:center">
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3> <h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
@@ -77,6 +77,7 @@
<section> <section>
<h2><%= t("legal.privacy.s5_title") %></h2> <h2><%= t("legal.privacy.s5_title") %></h2>
<div class="table-scroll">
<table class="legal-table"> <table class="legal-table">
<thead> <thead>
<tr><th><%= t("legal.privacy.s5_col_purpose") %></th><th><%= t("legal.privacy.s5_col_basis") %></th></tr> <tr><th><%= t("legal.privacy.s5_col_purpose") %></th><th><%= t("legal.privacy.s5_col_basis") %></th></tr>
@@ -108,6 +109,7 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
</section> </section>
<section> <section>
@@ -4,6 +4,7 @@
<section class="auth-page"> <section class="auth-page">
<h1><%= t("auth.password_reset.title") %></h1> <h1><%= t("auth.password_reset.title") %></h1>
<div class="card"> <div class="card">
<p class="muted" style="font-size:0.9rem"><%= t("password_policy.hint") %></p>
<%= form_with url: public_password_reset_path, method: :patch, local: true do %> <%= form_with url: public_password_reset_path, method: :patch, local: true do %>
<%= hidden_field_tag :token, @token %> <%= hidden_field_tag :token, @token %>
<%= render "shared/input_toggle", <%= render "shared/input_toggle",
@@ -25,6 +25,7 @@
required: true, required: true,
minlength: 8, minlength: 8,
autocomplete: "new-password" %> autocomplete: "new-password" %>
<p class="muted" style="font-size:0.9rem;margin-top:-0.5rem"><%= t("password_policy.hint") %></p>
<%= render "shared/input_toggle", <%= render "shared/input_toggle",
name: "user[password_confirmation]", name: "user[password_confirmation]",
id: "user_password_confirmation", id: "user_password_confirmation",
@@ -20,6 +20,7 @@
<h3 style="margin-top:28px;font-size:1.1rem"><%= t("billing.documents.table_heading") %></h3> <h3 style="margin-top:28px;font-size:1.1rem"><%= t("billing.documents.table_heading") %></h3>
<% if payments.any? %> <% if payments.any? %>
<div class="table-scroll">
<table class="data billing-table"> <table class="data billing-table">
<thead> <thead>
<tr> <tr>
@@ -60,6 +61,7 @@
<% end %> <% end %>
</tbody> </tbody>
</table> </table>
</div>
<% else %> <% else %>
<p style="color:#888"><%= t("billing.documents.no_payments") %></p> <p style="color:#888"><%= t("billing.documents.no_payments") %></p>
<% end %> <% end %>
@@ -58,10 +58,18 @@
} }
toggle.addEventListener("click", function (e) { toggle.addEventListener("click", function (e) {
e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setOpen(menu.hidden); setOpen(menu.hidden);
}); });
// Keep parent mobile nav open while interacting with the switcher UI.
root.addEventListener("click", function (e) {
if (e.target.closest(".lang-switcher__toggle")) {
e.stopPropagation();
}
});
document.addEventListener("click", function (e) { document.addEventListener("click", function (e) {
if (!root.contains(e.target)) setOpen(false); if (!root.contains(e.target)) setOpen(false);
}); });
@@ -19,6 +19,15 @@
<nav id="site-nav" class="nav" aria-label="<%= t('nav.main_menu') %>" aria-hidden="true"> <nav id="site-nav" class="nav" aria-label="<%= t('nav.main_menu') %>" aria-hidden="true">
<div class="nav-panel"> <div class="nav-panel">
<div class="nav-mobile-head">
<%= link_to root_path, class: "nav-mobile-brand", aria: { label: "Match Live TV" }, title: "Match Live TV" do %>
<img class="nav-mobile-brand-logo" src="/logo.png?v=3" alt="" width="40" height="40" decoding="async">
<span class="brand">Match <span>Live TV</span></span>
<% end %>
<div class="nav-lang">
<%= render "shared/language_switcher" %>
</div>
</div>
<%= link_to t("nav.home"), root_path, class: (request.path == "/" ? "nav-active" : nil) %> <%= link_to t("nav.home"), root_path, class: (request.path == "/" ? "nav-active" : nil) %>
<%= link_to t("nav.features"), public_features_path, class: (request.path == "/funzionalita" ? "nav-active" : nil) %> <%= link_to t("nav.features"), public_features_path, class: (request.path == "/funzionalita" ? "nav-active" : nil) %>
<%= link_to t("nav.pricing"), public_prezzi_path, class: (request.path == "/prezzi" ? "nav-active" : nil) %> <%= link_to t("nav.pricing"), public_prezzi_path, class: (request.path == "/prezzi" ? "nav-active" : nil) %>
@@ -34,14 +43,12 @@
<%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first), class: "nav-link-item" %> <%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first), class: "nav-link-item" %>
<% end %> <% end %>
<% 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" %> <%= button_to t("nav.logout"), public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>
<% else %> <% else %>
<%= link_to t("nav.login"), public_login_path, class: "nav-link-item" %> <%= link_to t("nav.login"), public_login_path, class: "nav-link-item" %>
<%= link_to t("nav.signup"), public_signup_path, class: "btn btn-primary nav-btn" %> <%= link_to t("nav.signup"), public_signup_path, class: "btn btn-primary nav-btn" %>
<% end %> <% end %>
<div class="nav-lang">
<%= render "shared/language_switcher" %>
</div>
</div> </div>
</div> </div>
</nav> </nav>
@@ -77,8 +84,16 @@
if (backdrop) backdrop.addEventListener("click", closeMenu); if (backdrop) backdrop.addEventListener("click", closeMenu);
function shouldCloseNavOnControl(el) {
// Language toggle must keep the mobile menu open; only a locale choice may close it.
if (!el.closest("[data-lang-switcher]")) return true;
return el.classList.contains("lang-switcher__option");
}
nav.querySelectorAll("a, button").forEach(function (el) { nav.querySelectorAll("a, button").forEach(function (el) {
el.addEventListener("click", closeMenu); el.addEventListener("click", function () {
if (shouldCloseNavOnControl(el)) closeMenu();
});
}); });
window.addEventListener("resize", function () { window.addEventListener("resize", function () {
+4 -1
View File
@@ -18,6 +18,9 @@ de:
logout_success: Abgemeldet logout_success: Abgemeldet
password_current_incorrect: Das aktuelle Passwort ist falsch password_current_incorrect: Das aktuelle Passwort ist falsch
password_too_short: Das neue Passwort muss mindestens 8 Zeichen lang sein password_too_short: Das neue Passwort muss mindestens 8 Zeichen lang sein
password_too_long: Das neue Passwort darf höchstens 72 Zeichen lang sein
password_too_weak: Das neue Passwort muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten
password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden
password_mismatch: Die Passwörter stimmen nicht überein password_mismatch: Die Passwörter stimmen nicht überein
password_updated: Passwort aktualisiert password_updated: Passwort aktualisiert
ops_acknowledged: Vorfall übernommen ops_acknowledged: Vorfall übernommen
@@ -53,7 +56,7 @@ de:
edit: edit:
title: Passwort ändern title: Passwort ändern
current_password_label: Aktuelles Passwort current_password_label: Aktuelles Passwort
new_password_label: "Neues Passwort (mind. 8 Zeichen)" new_password_label: "Neues Passwort (mind. 8, mindestens 3 Zeichenarten)"
confirm_password_label: Neues Passwort bestätigen confirm_password_label: Neues Passwort bestätigen
submit: Passwort speichern submit: Passwort speichern
cancel: Abbrechen cancel: Abbrechen
+4 -1
View File
@@ -18,6 +18,9 @@ en:
logout_success: Signed out logout_success: Signed out
password_current_incorrect: Current password is incorrect password_current_incorrect: Current password is incorrect
password_too_short: The new password must be at least 8 characters long password_too_short: The new password must be at least 8 characters long
password_too_long: New password cannot exceed 72 characters
password_too_weak: "New password must include at least 3 of: lowercase, uppercase, numbers and symbols"
password_same_as_current: New password must be different from the current password
password_mismatch: Passwords do not match password_mismatch: Passwords do not match
password_updated: Password updated password_updated: Password updated
ops_acknowledged: Incident acknowledged ops_acknowledged: Incident acknowledged
@@ -53,7 +56,7 @@ en:
edit: edit:
title: Change password title: Change password
current_password_label: Current password current_password_label: Current password
new_password_label: "New password (min. 8 characters)" new_password_label: "New password (min. 8, at least 3 character types)"
confirm_password_label: Confirm new password confirm_password_label: Confirm new password
submit: Save password submit: Save password
cancel: Cancel cancel: Cancel
+4 -1
View File
@@ -18,6 +18,9 @@ es:
logout_success: Sesión cerrada logout_success: Sesión cerrada
password_current_incorrect: La contraseña actual no es correcta password_current_incorrect: La contraseña actual no es correcta
password_too_short: La nueva contraseña debe tener al menos 8 caracteres password_too_short: La nueva contraseña debe tener al menos 8 caracteres
password_too_long: La nueva contraseña no puede superar los 72 caracteres
password_too_weak: "La nueva contraseña debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
password_same_as_current: La nueva contraseña debe ser distinta de la actual
password_mismatch: Las contraseñas no coinciden password_mismatch: Las contraseñas no coinciden
password_updated: Contraseña actualizada password_updated: Contraseña actualizada
ops_acknowledged: Incidencia asumida ops_acknowledged: Incidencia asumida
@@ -53,7 +56,7 @@ es:
edit: edit:
title: Cambiar contraseña title: Cambiar contraseña
current_password_label: Contraseña actual current_password_label: Contraseña actual
new_password_label: "Nueva contraseña (mín. 8 caracteres)" new_password_label: "Nueva contraseña (mín. 8, al menos 3 tipos de caracteres)"
confirm_password_label: Confirma la nueva contraseña confirm_password_label: Confirma la nueva contraseña
submit: Guardar contraseña submit: Guardar contraseña
cancel: Cancelar cancel: Cancelar
+4 -1
View File
@@ -18,6 +18,9 @@ fr:
logout_success: Déconnecté logout_success: Déconnecté
password_current_incorrect: Le mot de passe actuel est incorrect password_current_incorrect: Le mot de passe actuel est incorrect
password_too_short: Le nouveau mot de passe doit contenir au moins 8 caractères password_too_short: Le nouveau mot de passe doit contenir au moins 8 caractères
password_too_long: Le nouveau mot de passe ne peut pas dépasser 72 caractères
password_too_weak: "Le nouveau mot de passe doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel"
password_mismatch: Les mots de passe ne correspondent pas password_mismatch: Les mots de passe ne correspondent pas
password_updated: Mot de passe mis à jour password_updated: Mot de passe mis à jour
ops_acknowledged: Incident pris en charge ops_acknowledged: Incident pris en charge
@@ -53,7 +56,7 @@ fr:
edit: edit:
title: Changer le mot de passe title: Changer le mot de passe
current_password_label: Mot de passe actuel current_password_label: Mot de passe actuel
new_password_label: "Nouveau mot de passe (min. 8 caractères)" new_password_label: "Nouveau mot de passe (min. 8, au moins 3 types de caractères)"
confirm_password_label: Confirmer le nouveau mot de passe confirm_password_label: Confirmer le nouveau mot de passe
submit: Enregistrer le mot de passe submit: Enregistrer le mot de passe
cancel: Annuler cancel: Annuler
+4 -1
View File
@@ -18,6 +18,9 @@ it:
logout_success: Disconnesso logout_success: Disconnesso
password_current_incorrect: Password attuale non corretta password_current_incorrect: Password attuale non corretta
password_too_short: La nuova password deve avere almeno 8 caratteri password_too_short: La nuova password deve avere almeno 8 caratteri
password_too_long: La nuova password non può superare i 72 caratteri
password_too_weak: "La nuova password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
password_same_as_current: La nuova password deve essere diversa da quella attuale
password_mismatch: Le password non coincidono password_mismatch: Le password non coincidono
password_updated: Password aggiornata password_updated: Password aggiornata
ops_acknowledged: Incidente preso in carico ops_acknowledged: Incidente preso in carico
@@ -53,7 +56,7 @@ it:
edit: edit:
title: Cambia password title: Cambia password
current_password_label: Password attuale current_password_label: Password attuale
new_password_label: "Nuova password (min. 8 caratteri)" new_password_label: "Nuova password (min. 8, almeno 3 tipi di caratteri)"
confirm_password_label: Conferma nuova password confirm_password_label: Conferma nuova password
submit: Salva password submit: Salva password
cancel: Annulla cancel: Annulla
+39
View File
@@ -1,4 +1,26 @@
de: de:
password_policy:
hint: "Mindestens 8 Zeichen, mit mindestens 3 aus: Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen."
activerecord:
attributes:
user:
password: Passwort
admin_account:
password: Passwort
errors:
models:
user:
attributes:
password:
too_short: "ist zu kurz (mindestens %{count} Zeichen)"
too_long: "ist zu lang (höchstens %{count} Zeichen)"
complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten"
admin_account:
attributes:
password:
too_short: "ist zu kurz (mindestens %{count} Zeichen)"
too_long: "ist zu lang (höchstens %{count} Zeichen)"
complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten"
club: club:
back_to_club: "← Verein" back_to_club: "← Verein"
sport_label: Hauptsportart sport_label: Hauptsportart
@@ -600,12 +622,29 @@ de:
welcome_back: Willkommen zurück! welcome_back: Willkommen zurück!
invalid_credentials: E-Mail oder Passwort ungültig invalid_credentials: E-Mail oder Passwort ungültig
logged_out: Abgemeldet logged_out: Abgemeldet
unauthorized: Nicht autorisiert
invalid_token: Ungültiges Token
password_resets: password_resets:
email_sent: Wenn die E-Mail registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen des Passworts. email_sent: Wenn die E-Mail registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen des Passworts.
invalid_or_expired_link: Link ungültig oder abgelaufen. Fordere ein neues Zurücksetzen des Passworts an. invalid_or_expired_link: Link ungültig oder abgelaufen. Fordere ein neues Zurücksetzen des Passworts an.
password_min_length: Das Passwort muss mindestens 8 Zeichen lang sein password_min_length: Das Passwort muss mindestens 8 Zeichen lang sein
password_too_short: Das Passwort muss mindestens 8 Zeichen lang sein
password_too_long: Das Passwort darf höchstens 72 Zeichen lang sein
password_too_weak: Das Passwort muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten
password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden
password_mismatch: Die Passwörter stimmen nicht überein password_mismatch: Die Passwörter stimmen nicht überein
password_updated: Passwort aktualisiert. Du kannst dich jetzt anmelden. 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_too_long: Das Passwort darf höchstens 72 Zeichen lang sein
password_too_weak: Das Passwort muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten
password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden
password_mismatch: Die Passwörter stimmen nicht überein
password_updated: Passwort aktualisiert.
password_update_failed: Passwort konnte nicht aktualisiert werden
replay: replay:
download_unavailable: Download nicht verfügbar download_unavailable: Download nicht verfügbar
not_available: Replay nicht verfügbar not_available: Replay nicht verfügbar
+34
View File
@@ -1,4 +1,21 @@
en: en:
password_policy:
hint: "At least 8 characters, including 3 of: lowercase, uppercase, numbers and symbols."
activerecord:
errors:
models:
user:
attributes:
password:
too_short: "is too short (minimum is %{count} characters)"
too_long: "is too long (maximum is %{count} characters)"
complexity: "must include at least 3 of: lowercase letters, uppercase letters, numbers and symbols"
admin_account:
attributes:
password:
too_short: "is too short (minimum is %{count} characters)"
too_long: "is too long (maximum is %{count} characters)"
complexity: "must include at least 3 of: lowercase letters, uppercase letters, numbers and symbols"
club: club:
back_to_club: "← Club" back_to_club: "← Club"
sport_label: Main sport sport_label: Main sport
@@ -600,12 +617,29 @@ en:
welcome_back: Welcome back! welcome_back: Welcome back!
invalid_credentials: Invalid email or password invalid_credentials: Invalid email or password
logged_out: Logged out logged_out: Logged out
unauthorized: Unauthorized
invalid_token: Invalid token
password_resets: password_resets:
email_sent: If the email is registered, you'll receive a password reset link shortly. email_sent: If the email is registered, you'll receive a password reset link shortly.
invalid_or_expired_link: Invalid or expired link. Request a new password reset. invalid_or_expired_link: Invalid or expired link. Request a new password reset.
password_min_length: Password must be at least 8 characters password_min_length: Password must be at least 8 characters
password_too_short: Password must be at least 8 characters
password_too_long: Password cannot exceed 72 characters
password_too_weak: "Password must include at least 3 of: lowercase, uppercase, numbers and symbols"
password_same_as_current: New password must be different from the current password
password_mismatch: Passwords don't match password_mismatch: Passwords don't match
password_updated: Password updated. You can now log in. 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_too_long: Password cannot exceed 72 characters
password_too_weak: "Password must include at least 3 of: lowercase, uppercase, numbers and symbols"
password_same_as_current: New password must be different from the current password
password_mismatch: Passwords do not match
password_updated: Password updated.
password_update_failed: Unable to update password
replay: replay:
download_unavailable: Download not available download_unavailable: Download not available
not_available: Replay not available not_available: Replay not available
+39
View File
@@ -1,4 +1,26 @@
es: es:
password_policy:
hint: "Mínimo 8 caracteres, con al menos 3 entre: minúsculas, mayúsculas, números y símbolos."
activerecord:
attributes:
user:
password: Contraseña
admin_account:
password: Contraseña
errors:
models:
user:
attributes:
password:
too_short: "es demasiado corta (mínimo %{count} caracteres)"
too_long: "es demasiado larga (máximo %{count} caracteres)"
complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
admin_account:
attributes:
password:
too_short: "es demasiado corta (mínimo %{count} caracteres)"
too_long: "es demasiado larga (máximo %{count} caracteres)"
complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
club: club:
back_to_club: "← Club" back_to_club: "← Club"
sport_label: Deporte principal sport_label: Deporte principal
@@ -600,12 +622,29 @@ es:
welcome_back: "¡Bienvenido de nuevo!" welcome_back: "¡Bienvenido de nuevo!"
invalid_credentials: Correo o contraseña no válidos invalid_credentials: Correo o contraseña no válidos
logged_out: Sesión cerrada logged_out: Sesión cerrada
unauthorized: No autorizado
invalid_token: Token no válido
password_resets: password_resets:
email_sent: Si el correo está registrado, recibirás en breve un enlace para restablecer la contraseña. email_sent: Si el correo está registrado, recibirás en breve un enlace para restablecer la contraseña.
invalid_or_expired_link: Enlace no válido o caducado. Solicita un nuevo restablecimiento de contraseña. invalid_or_expired_link: Enlace no válido o caducado. Solicita un nuevo restablecimiento de contraseña.
password_min_length: La contraseña debe tener al menos 8 caracteres password_min_length: La contraseña debe tener al menos 8 caracteres
password_too_short: La contraseña debe tener al menos 8 caracteres
password_too_long: La contraseña no puede superar los 72 caracteres
password_too_weak: "La contraseña debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
password_same_as_current: La nueva contraseña debe ser distinta de la actual
password_mismatch: Las contraseñas no coinciden password_mismatch: Las contraseñas no coinciden
password_updated: Contraseña actualizada. Ya puedes iniciar sesión. 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_too_long: La contraseña no puede superar los 72 caracteres
password_too_weak: "La contraseña debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
password_same_as_current: La nueva contraseña debe ser distinta de la actual
password_mismatch: Las contraseñas no coinciden
password_updated: Contraseña actualizada.
password_update_failed: No se pudo actualizar la contraseña
replay: replay:
download_unavailable: Descarga no disponible download_unavailable: Descarga no disponible
not_available: Repetición no disponible not_available: Repetición no disponible
+39
View File
@@ -1,4 +1,26 @@
fr: fr:
password_policy:
hint: "Au moins 8 caractères, avec au moins 3 parmi : minuscules, majuscules, chiffres et symboles."
activerecord:
attributes:
user:
password: Mot de passe
admin_account:
password: Mot de passe
errors:
models:
user:
attributes:
password:
too_short: "est trop court (minimum %{count} caractères)"
too_long: "est trop long (maximum %{count} caractères)"
complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
admin_account:
attributes:
password:
too_short: "est trop court (minimum %{count} caractères)"
too_long: "est trop long (maximum %{count} caractères)"
complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
club: club:
back_to_club: "← Club" back_to_club: "← Club"
sport_label: Sport principal sport_label: Sport principal
@@ -600,12 +622,29 @@ fr:
welcome_back: Bon retour ! welcome_back: Bon retour !
invalid_credentials: E-mail ou mot de passe invalide invalid_credentials: E-mail ou mot de passe invalide
logged_out: Déconnecté logged_out: Déconnecté
unauthorized: Non autorisé
invalid_token: Jeton invalide
password_resets: password_resets:
email_sent: Si l'e-mail est enregistré, tu recevras bientôt un lien pour réinitialiser le mot de passe. email_sent: Si l'e-mail est enregistré, tu recevras bientôt un lien pour réinitialiser le mot de passe.
invalid_or_expired_link: Lien invalide ou expiré. Demande une nouvelle réinitialisation du mot de passe. invalid_or_expired_link: Lien invalide ou expiré. Demande une nouvelle réinitialisation du mot de passe.
password_min_length: Le mot de passe doit comporter au moins 8 caractères password_min_length: Le mot de passe doit comporter au moins 8 caractères
password_too_short: Le mot de passe doit comporter au moins 8 caractères
password_too_long: Le mot de passe ne peut pas dépasser 72 caractères
password_too_weak: "Le mot de passe doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel"
password_mismatch: Les mots de passe ne correspondent pas password_mismatch: Les mots de passe ne correspondent pas
password_updated: Mot de passe mis à jour. Tu peux maintenant te connecter. 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_too_long: Le mot de passe ne peut pas dépasser 72 caractères
password_too_weak: "Le mot de passe doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel"
password_mismatch: Les mots de passe ne correspondent pas
password_updated: Mot de passe mis à jour.
password_update_failed: Impossible de mettre à jour le mot de passe
replay: replay:
download_unavailable: Téléchargement non disponible download_unavailable: Téléchargement non disponible
not_available: Replay non disponible not_available: Replay non disponible
+39
View File
@@ -1,4 +1,21 @@
it: it:
password_policy:
hint: "Minimo 8 caratteri, con almeno 3 tra: minuscole, maiuscole, numeri e simboli."
activerecord:
errors:
models:
user:
attributes:
password:
too_short: "è troppo corta (minimo %{count} caratteri)"
too_long: "è troppo lunga (massimo %{count} caratteri)"
complexity: "deve includere almeno 3 tra: lettere minuscole, maiuscole, numeri e simboli"
admin_account:
attributes:
password:
too_short: "è troppo corta (minimo %{count} caratteri)"
too_long: "è troppo lunga (massimo %{count} caratteri)"
complexity: "deve includere almeno 3 tra: lettere minuscole, maiuscole, numeri e simboli"
club: club:
back_to_club: "← Società" back_to_club: "← Società"
sport_label: Sport principale sport_label: Sport principale
@@ -600,12 +617,34 @@ it:
welcome_back: Bentornato! welcome_back: Bentornato!
invalid_credentials: Email o password non validi invalid_credentials: Email o password non validi
logged_out: Disconnesso logged_out: Disconnesso
unauthorized: Non autorizzato
invalid_token: Token non valido
password_resets: password_resets:
email_sent: Se l'email è registrata, riceverai a breve un link per reimpostare la password. email_sent: Se l'email è registrata, riceverai a breve un link per reimpostare la password.
invalid_or_expired_link: Link non valido o scaduto. Richiedi un nuovo reset password. invalid_or_expired_link: Link non valido o scaduto. Richiedi un nuovo reset password.
password_min_length: La password deve avere almeno 8 caratteri password_min_length: La password deve avere almeno 8 caratteri
password_too_short: La password deve avere almeno 8 caratteri
password_too_long: La password non può superare i 72 caratteri
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
password_same_as_current: La nuova password deve essere diversa da quella attuale
password_too_short: La password deve avere almeno 8 caratteri
password_too_long: La password non può superare i 72 caratteri
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
password_mismatch: Le password non coincidono password_mismatch: Le password non coincidono
password_updated: Password aggiornata. Ora puoi accedere. 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_too_long: La password non può superare i 72 caratteri
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
password_same_as_current: La nuova password deve essere diversa da quella attuale
password_too_long: La password non può superare i 72 caratteri
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
password_mismatch: Le password non coincidono
password_updated: Password aggiornata.
password_update_failed: Impossibile aggiornare la password
replay: replay:
download_unavailable: Download non disponibile download_unavailable: Download non disponibile
not_available: Replay non disponibile not_available: Replay non disponibile
+14 -1
View File
@@ -16,6 +16,7 @@ de:
my_team: Mein Team my_team: Mein Team
login: Anmelden login: Anmelden
logout: Abmelden logout: Abmelden
account: Konto
signup: Team registrieren signup: Team registrieren
footer: footer:
tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können
@@ -102,7 +103,7 @@ de:
password_reset: password_reset:
meta_title: "Neues Passwort — Match Live TV" meta_title: "Neues Passwort — Match Live TV"
title: Neues Passwort wählen title: Neues Passwort wählen
new_password_label: "Neues Passwort (min. 8 Zeichen)" new_password_label: "Neues Passwort (mind. 8, mindestens 3 Zeichenarten)"
submit: Passwort speichern submit: Passwort speichern
back_to_login: Zurück zur Anmeldung back_to_login: Zurück zur Anmeldung
invitation: invitation:
@@ -118,6 +119,18 @@ de:
signup_link: registrieren Sie sich signup_link: registrieren Sie sich
mobile_app_label: "Mobile App:" mobile_app_label: "Mobile App:"
open_in_app: Einladung in der App öffnen 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, mindestens 3 Zeichenarten)"
save_profile: Profil speichern
save_password: Passwort aktualisieren
common: common:
privacy: Datenschutz privacy: Datenschutz
cookies: Cookies cookies: Cookies
+14 -1
View File
@@ -16,6 +16,7 @@ en:
my_team: My team my_team: My team
login: Log in login: Log in
logout: Log out logout: Log out
account: Account
signup: Register a team signup: Register a team
footer: footer:
tagline: Every match, every event, for those who can't be there tagline: Every match, every event, for those who can't be there
@@ -102,7 +103,7 @@ en:
password_reset: password_reset:
meta_title: "New password — Match Live TV" meta_title: "New password — Match Live TV"
title: Choose a new password title: Choose a new password
new_password_label: "New password (min. 8 characters)" new_password_label: "New password (min. 8, at least 3 character types)"
submit: Save password submit: Save password
back_to_login: Back to login back_to_login: Back to login
invitation: invitation:
@@ -118,6 +119,18 @@ en:
signup_link: sign up signup_link: sign up
mobile_app_label: "Mobile app:" mobile_app_label: "Mobile app:"
open_in_app: Open invitation in the 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, at least 3 character types)"
save_profile: Save profile
save_password: Update password
common: common:
privacy: Privacy privacy: Privacy
cookies: Cookies cookies: Cookies
+14 -1
View File
@@ -16,6 +16,7 @@ es:
my_team: Mi equipo my_team: Mi equipo
login: Acceder login: Acceder
logout: Salir logout: Salir
account: Cuenta
signup: Registrar equipo signup: Registrar equipo
footer: footer:
tagline: Cada partido, cada evento, para quien no puede estar tagline: Cada partido, cada evento, para quien no puede estar
@@ -102,7 +103,7 @@ es:
password_reset: password_reset:
meta_title: "Nueva contraseña — Match Live TV" meta_title: "Nueva contraseña — Match Live TV"
title: Elige una nueva contraseña title: Elige una nueva contraseña
new_password_label: "Nueva contraseña (mín. 8 caracteres)" new_password_label: "Nueva contraseña (mín. 8, al menos 3 tipos de caracteres)"
submit: Guardar contraseña submit: Guardar contraseña
back_to_login: Volver al inicio de sesión back_to_login: Volver al inicio de sesión
invitation: invitation:
@@ -118,6 +119,18 @@ es:
signup_link: regístrate signup_link: regístrate
mobile_app_label: "App móvil:" mobile_app_label: "App móvil:"
open_in_app: Abrir invitación en la app 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, al menos 3 tipos de caracteres)"
save_profile: Guardar perfil
save_password: Actualizar contraseña
common: common:
privacy: Privacidad privacy: Privacidad
cookies: Cookies cookies: Cookies
+14 -1
View File
@@ -16,6 +16,7 @@ fr:
my_team: Mon équipe my_team: Mon équipe
login: Connexion login: Connexion
logout: Déconnexion logout: Déconnexion
account: Compte
signup: Inscrire une équipe signup: Inscrire une équipe
footer: footer:
tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là
@@ -102,7 +103,7 @@ fr:
password_reset: password_reset:
meta_title: "Nouveau mot de passe — Match Live TV" meta_title: "Nouveau mot de passe — Match Live TV"
title: Choisissez un nouveau mot de passe title: Choisissez un nouveau mot de passe
new_password_label: "Nouveau mot de passe (8 caractères min.)" new_password_label: "Nouveau mot de passe (min. 8, au moins 3 types de caractères)"
submit: Enregistrer le mot de passe submit: Enregistrer le mot de passe
back_to_login: Retour à la connexion back_to_login: Retour à la connexion
invitation: invitation:
@@ -118,6 +119,18 @@ fr:
signup_link: inscrivez-vous signup_link: inscrivez-vous
mobile_app_label: "Application mobile :" mobile_app_label: "Application mobile :"
open_in_app: Ouvrir l'invitation dans l'application 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, au moins 3 types de caractères)"
save_profile: Enregistrer le profil
save_password: Mettre à jour le mot de passe
common: common:
privacy: Confidentialité privacy: Confidentialité
cookies: Cookies cookies: Cookies
+14 -1
View File
@@ -16,6 +16,7 @@ it:
my_team: La mia squadra my_team: La mia squadra
login: Accedi login: Accedi
logout: Esci logout: Esci
account: Account
signup: Registra squadra signup: Registra squadra
footer: footer:
tagline: Ogni partita, ogni evento, per chi non può esserci tagline: Ogni partita, ogni evento, per chi non può esserci
@@ -102,7 +103,7 @@ it:
password_reset: password_reset:
meta_title: "Nuova password — Match Live TV" meta_title: "Nuova password — Match Live TV"
title: Scegli una nuova password title: Scegli una nuova password
new_password_label: "Nuova password (min. 8 caratteri)" new_password_label: "Nuova password (min. 8, almeno 3 tipi di caratteri)"
submit: Salva password submit: Salva password
back_to_login: Torna al login back_to_login: Torna al login
invitation: invitation:
@@ -118,6 +119,18 @@ it:
signup_link: registrati signup_link: registrati
mobile_app_label: "App mobile:" mobile_app_label: "App mobile:"
open_in_app: Apri invito nell'app 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, almeno 3 tipi di caratteri)"
save_profile: Salva profilo
save_password: Aggiorna password
common: common:
privacy: Privacy privacy: Privacy
cookies: Cookie cookies: Cookie
+8
View File
@@ -11,6 +11,10 @@ Rails.application.routes.draw do
post "auth/logout", to: "auth#logout" post "auth/logout", to: "auth#logout"
post "auth/refresh", to: "auth#refresh" post "auth/refresh", to: "auth#refresh"
get "auth/me", to: "auth#me" 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 "sports", to: "sports#index"
get "invitations/:token", to: "invitations#show" get "invitations/:token", to: "invitations#show"
@@ -163,6 +167,10 @@ Rails.application.routes.draw do
post "password/forgot", to: "password_resets#create" post "password/forgot", to: "password_resets#create"
get "password/reset", to: "password_resets#edit", as: :password_reset get "password/reset", to: "password_resets#edit", as: :password_reset
patch "password/reset", to: "password_resets#update" patch "password/reset", to: "password_resets#update"
get "account", to: "accounts#show", as: :account
patch "account", to: "accounts#update"
get "account/password", to: redirect("/account"), as: nil
patch "account/password", to: "accounts#update_password", as: :account_password
get "clubs/new", to: "clubs#new", as: :new_club get "clubs/new", to: "clubs#new", as: :new_club
post "clubs", to: "clubs#create" post "clubs", to: "clubs#create"
get "clubs/:id", to: "clubs#show", as: :club get "clubs/:id", to: "clubs#show", as: :club
+4 -4
View File
@@ -1,18 +1,18 @@
load Rails.root.join("db/seeds/plans.rb") load Rails.root.join("db/seeds/plans.rb")
AdminAccount.find_or_create_by!(username: "admin") do |a| AdminAccount.find_or_create_by!(username: "admin") do |a|
a.password = "admin" a.password = "AdminPass123"
end end
coach = User.find_or_create_by!(email: "coach@matchlivetv.test") do |u| coach = User.find_or_create_by!(email: "coach@matchlivetv.test") do |u|
u.name = "Coach Demo" u.name = "Coach Demo"
u.password = "password123" u.password = "Password123"
u.role = "coach" u.role = "coach"
end end
admin = User.find_or_create_by!(email: "admin@matchlivetv.test") do |u| admin = User.find_or_create_by!(email: "admin@matchlivetv.test") do |u|
u.name = "Admin" u.name = "Admin"
u.password = "password123" u.password = "Password123"
u.role = "admin" u.role = "admin"
end end
@@ -41,5 +41,5 @@ match = team.matches.find_or_create_by!(opponent_name: "ASD Eagles Pavia") do |m
m.phase = "Semifinale" m.phase = "Semifinale"
end end
puts "Seed OK: coach@matchlivetv.test / password123" puts "Seed OK: coach@matchlivetv.test / Password123"
puts "Club: #{club.name}, Team: #{team.name}, Match: #{match.opponent_name}" puts "Club: #{club.name}, Team: #{team.name}, Match: #{match.opponent_name}"
+107 -16
View File
@@ -199,6 +199,17 @@ body.nav-menu-open { overflow: hidden; }
body.nav-menu-open .site-chrome { body.nav-menu-open .site-chrome {
z-index: 1300; z-index: 1300;
} }
/* Keep the close control above the full-screen sheet; hide duplicate mast brand. */
body.nav-menu-open .site-masthead {
z-index: 1320;
background: transparent;
border-bottom-color: transparent;
backdrop-filter: none;
}
body.nav-menu-open .mast-brand {
visibility: hidden;
pointer-events: none;
}
.nav-backdrop { .nav-backdrop {
display: block; display: block;
position: fixed; position: fixed;
@@ -236,16 +247,50 @@ body.nav-menu-open { overflow: hidden; }
.nav-panel { .nav-panel {
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
flex-wrap: nowrap;
align-items: stretch; align-items: stretch;
gap: 0; gap: 0;
width: 100%; width: 100%;
max-width: none; max-width: none;
min-height: 0;
margin: 0; margin: 0;
padding: 72px 24px 32px; padding: 16px 24px 32px;
padding-top: calc(72px + env(safe-area-inset-top, 0px)); padding-top: calc(16px + env(safe-area-inset-top, 0px));
padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px)); padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px));
overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
} }
.nav-mobile-head {
order: -1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
min-height: 44px;
margin: 0 0 8px;
padding: 0 52px 16px 0; /* room for the close (X) control on the right */
border-bottom: 1px solid #252530;
flex-shrink: 0;
}
.nav-mobile-brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
text-decoration: none;
line-height: 1;
}
.nav-mobile-brand:hover { text-decoration: none; opacity: 0.92; }
.nav-mobile-brand .brand { font-size: 1.05rem; }
.nav-mobile-brand-logo {
display: block;
width: 40px;
height: 40px;
border-radius: 8px;
object-fit: contain;
flex-shrink: 0;
}
.nav-panel > a, .nav-panel > a,
.nav-panel .nav-link-item { .nav-panel .nav-link-item {
display: block; display: block;
@@ -263,11 +308,13 @@ body.nav-menu-open { overflow: hidden; }
} }
.nav-actions { .nav-actions {
flex-direction: column; flex-direction: column;
flex-wrap: nowrap;
align-items: stretch; align-items: stretch;
gap: 14px; gap: 0;
margin-top: 24px; margin-top: 8px;
padding: 24px 0 0; padding: 0;
border-top: 1px solid #252530; border-top: none;
width: 100%;
} }
.nav-actions .nav-link-item { .nav-actions .nav-link-item {
display: block; display: block;
@@ -289,10 +336,14 @@ body.nav-menu-open { overflow: hidden; }
border-radius: 10px; border-radius: 10px;
} }
.nav-lang { .nav-lang {
margin-left: 0; margin: 0;
margin-top: 4px;
justify-content: flex-end; justify-content: flex-end;
width: 100%; width: auto;
flex-shrink: 0;
}
.nav-lang .lang-switcher__menu {
/* Keep the list inside the open mobile sheet */
z-index: 1320;
} }
} }
@@ -342,14 +393,22 @@ body.nav-menu-open { overflow: hidden; }
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 8px 20px; gap: 8px 20px;
} }
.nav-mobile-head {
display: contents;
}
.nav-mobile-brand {
display: none;
}
.nav-lang {
order: 2;
margin-left: 2px;
}
.nav-actions { .nav-actions {
order: 1;
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 8px 12px; gap: 8px 12px;
margin-left: auto; margin-left: auto;
} }
.nav-lang {
margin-left: 2px;
}
.nav-backdrop { display: none !important; } .nav-backdrop { display: none !important; }
} }
.btn { display: inline-block; padding: 10px 18px; border-radius: 8px; font-weight: 700; font-size: 0.9rem; border: none; cursor: pointer; text-decoration: none; } .btn { display: inline-block; padding: 10px 18px; border-radius: 8px; font-weight: 700; font-size: 0.9rem; border: none; cursor: pointer; text-decoration: none; }
@@ -1230,7 +1289,22 @@ body.nav-menu-open { overflow: hidden; }
.hero-split .hero-cta { flex-direction: column; } .hero-split .hero-cta { flex-direction: column; }
.hero-split .hero-cta .btn { width: 100%; text-align: center; } .hero-split .hero-cta .btn { width: 100%; text-align: center; }
} }
.section { padding: 40px 0; } .section {
padding-top: 40px;
padding-bottom: 40px;
}
/* Keep horizontal inset when .section shares a node with .wrap (padding shorthand must not win). */
.section.wrap {
padding-left: 20px;
padding-right: 20px;
}
.table-scroll {
width: 100%;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin-top: 20px;
}
.section h2 { font-size: 1.6rem; margin: 0 0 20px; text-align: center; } .section h2 { font-size: 1.6rem; margin: 0 0 20px; text-align: center; }
.steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; } .steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; }
.step { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 22px; } .step { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 22px; }
@@ -1363,7 +1437,7 @@ body.nav-menu-open { overflow: hidden; }
.site-footer__legal { flex: 1 1 100%; margin-top: 4px; } .site-footer__legal { flex: 1 1 100%; margin-top: 4px; }
.site-footer__legal p { margin: 0 0 6px; line-height: 1.45; } .site-footer__legal p { margin: 0 0 6px; line-height: 1.45; }
.site-footer__legal p:last-child { margin-bottom: 0; } .site-footer__legal p:last-child { margin-bottom: 0; }
.compare-table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 0.9rem; } .compare-table { width: 100%; min-width: 520px; border-collapse: collapse; margin-top: 0; font-size: 0.9rem; }
.compare-table th, .compare-table td { padding: 10px 12px; border-bottom: 1px solid #2a2a36; text-align: left; } .compare-table th, .compare-table td { padding: 10px 12px; border-bottom: 1px solid #2a2a36; text-align: left; }
.compare-table th { color: #aaa; font-weight: 600; } .compare-table th { color: #aaa; font-weight: 600; }
.billing-documents h2 { margin-top: 0; } .billing-documents h2 { margin-top: 0; }
@@ -1372,11 +1446,14 @@ body.nav-menu-open { overflow: hidden; }
.card { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 20px; margin-bottom: 16px; } .card { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 20px; margin-bottom: 16px; }
.seo-prose { max-width: 720px; margin: 0 auto; color: #bbb; line-height: 1.65; } .seo-prose { max-width: 720px; margin: 0 auto; color: #bbb; line-height: 1.65; }
.seo-prose h2 { color: #fff; font-size: 1.25rem; margin: 28px 0 12px; } .seo-prose h2 { color: #fff; font-size: 1.25rem; margin: 28px 0 12px; text-align: left; }
.seo-prose h2:first-child { margin-top: 0; } .seo-prose h2:first-child { margin-top: 0; }
.seo-prose p { margin: 0 0 14px; } .seo-prose p { margin: 0 0 14px; }
.seo-prose ul { margin: 0 0 16px; padding-left: 1.25rem; } .seo-prose ul { margin: 0 0 16px; padding-left: 1.25rem; }
.seo-prose a { color: #e53935; } .seo-prose a { color: #e53935; }
@media (max-width: 899px) {
.seo-prose h2 { font-size: 1.15rem; line-height: 1.35; }
}
.seo-page .seo-lead { color: #aaa; max-width: 640px; line-height: 1.55; margin-bottom: 28px; } .seo-page .seo-lead { color: #aaa; max-width: 640px; line-height: 1.55; margin-bottom: 28px; }
.faq-list { max-width: 720px; margin: 0 auto; } .faq-list { max-width: 720px; margin: 0 auto; }
.faq-item { .faq-item {
@@ -1412,7 +1489,8 @@ body.nav-menu-open { overflow: hidden; }
.legal-doc a { color: #e53935; } .legal-doc a { color: #e53935; }
.legal-meta { color: #888; font-size: 0.88rem; margin-bottom: 24px; } .legal-meta { color: #888; font-size: 0.88rem; margin-bottom: 24px; }
.legal-back { margin-top: 32px; } .legal-back { margin-top: 32px; }
.legal-table { width: 100%; border-collapse: collapse; margin: 12px 0 16px; font-size: 0.88rem; } .legal-doc .table-scroll { margin: 12px 0 16px; }
.legal-table { width: 100%; min-width: 560px; border-collapse: collapse; margin: 0; font-size: 0.88rem; }
.legal-table th, .legal-table td { border: 1px solid #2a2a36; padding: 10px 12px; text-align: left; vertical-align: top; } .legal-table th, .legal-table td { border: 1px solid #2a2a36; padding: 10px 12px; text-align: left; vertical-align: top; }
.legal-table th { background: #14141c; color: #ccc; } .legal-table th { background: #14141c; color: #ccc; }
.legal-accept { .legal-accept {
@@ -1447,6 +1525,19 @@ body.nav-menu-open { overflow: hidden; }
.auth-forgot a { color: #e53935; } .auth-forgot a { color: #e53935; }
table.data { width: 100%; border-collapse: collapse; } table.data { width: 100%; border-collapse: collapse; }
table.data th, table.data td { padding: 8px; border-bottom: 1px solid #2a2a36; text-align: left; } table.data th, table.data td { padding: 8px; border-bottom: 1px solid #2a2a36; text-align: left; }
/* Wide roster/match tables: scroll inside the card instead of expanding the page. */
@media (max-width: 899px) {
.card:has(table.data),
.team-streaming-staff:has(table.data),
.billing-documents:has(table.data) {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
max-width: 100%;
}
table.data {
min-width: 520px;
}
}
input, select { input, select {
width: 100%; width: 100%;
padding: 12px 14px; padding: 12px 14px;
@@ -0,0 +1,45 @@
require "rails_helper"
RSpec.describe PasswordComplexity do
describe ".violation" do
it "accepts a password with 3 character classes" do
expect(described_class.violation("NewPass123")).to be_nil
end
it "accepts symbols instead of one letter class" do
expect(described_class.violation("newpass1!")).to be_nil
end
it "rejects passwords that are too short" do
expect(described_class.violation("Ab1!")).to eq(:too_short)
end
it "rejects passwords with fewer than 3 classes" do
expect(described_class.violation("password123")).to eq(:too_weak)
expect(described_class.violation("PASSWORD123")).to eq(:too_weak)
expect(described_class.violation("Password")).to eq(:too_weak)
end
end
describe ".same_as_current?" do
let!(:user) { User.create!(email: "same@example.com", name: "Same", password: "Password123", role: "coach") }
it "detects when the new password matches the current one" do
expect(described_class.same_as_current?(user, "Password123")).to eq(true)
expect(described_class.same_as_current?(user, "OtherPass123")).to eq(false)
end
end
describe "User validation" do
it "blocks weak passwords on create" do
user = User.new(email: "weak@example.com", name: "Weak", password: "password123", role: "coach")
expect(user).not_to be_valid
expect(user.errors[:password]).to be_present
end
it "allows strong passwords on create" do
user = User.new(email: "strong@example.com", name: "Strong", password: "NewPass123", role: "coach")
expect(user).to be_valid
end
end
end
+127
View File
@@ -0,0 +1,127 @@
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
it "rejects a password that fails complexity rules" do
patch "/api/v1/account/password",
params: {
current_password: "Password123",
password: "newpass123",
password_confirmation: "newpass123"
},
headers: auth_headers.merge("Accept-Language" => "en")
expect(response).to have_http_status(:unprocessable_entity)
expect(JSON.parse(response.body)["error"]).to match(/3 of/i)
expect(user.reload.authenticate("Password123")).to be_truthy
end
it "rejects reusing the current password" do
patch "/api/v1/account/password",
params: {
current_password: "Password123",
password: "Password123",
password_confirmation: "Password123"
},
headers: auth_headers.merge("Accept-Language" => "en")
expect(response).to have_http_status(:unprocessable_entity)
expect(JSON.parse(response.body)["error"]).to match(/different/i)
expect(user.reload.authenticate("Password123")).to be_truthy
end
it "localizes password errors from Accept-Language" do
patch "/api/v1/account/password",
params: {
current_password: "Password123",
password: "Password123",
password_confirmation: "Password123"
},
headers: auth_headers.merge("Accept-Language" => "it")
expect(response).to have_http_status(:unprocessable_entity)
expect(JSON.parse(response.body)["error"]).to eq("La nuova password deve essere diversa da quella attuale")
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
it "localizes the response message from Accept-Language" do
post "/api/v1/auth/password/forgot",
params: { email: user.email },
headers: { "Accept-Language" => "fr" }
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body)["message"]).to include("réinitialiser")
end
end
end
+2 -2
View File
@@ -1,10 +1,10 @@
require "rails_helper" require "rails_helper"
RSpec.describe "Auth API", type: :request do RSpec.describe "Auth API", type: :request do
let!(:user) { User.create!(email: "test@example.com", name: "Test", password: "password123", role: "coach") } let!(:user) { User.create!(email: "test@example.com", name: "Test", password: "Password123", role: "coach") }
it "logs in with valid credentials" do it "logs in with valid credentials" do
post "/api/v1/auth/login", params: { email: user.email, password: "password123" } post "/api/v1/auth/login", params: { email: user.email, password: "Password123" }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body)).to have_key("access_token") expect(JSON.parse(response.body)).to have_key("access_token")
end end
@@ -0,0 +1,24 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Streams::YoutubeRelay do
describe ".youtube_ffmpeg_args (private)" do
def args(mode, url)
described_class.send(:youtube_ffmpeg_args, [mode, url], "rtmps://a.rtmps.youtube.com/live2/key")
end
it "remuxes RTMP with video and audio copy (no AAC re-encode)" do
cmd = args(:rtmp, "rtmp://mediamtx:1935/live/match_x")
expect(cmd).to include("-c:v", "copy", "-c:a", "copy", "-f", "flv")
expect(cmd).not_to include("aac")
expect(cmd.join(" ")).not_to include("-b:a")
end
it "remuxes HLS with AAC bitstream filter for FLV" do
cmd = args(:hls, "http://mediamtx:8888/live/match_x/index.m3u8")
expect(cmd).to include("-c:v", "copy", "-c:a", "copy", "-bsf:a", "aac_adtstoasc", "-f", "flv")
expect(cmd).not_to include("-b:a")
end
end
end
+4 -3
View File
@@ -202,7 +202,7 @@ MediaMTX :1935 — path live/match_{uuid}
├──► HLS :8888 ──► edge /hls/ ──► spettatori web ├──► HLS :8888 ──► edge /hls/ ──► spettatori web
└──► (solo platform=youtube) └──► (solo platform=youtube)
Streams::YoutubeRelay in Sidekiq Streams::YoutubeRelay in Sidekiq
ffmpeg: -c:v copy, -c:a aac ──► YouTube RTMPS ffmpeg: -c:v copy, -c:a copy ──► YouTube RTMPS
``` ```
| Componente | Ruolo | | Componente | Ruolo |
@@ -210,7 +210,7 @@ MediaMTX :1935 — path live/match_{uuid}
| Path dinamici via API :9997 | `Mediamtx::Client` (`create_path`, recording patch, …) | | Path dinamici via API :9997 | `Mediamtx::Client` (`create_path`, recording patch, …) |
| `alwaysAvailable` + slate | Copertina `/slates/offline.mp4` senza interrompere HLS | | `alwaysAvailable` + slate | Copertina `/slates/offline.mp4` senza interrompere HLS |
| Overlay tabellone | **App nativa** (non più ricodifica server) | | Overlay tabellone | **App nativa** (non più ricodifica server) |
| `Streams::YoutubeRelay` | Legge RTMP (o HLS) da MediaMTX; copy video + AAC → YouTube | | `Streams::YoutubeRelay` | Legge RTMP (o HLS) da MediaMTX; remux copy A/V → YouTube |
| `Mediamtx::PublisherSync` | Publisher online/offline, go_live, recording on/off | | `Mediamtx::PublisherSync` | Publisher online/offline, go_live, recording on/off |
Dettaglio pause, player e ruolo ffmpeg: [`LIVE_STREAMING.md`](LIVE_STREAMING.md). Dettaglio pause, player e ruolo ffmpeg: [`LIVE_STREAMING.md`](LIVE_STREAMING.md).
@@ -281,7 +281,7 @@ App Android ◄──── REST API (/api/v1/...) ────► Rails
Sidekiq ◄── Redis ◄──────┘ Sidekiq ◄── Redis ◄──────┘
├── HealthMonitorJob, UploadJob (merge/thumbnail ffmpeg) ├── HealthMonitorJob, UploadJob (merge/thumbnail ffmpeg)
├── YoutubeRelay (ffmpeg copy+AAC, solo dirette YouTube) ├── YoutubeRelay (ffmpeg remux copy A/V, solo dirette YouTube)
└── PublishToYoutubeJob, purge replay, ecc. └── PublishToYoutubeJob, purge replay, ecc.
``` ```
@@ -419,3 +419,4 @@ cd infra && cp .env.example .env && docker compose up -d --build
| 2026-06 | `RAILS_MAX_THREADS=5`; relay YouTube da MediaMTX (non via Puma) | | 2026-06 | `RAILS_MAX_THREADS=5`; relay YouTube da MediaMTX (non via Puma) |
| 2026-06 | Cleanup path MediaMTX orfani (`Mediamtx::CleanupOrphanPaths`); delete path sempre a fine diretta | | 2026-06 | Cleanup path MediaMTX orfani (`Mediamtx::CleanupOrphanPaths`); delete path sempre a fine diretta |
| 2026-07 | Rimosso overlay server (`OverlayRelay`); tabellone bruciato in app; HLS su path camera (non `*_air`); `YoutubeRelay` = copy video + AAC | | 2026-07 | Rimosso overlay server (`OverlayRelay`); tabellone bruciato in app; HLS su path camera (non `*_air`); `YoutubeRelay` = copy video + AAC |
| 2026-08 | `YoutubeRelay` = remux copy video+audio (niente ricodifica AAC); profilo app/slate AAC 48k mono |
+136
View File
@@ -0,0 +1,136 @@
# iOS — allineamento password policy + errori API localizzati
Documento operativo per **Cursor su Mac**: allineare lapp iOS a backend/Android dopo il ramo `feature/password-policy` (merge su `main`).
**Aggiornato:** 2026-08-08
**Android di riferimento (testato su emulatore → API locale):** `2.0.10-native` (`versionCode` **31**)
**iOS oggi:** marketing `2.0.5` / build `26`
**API produzione:** `https://www.matchlivetv.it`
---
## Contesto (già in produzione backend dopo questo rilascio)
### Policy password (server)
Definita in `backend/app/models/concerns/password_complexity.rb`:
- minimo **8** caratteri, massimo **72** byte (bcrypt)
- almeno **3 classi su 4**: minuscole, maiuscole, numeri, simboli
- in **cambio password** e **reset**: la nuova password **non** può coincidere con quella attuale
Validazione ActiveModel su `User` / `AdminAccount`. Endpoint che la applicano:
| Endpoint | Note |
|----------|------|
| `PATCH /api/v1/account/password` | App native + stessi codici errore |
| `PATCH /account/password` (web) | Flash I18n |
| reset password pubblico | Stesse regole |
| registrazione (`auth/register`) | Validazione modello |
### Errori API localizzati
`ApplicationController#set_api_locale` legge, in ordine:
1. header `X-Locale`
2. `Accept-Language`
3. `I18n.default_locale` (fallback; **retrocompatibile** se lapp vecchia non manda nulla)
Risposta invariata: `{ "error": "<messaggio già tradotto>" }` (o `{ "message": "..." }` su alcuni successi).
Chiavi rilevanti (`backend/config/locales/app.{it,en,fr,de,es}.yml`):
- `flash.accounts.password_too_short`
- `flash.accounts.password_too_long`
- `flash.accounts.password_too_weak`
- `flash.accounts.password_same_as_current`
- `flash.accounts.password_current_incorrect`
- `flash.accounts.password_mismatch`
- `flash.accounts.password_updated` / `name_required`
- analoghi in `flash.password_resets.*` e `flash.sessions.*`
---
## Stato iOS vs Android (dopo il merge di questo branch)
| Area | Android 2.0.10 | iOS (repo dopo merge) | Da fare su Mac |
|------|----------------|------------------------|----------------|
| Header `Accept-Language` su tutte le request API | `AppContainer` OkHttp interceptor | `MatchLiveAPI.request` + `multipartPatch` | **Verificare** in debug che parta su login/account/password; se manca su altri helper HTTP, allineare |
| Hint UI nuova password | `account_new_password` in `values*` (5 lingue) | `account.new.password` in `AppLanguage.swift` (già allineato al testo policy) | OK — rivedi solo se cambi copy |
| Schermata Account | Mostra `error` dal body HTTP | `AccountScreen` + `APIError` estrae `error`/`message` | **Test manuale** (vedi sotto) |
| Parsing errori API | Regex su `"error"` | `APIError.friendlyHttpMessage` | OK |
| Client-side pre-check complessità | No (si affida al server) | No | Opzionale: stessa regola di `PasswordComplexity` prima della PATCH |
| Versione App Store | `2.0.10-native` / 31 | `2.0.5` / 26 | **Bump** marketing + build prima del submit TestFlight/App Store |
| Signup in-app | Non è il flusso principale | Nessuna UI register dedicata | N/A (signup resta web) |
### File già toccati / da tenere
```
native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift # Accept-Language
native/ios/MatchLiveTv/Core/AppLanguage.swift # hint account.new.password (5 lingue)
native/ios/MatchLiveTv/UI/Account/AccountScreen.swift
native/ios/MatchLiveTv/Core/UserFacingError.swift
native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift # enum APIError
```
Riferimento Android:
```
native/android/.../data/AppContainer.kt
native/android/.../ui/account/AccountScreen.kt
native/android/app/src/main/res/values*/strings.xml # account_new_password
```
---
## Checklist operativa su Mac
1. **Pull** `main` (dopo merge/push da Linux).
2. Apri `native/ios/MatchLiveTv.xcodeproj`.
3. Build debug con `API_BASE_URL=https://www.matchlivetv.it` (o locale se hai tunnel).
4. **Test Account** (credenziali demo se disponibili, altrimenti account reale di test):
- password debole (`password`) → messaggio *too_weak* nella lingua UI
- password = corrente → *same_as_current*
- conferma diversa → *mismatch*
- password valida nuova → successo; login successivo ok
5. Cambia lingua app (globo) e ripeti un errore: il testo API deve seguire `Accept-Language` / lingua scelta.
6. **Bump versione** allineata ad Android se rilasci store: es. marketing `2.0.10`, build `≥ 31`.
7. (Opzionale) Unit test Swift che replica `PasswordComplexity.strong_enough?` se aggiungi validazione client.
### Comandi utili
```bash
# Clone/pull
git checkout main && git pull
# Release iOS (script repo)
./scripts/build_ios_release.sh
# oppure API staging:
# API_BASE_URL=https://www.matchlivetv.it ./scripts/build_ios_release.sh
```
### Cosa non serve rifare su iOS
- Fix CSS/menu mobile web, tabelle scroll, logo nel drawer: **solo sito**.
- Redirect `GET /account/password``/account`: **solo web**.
- Seed password / concern Rails: già lato server.
---
## Retrocompatibilità (per release iOS)
- App iOS **vecchie** (senza `Accept-Language`): continuano a funzionare; messaggi API nella locale default del server.
- Login con password già esistenti: invariato.
- Solo **impostazione/cambio** password debole viene rifiutato (stesso JSON `error`).
---
## Criterio “fatto”
- [ ] `Accept-Language` presente su login, me, account, change password, forgot (Charles/Proxyman o log)
- [ ] Errori password in it/en/fr/de/es coerenti col backend
- [ ] Hint campo nuova password parla di “min. 8 / 3 tipi”
- [ ] Versione bumpata se lo store release è in scope
- [ ] Nessuna regressione login / lista partite / broadcast
Quando completo, aggiorna questo file con data + versione iOS rilasciata.
+3 -3
View File
@@ -8,7 +8,7 @@ MediaMTX espone **un unico flusso di uscita** per path (`live/match_{uuid}`):
1. **Telefono in onda** → publisher RTMP (camera + **overlay grafico bruciato in app**). 1. **Telefono in onda** → publisher RTMP (camera + **overlay grafico bruciato in app**).
2. **Pausa / rete assente**`alwaysAvailable` con file slate (`/slates/offline.mp4`) **senza interrompere** lHLS verso il sito. 2. **Pausa / rete assente**`alwaysAvailable` con file slate (`/slates/offline.mp4`) **senza interrompere** lHLS verso il sito.
3. **YouTube** (solo se `platform=youtube`) → `Streams::YoutubeRelay` in **Sidekiq**: un processo `ffmpeg` legge RTMP/HLS da MediaMTX e inoltra a YouTube (`-c:v copy`, audio ricodificato in AAC). 3. **YouTube** (solo se `platform=youtube`) → `Streams::YoutubeRelay` in **Sidekiq**: un processo `ffmpeg` legge RTMP/HLS da MediaMTX e inoltra a YouTube (`-c:v copy`, `-c:a copy`; lAAC 48 kHz mono è già prodotto dallapp / slate).
Il telefono **non** invia la copertina di pausa: risparmia banda; lo switch slate ↔ camera è lato MediaMTX. Il telefono **non** invia la copertina di pausa: risparmia banda; lo switch slate ↔ camera è lato MediaMTX.
@@ -51,7 +51,7 @@ MediaMTX path live/match_{uuid}
├──► HLS (segmenti ~1s) ──► edge /hls/ → MediaMTX ──► player web (HLS.js) ├──► HLS (segmenti ~1s) ──► edge /hls/ → MediaMTX ──► player web (HLS.js)
└──► (solo platform=youtube) └──► (solo platform=youtube)
Streams::YoutubeRelay Streams::YoutubeRelay
ffmpeg: -c:v copy, -c:a aac ──► RTMPS YouTube ffmpeg: -c:v copy, -c:a copy ──► RTMPS YouTube
``` ```
| Responsabilità | Dove | | Responsabilità | Dove |
@@ -93,7 +93,7 @@ La pagina live può mostrare badge HTML di stato (in onda / pausa / attesa); non
| Quando | Cosa | Peso CPU | | Quando | Cosa | Peso CPU |
|--------|------|----------| |--------|------|----------|
| Diretta `platform=youtube` | `YoutubeRelay`: demux + **copy video** + AAC audio → RTMPS | Medio-basso **per diretta**, scala con N processi | | Diretta `platform=youtube` | `YoutubeRelay`: demux + **copy video/audio** → RTMPS (remux) | Basso **per diretta**, scala con N processi |
| Diretta solo `matchlivetv` | Nessun ffmpeg in live | Quasi zero | | Diretta solo `matchlivetv` | Nessun ffmpeg in live | Quasi zero |
| Fine diretta | `UploadFromSession`: concat segmenti (`-c copy`) | Picco breve | | Fine diretta | `UploadFromSession`: concat segmenti (`-c copy`) | Picco breve |
| Post-process | `GenerateThumbnail`: un frame → JPEG | Trascurabile | | Post-process | `GenerateThumbnail`: un frame → JPEG | Trascurabile |
+1 -1
View File
@@ -170,7 +170,7 @@ Sullapp nativa loverlay è composito in GPU **prima** dellencode RTMP (
2. `OverlayRenderer``OverlayCanvasRenderer` disegna gli elementi su bitmap trasparente. 2. `OverlayRenderer``OverlayCanvasRenderer` disegna gli elementi su bitmap trasparente.
3. Il bitmap viene applicato come filtro sul flusso RTMP (OpenGL su Android, HaishinKit su iOS). 3. Il bitmap viene applicato come filtro sul flusso RTMP (OpenGL su Android, HaishinKit su iOS).
MediaMTX e `YoutubeRelay` ricevono quindi un flusso già “brandizzato”; il relay YouTube fa solo `-c:v copy` (+ AAC). MediaMTX e `YoutubeRelay` ricevono quindi un flusso già “brandizzato”; il relay YouTube fa solo remux (`-c copy` video e audio).
Elementi grafici (`OverlayCanvasRenderer`): Elementi grafici (`OverlayCanvasRenderer`):
+1 -1
View File
@@ -1,7 +1,7 @@
# YouTube Live — checklist operativa # YouTube Live — checklist operativa
**Aggiornato:** 2026-07-29 **Aggiornato:** 2026-07-29
Pipeline attuale: telefono → MediaMTX → `Streams::YoutubeRelay` (ffmpeg copy+AAC in Sidekiq) → YouTube. Overlay tabellone in **app**, non più `OverlayRelay` server. Pipeline attuale: telefono → MediaMTX → `Streams::YoutubeRelay` (ffmpeg remux copy A/V in Sidekiq) → YouTube. Overlay tabellone in **app**, non più `OverlayRelay` server.
## Verifica rapida su server ## Verifica rapida su server
+1 -1
View File
@@ -7,7 +7,7 @@ Match Live TV supporta due modalità YouTube, legate al piano società:
| **Premium Light** | `matchlivetv_light` | Canale YouTube **Match Live TV** (gestito da te) | | **Premium Light** | `matchlivetv_light` | Canale YouTube **Match Live TV** (gestito da te) |
| **Premium Full** | `team` | Canale YouTube **della società** (OAuth del club) | | **Premium Full** | `team` | Canale YouTube **della società** (OAuth del club) |
Flusso tecnico: telefono (camera + overlay app) → RTMP MediaMTX → `Streams::YoutubeRelay` (ffmpeg `-c:v copy`, AAC) → YouTube Live. Flusso tecnico: telefono (camera + overlay app) → RTMP MediaMTX → `Streams::YoutubeRelay` (ffmpeg `-c:v copy -c:a copy`) → YouTube Live.
--- ---
@@ -29,7 +29,7 @@ Se il telefono andasse diretto a YouTube (o a un ingest cloud senza hold), la li
### Problema di capacità ### Problema di capacità
Ogni diretta `platform=youtube` avvia un processo `ffmpeg` in Sidekiq (`Streams::YoutubeRelay`: video **copy**, audio **AAC**). Ogni diretta `platform=youtube` avvia un processo `ffmpeg` in Sidekiq (`Streams::YoutubeRelay`: video+audio **copy** / remux).
Il carico scala con **N processi**, non con gli spettatori HLS. Il carico scala con **N processi**, non con gli spettatori HLS.
Sul box attuale (~4 CPU / 8 GB, stack completo sullo stesso host): Sul box attuale (~4 CPU / 8 GB, stack completo sullo stesso host):
@@ -299,7 +299,7 @@ Finché D1 non è chiusa, non stimare bandwitdh né SLA del picco.
## 7. Dimensionamento indicativo ## 7. Dimensionamento indicativo
Ipotesi: 720p, video copy, AAC mono**~0.30.7 vCPU** medi per relay (picchi più alti su analyze/reconnect). Ipotesi: 720p, remux copy A/V**~0.150.4 vCPU** medi per relay (picchi su analyze/reconnect).
| Target live YT | Dedicated (relay) | Overflow tipico | Note | | Target live YT | Dedicated (relay) | Overflow tipico | Note |
|----------------|-------------------|-----------------|------| |----------------|-------------------|-----------------|------|
@@ -349,7 +349,7 @@ Questi numeri vanno **calibati** con un load test (N sessioni fake + ffmpeg cont
### Fase 4 — Solo se necessario ### Fase 4 — Solo se necessario
- Secondo MediaMTX / sharding - Secondo MediaMTX / sharding
- Audio copy se lencoder telefono è già YouTube-compatibile (meno CPU) - ~~Audio copy se lencoder telefono è già YouTube-compatibile (meno CPU)~~ → fatto (app AAC 48k mono + relay `-c:a copy`)
- Dedicated più grosso come baseline (spesso più economico delloverflow cronico ogni weekend) - Dedicated più grosso come baseline (spesso più economico delloverflow cronico ogni weekend)
--- ---
+2
View File
@@ -0,0 +1,2 @@
garage.prod.toml
prod-credentials.env
+1
View File
@@ -5,6 +5,7 @@
*.iml *.iml
/captures/ /captures/
/app/build/ /app/build/
/dist/
.DS_Store .DS_Store
keystore.properties keystore.properties
keystore/ keystore/
+128 -3
View File
@@ -1,4 +1,8 @@
import java.io.File
import java.util.Properties import java.util.Properties
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream
plugins { plugins {
id("com.android.application") id("com.android.application")
@@ -20,13 +24,18 @@ android {
applicationId = "com.matchlivetv.match_live_tv" applicationId = "com.matchlivetv.match_live_tv"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 28 versionCode = 31
versionName = "2.0.7-native" versionName = "2.0.10-native"
val apiBaseUrl = project.findProperty("API_BASE_URL") as String? val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
?: "https://www.matchlivetv.it" ?: "https://www.matchlivetv.it"
buildConfigField("String", "API_BASE_URL", "\"$apiBaseUrl\"") buildConfigField("String", "API_BASE_URL", "\"$apiBaseUrl\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Anche in defaultConfig: AGP estrae simboli se le .so non sono già stripped.
ndk {
debugSymbolLevel = "SYMBOL_TABLE"
}
} }
signingConfigs { signingConfigs {
@@ -47,7 +56,8 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro", "proguard-rules.pro",
) )
// Simboli .so per crash/ANR su Play Console (evita l'avviso "native code senza debug symbols") // Le .so AndroidX arrivano già stripped: AGP non genera metadata.
// Il task injectNativeDebugSymbolsInReleaseBundle le incorpora nell'AAB.
ndk { ndk {
debugSymbolLevel = "SYMBOL_TABLE" debugSymbolLevel = "SYMBOL_TABLE"
} }
@@ -112,3 +122,118 @@ dependencies {
androidTestImplementation("androidx.test:core:1.6.1") androidTestImplementation("androidx.test:core:1.6.1")
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0") androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
} }
// Play Console: le .so prebuilt (AndroidX) sono già stripped → AGP salta l'estrazione
// (niente BUNDLE-METADATA/com.android.tools.build.debugsymbols). Generiamo i .so.sym
// come fa AGP (llvm-objcopy --strip-debug) e li iniettiamo nell'intermediary AAB.
fun findLlvmObjcopy(): File {
val ndkRoot = File(android.sdkDirectory, "ndk")
val ndkDirs = ndkRoot.listFiles()?.filter { it.isDirectory }?.sortedByDescending { it.name }.orEmpty()
for (ndk in ndkDirs) {
val candidate = ndk.resolve("toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objcopy")
if (candidate.isFile) return candidate
}
error("llvm-objcopy non trovato sotto ${ndkRoot.absolutePath}")
}
val injectNativeDebugSymbolsInReleaseBundle by tasks.registering {
description = "Embed native-debug-symbols.zip (.so.sym) into the release AAB intermediary"
val mergeNative = tasks.named("mergeReleaseNativeLibs")
val packageBundle = tasks.named("packageReleaseBundle")
dependsOn(mergeNative, packageBundle)
inputs.dir(
layout.buildDirectory.dir(
"intermediates/merged_native_libs/release/mergeReleaseNativeLibs/out/lib",
),
)
inputs.file(
layout.buildDirectory.file(
"intermediates/intermediary_bundle/release/packageReleaseBundle/intermediary-bundle.aab",
),
)
outputs.file(
layout.buildDirectory.file(
"intermediates/intermediary_bundle/release/packageReleaseBundle/intermediary-bundle.aab",
),
)
doLast {
val libsDir = layout.buildDirectory
.dir("intermediates/merged_native_libs/release/mergeReleaseNativeLibs/out/lib")
.get()
.asFile
val aabFile = layout.buildDirectory
.file("intermediates/intermediary_bundle/release/packageReleaseBundle/intermediary-bundle.aab")
.get()
.asFile
require(libsDir.isDirectory) { "Native libs missing: $libsDir" }
require(aabFile.isFile) { "Intermediary AAB missing: $aabFile" }
val objcopy = findLlvmObjcopy()
val workDir = layout.buildDirectory.dir("tmp/native-debug-symbols-inject").get().asFile
workDir.deleteRecursively()
workDir.mkdirs()
val symbolsRoot = workDir.resolve("symbols")
symbolsRoot.mkdirs()
var count = 0
libsDir.walkTopDown().filter { it.isFile && it.extension == "so" }.forEach { so ->
val rel = so.relativeTo(libsDir).invariantSeparatorsPath
val outRel = "$rel.sym"
val outFile = symbolsRoot.resolve(outRel)
outFile.parentFile.mkdirs()
// Allineato ad AGP SYMBOL_TABLE: llvm-objcopy --strip-debug → *.so.sym
val proc = ProcessBuilder(
objcopy.absolutePath,
"--strip-debug",
so.absolutePath,
outFile.absolutePath,
).redirectErrorStream(true).start()
val out = proc.inputStream.bufferedReader().readText()
check(proc.waitFor() == 0) {
"objcopy failed for $rel: $out"
}
count++
}
check(count > 0) { "Nessuna .so da cui generare simboli in $libsDir" }
val symbolsZip = workDir.resolve("native-debug-symbols.zip")
ZipOutputStream(symbolsZip.outputStream().buffered()).use { zos ->
symbolsRoot.walkTopDown().filter { it.isFile }.forEach { f ->
val rel = f.relativeTo(symbolsRoot).invariantSeparatorsPath
zos.putNextEntry(ZipEntry(rel))
f.inputStream().use { it.copyTo(zos) }
zos.closeEntry()
}
}
val metaPath = "BUNDLE-METADATA/com.android.tools.build.debugsymbols/native-debug-symbols.zip"
val outAab = workDir.resolve("intermediary-with-symbols.aab")
ZipFile(aabFile).use { src ->
ZipOutputStream(outAab.outputStream().buffered()).use { dst ->
val entries = src.entries()
while (entries.hasMoreElements()) {
val entry = entries.nextElement()
if (entry.name == metaPath) continue
val newEntry = ZipEntry(entry.name)
newEntry.time = entry.time
// Deflate all entries when rewriting; STORED copy is fragile across ZipFile.
newEntry.method = ZipEntry.DEFLATED
dst.putNextEntry(newEntry)
src.getInputStream(entry).use { it.copyTo(dst) }
dst.closeEntry()
}
dst.putNextEntry(ZipEntry(metaPath))
symbolsZip.inputStream().use { it.copyTo(dst) }
dst.closeEntry()
}
}
outAab.copyTo(aabFile, overwrite = true)
logger.lifecycle(
"Injected $metaPath ($count .so.sym, ${symbolsZip.length()} bytes) into ${aabFile.name} via ${objcopy.name}",
)
}
}
tasks.matching { it.name == "signReleaseBundle" }.configureEach {
dependsOn(injectNativeDebugSymbolsInReleaseBundle)
}
@@ -2,6 +2,7 @@ package com.matchlivetv.match_live_tv.data
import android.content.Context import android.content.Context
import com.matchlivetv.match_live_tv.core.AppConfig import com.matchlivetv.match_live_tv.core.AppConfig
import com.matchlivetv.match_live_tv.core.AppLocale
import com.matchlivetv.match_live_tv.core.TokenStore import com.matchlivetv.match_live_tv.core.TokenStore
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
import com.matchlivetv.match_live_tv.data.cable.SessionCableService import com.matchlivetv.match_live_tv.data.cable.SessionCableService
@@ -37,10 +38,20 @@ class AppContainer(context: Context) {
accessToken?.let { header("Authorization", "Bearer $it") } accessToken?.let { header("Authorization", "Bearer $it") }
header("Accept", "application/json") header("Accept", "application/json")
header("Content-Type", "application/json") header("Content-Type", "application/json")
header("Accept-Language", apiLanguageTag())
}.build() }.build()
chain.proceed(request) chain.proceed(request)
} }
private fun apiLanguageTag(): String {
val tag = AppLocale.currentTag(appContext)
return if (tag.isBlank()) {
java.util.Locale.getDefault().language.ifBlank { "it" }
} else {
tag
}
}
private val okHttp = OkHttpClient.Builder() private val okHttp = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS)
@@ -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 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( data class LoginResponse(
val user: UserDto, val user: UserDto,
@Json(name = "access_token") val accessToken: String, @Json(name = "access_token") val accessToken: String,
@@ -23,6 +23,18 @@ interface MatchLiveApi {
@POST("auth/logout") @POST("auth/logout")
suspend fun 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") @GET("sports")
suspend fun sports(): List<SportDto> 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.StoredSession
import com.matchlivetv.match_live_tv.core.TokenStore 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.LoginRequest
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi 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.RefreshRequest
import com.matchlivetv.match_live_tv.data.api.UpdateAccountRequest
import com.matchlivetv.match_live_tv.domain.User
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
class AuthRepository( class AuthRepository(
@@ -46,6 +50,33 @@ class AuthRepository(
return session 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() { suspend fun logout() {
runCatching { api.logout() } runCatching { api.logout() }
tokenStore.clear() tokenStore.clear()
@@ -1,5 +1,9 @@
package com.matchlivetv.match_live_tv.streaming package com.matchlivetv.match_live_tv.streaming
/**
* Profilo RTMP allineato a MediaMTX slate + relay YouTube (copy audio/video).
* Audio: AAC 48 kHz mono 128 kbps stesso contratto di `infra/scripts/generate_slate.sh`.
*/
data class BroadcastConfig( data class BroadcastConfig(
val rtmpUrl: String, val rtmpUrl: String,
val width: Int = 1280, val width: Int = 1280,
@@ -11,7 +15,13 @@ data class BroadcastConfig(
val portrait: Boolean = false, val portrait: Boolean = false,
val maxReconnectAttempts: Int = 10, val maxReconnectAttempts: Int = 10,
val reconnectDelayMs: Long = 5_000L, val reconnectDelayMs: Long = 5_000L,
) ) {
companion object {
const val AUDIO_SAMPLE_RATE_HZ = 48_000
/** false = mono in RootEncoder `prepareAudio(sampleRate, isStereo, bitrate)`. */
const val AUDIO_STEREO = false
}
}
enum class BroadcastPhase { enum class BroadcastPhase {
IDLE, IDLE,
@@ -316,7 +316,11 @@ class LiveBroadcastEngine(
rotation = cfg.rotation, rotation = cfg.rotation,
profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline, profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline,
level = h264Level, level = h264Level,
) && stream.prepareAudio(48_000, false, cfg.audioBitrate) ) && stream.prepareAudio(
BroadcastConfig.AUDIO_SAMPLE_RATE_HZ,
BroadcastConfig.AUDIO_STEREO,
cfg.audioBitrate,
)
}.getOrElse { }.getOrElse {
Log.w(TAG, "preparePipeline: ${it.message}") Log.w(TAG, "preparePipeline: ${it.message}")
false false
@@ -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 package com.matchlivetv.match_live_tv.ui.login
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth 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.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.R import com.matchlivetv.match_live_tv.R
import com.matchlivetv.match_live_tv.data.AppContainer import com.matchlivetv.match_live_tv.data.AppContainer
@@ -53,6 +55,7 @@ import kotlinx.coroutines.launch
fun LoginScreen( fun LoginScreen(
container: AppContainer, container: AppContainer,
onLoggedIn: () -> Unit, onLoggedIn: () -> Unit,
onForgotPassword: () -> Unit,
) { ) {
var email by remember { mutableStateOf("") } var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
@@ -170,6 +173,16 @@ fun LoginScreen(
keyboardActions = KeyboardActions(onDone = { submitLogin() }), keyboardActions = KeyboardActions(onDone = { submitLogin() }),
colors = matchTextFieldColors(), 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 { error?.let {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text( Text(
@@ -17,9 +17,9 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.DeleteOutline
import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -67,7 +67,7 @@ fun MatchesScreen(
container: AppContainer, container: AppContainer,
onOpenSetup: (matchId: String) -> Unit, onOpenSetup: (matchId: String) -> Unit,
onOpenBroadcast: (sessionId: String) -> Unit, onOpenBroadcast: (sessionId: String) -> Unit,
onLogout: () -> Unit, onOpenAccount: () -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -179,15 +179,10 @@ fun MatchesScreen(
tint = MatchColors.TextSecondary, tint = MatchColors.TextSecondary,
) )
} }
IconButton(onClick = { IconButton(onClick = onOpenAccount) {
scope.launch {
container.authRepository.logout()
onLogout()
}
}) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.Logout, imageVector = Icons.Filled.Person,
contentDescription = stringResource(R.string.action_logout), contentDescription = stringResource(R.string.account_title),
tint = MatchColors.TextSecondary, tint = MatchColors.TextSecondary,
) )
} }
@@ -7,7 +7,9 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.matchlivetv.match_live_tv.data.AppContainer 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.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.login.LoginScreen
import com.matchlivetv.match_live_tv.ui.matches.MatchesScreen import com.matchlivetv.match_live_tv.ui.matches.MatchesScreen
import com.matchlivetv.match_live_tv.ui.splash.SplashScreen import com.matchlivetv.match_live_tv.ui.splash.SplashScreen
@@ -34,11 +36,23 @@ fun AppNavHost(container: AppContainer) {
) )
} }
composable(Routes.Login) { composable(Routes.Login) {
LoginScreen(container) { LoginScreen(
container = container,
onLoggedIn = {
navController.navigate(Routes.Matches) { navController.navigate(Routes.Matches) {
popUpTo(Routes.Login) { inclusive = true } popUpTo(Routes.Login) { inclusive = true }
} }
},
onForgotPassword = {
navController.navigate(Routes.ForgotPassword)
},
)
} }
composable(Routes.ForgotPassword) {
ForgotPasswordScreen(
container = container,
onBack = { navController.popBackStack() },
)
} }
composable(Routes.Matches) { composable(Routes.Matches) {
MatchesScreen( MatchesScreen(
@@ -49,9 +63,18 @@ fun AppNavHost(container: AppContainer) {
onOpenBroadcast = { sessionId -> onOpenBroadcast = { sessionId ->
navController.navigate(Routes.broadcast(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) { 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 { object Routes {
const val Splash = "splash" const val Splash = "splash"
const val Login = "login" const val Login = "login"
const val ForgotPassword = "forgot_password"
const val Matches = "matches" const val Matches = "matches"
const val Account = "account"
const val Setup = "setup/{matchId}/{step}" const val Setup = "setup/{matchId}/{step}"
const val Broadcast = "broadcast/{sessionId}" 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="login_email_placeholder">coach@team.com</string>
<string name="action_delete">Löschen</string> <string name="action_delete">Löschen</string>
<string name="wizard_color_hue">Farbton</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, mindestens 3 Zeichenarten)</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> </resources>
@@ -241,4 +241,21 @@ This cannot be undone.</string>
<string name="login_email_placeholder">coach@team.com</string> <string name="login_email_placeholder">coach@team.com</string>
<string name="action_delete">Delete</string> <string name="action_delete">Delete</string>
<string name="wizard_color_hue">Hue</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, at least 3 character types)</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> </resources>
@@ -241,4 +241,21 @@ Esta acción no se puede deshacer.</string>
<string name="login_email_placeholder">coach@equipo.com</string> <string name="login_email_placeholder">coach@equipo.com</string>
<string name="action_delete">Eliminar</string> <string name="action_delete">Eliminar</string>
<string name="wizard_color_hue">Tono</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, al menos 3 tipos de 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> </resources>
@@ -241,4 +241,21 @@ Cette action est irréversible.</string>
<string name="login_email_placeholder">coach@equipe.com</string> <string name="login_email_placeholder">coach@equipe.com</string>
<string name="action_delete">Supprimer</string> <string name="action_delete">Supprimer</string>
<string name="wizard_color_hue">Teinte</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, au moins 3 types de 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> </resources>
@@ -241,4 +241,21 @@ L\'operazione non si può annullare.</string>
<string name="login_email_placeholder">coach@team.com</string> <string name="login_email_placeholder">coach@team.com</string>
<string name="action_delete">Elimina</string> <string name="action_delete">Elimina</string>
<string name="wizard_color_hue">Tonalità</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, almeno 3 tipi di 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> </resources>
@@ -55,6 +55,8 @@
D7434175C1E849D7A8308896 /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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 */; }; D71CB837FE844932AA590D30 /* MatchScreenScaffold.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7434175C1E849D7A8308896 /* MatchScreenScaffold.swift */; };
52806453AD4C4E59BA2F05DE /* MatchSecondaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE0F41386A741B2A6FC538B /* MatchSecondaryButton.swift */; }; 52806453AD4C4E59BA2F05DE /* MatchSecondaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE0F41386A741B2A6FC538B /* MatchSecondaryButton.swift */; };
FA601701F8684EE2BFDBBBFD /* MatchStatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6A499FE7D94EF498DA9A84 /* MatchStatusBadge.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 */; }; 8C0225C7F87A4F9A8A0C2919 /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA51BC65490E421AA3D16F02 /* LoginScreen.swift */; };
7293CD3C1B634333A59FA782 /* MatchesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A439BB96CCB4C038EEF44DD /* MatchesScreen.swift */; }; 7293CD3C1B634333A59FA782 /* MatchesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A439BB96CCB4C038EEF44DD /* MatchesScreen.swift */; };
465980490F2D417E87241EBA /* AppNavHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DD00428364442C6B8C55DDF /* AppNavHost.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; }; 9246A8532B66443A83535192 /* de.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 85B0F928887C44128434974E; };
5DA941A5928A4F638223030C /* es.lproj in Resources */ = {isa = PBXBuildFile; fileRef = E907C350C3E5410FB4A2038D; }; 5DA941A5928A4F638223030C /* es.lproj in Resources */ = {isa = PBXBuildFile; fileRef = E907C350C3E5410FB4A2038D; };
B6A69164398F4F9FB84EC475 = {isa = PBXGroup; children = (86BA4622CE11450BAD7902B9, E068E1D4709041789B1E8F5A, 2165E0C34A8B43F28AC2B21C); sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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; }; 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; }; 6B4D5383389142E7AE9C4F41 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (C44DD19CDB6545A381B61D20, 7C8A2DFFAB6B4CB8AA3E4FE8, 8D479F8873F94073938D737E, 9A894DBC632C4904BC5D9A78, 9246A8532B66443A83535192, 5DA941A5928A4F638223030C); runOnlyForDeploymentPostprocessing = 0; };
AA7CFC56BCC040A4AB487E2C = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; AA7CFC56BCC040A4AB487E2C = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
@@ -78,6 +78,23 @@ enum L10n {
"action.exit": "Esci", "action.exit": "Esci",
"action.login": "Accedi", "action.login": "Accedi",
"action.logout": "Esci", "action.logout": "Esci",
"account.confirm.password": "Conferma password",
"account.current.password": "Password attuale",
"account.name.label": "Nome",
"account.new.password": "Nuova password (min. 8, almeno 3 tipi di 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.ok": "OK",
"action.save": "Salva", "action.save": "Salva",
"api.error.decoding": "Risposta del server non valida", "api.error.decoding": "Risposta del server non valida",
@@ -352,6 +369,23 @@ enum L10n {
"action.exit": "Exit", "action.exit": "Exit",
"action.login": "Log in", "action.login": "Log in",
"action.logout": "Log out", "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, at least 3 character types)",
"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.ok": "OK",
"action.save": "Save", "action.save": "Save",
"api.error.decoding": "Invalid server response", "api.error.decoding": "Invalid server response",
@@ -626,6 +660,23 @@ enum L10n {
"action.exit": "Quitter", "action.exit": "Quitter",
"action.login": "Connexion", "action.login": "Connexion",
"action.logout": "Dé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, au moins 3 types de 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.ok": "OK",
"action.save": "Enregistrer", "action.save": "Enregistrer",
"api.error.decoding": "Réponse du serveur non valide", "api.error.decoding": "Réponse du serveur non valide",
@@ -900,6 +951,23 @@ enum L10n {
"action.exit": "Beenden", "action.exit": "Beenden",
"action.login": "Anmelden", "action.login": "Anmelden",
"action.logout": "Abmelden", "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, mindestens 3 Zeichenarten)",
"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.ok": "OK",
"action.save": "Speichern", "action.save": "Speichern",
"api.error.decoding": "Ungültige Serverantwort", "api.error.decoding": "Ungültige Serverantwort",
@@ -1174,6 +1242,23 @@ enum L10n {
"action.exit": "Salir", "action.exit": "Salir",
"action.login": "Acceder", "action.login": "Acceder",
"action.logout": "Salir", "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, al menos 3 tipos de 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.ok": "OK",
"action.save": "Guardar", "action.save": "Guardar",
"api.error.decoding": "Respuesta del servidor no válida", "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 { struct LoginResponse: Decodable {
let user: UserDto let user: UserDto
let accessToken: String let accessToken: String
@@ -73,6 +73,33 @@ final class MatchLiveAPI: @unchecked Sendable {
try await postVoid("auth/logout") 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 // MARK: - Sports & Teams
func sports() async throws -> [SportDto] { func sports() async throws -> [SportDto] {
@@ -213,6 +240,7 @@ final class MatchLiveAPI: @unchecked Sendable {
request.httpMethod = "PATCH" request.httpMethod = "PATCH"
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(AppLanguage.resolvedCode, forHTTPHeaderField: "Accept-Language")
if let accessToken { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } if let accessToken { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") }
request.httpBody = MultipartBuilder.build(fields: fields, boundary: boundary) request.httpBody = MultipartBuilder.build(fields: fields, boundary: boundary)
return try await execute(request) return try await execute(request)
@@ -226,6 +254,7 @@ final class MatchLiveAPI: @unchecked Sendable {
var request = URLRequest(url: baseURL.appendingPathComponent(path)) var request = URLRequest(url: baseURL.appendingPathComponent(path))
request.httpMethod = method request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(AppLanguage.resolvedCode, forHTTPHeaderField: "Accept-Language")
if body != nil { if body != nil {
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try ApiInstant.encoder.encode(body) request.httpBody = try ApiInstant.encoder.encode(body)
@@ -267,6 +296,7 @@ final class MatchLiveAPI: @unchecked Sendable {
return path.hasSuffix("/auth/login") return path.hasSuffix("/auth/login")
|| path.hasSuffix("/auth/refresh") || path.hasSuffix("/auth/refresh")
|| path.hasSuffix("/auth/register") || path.hasSuffix("/auth/register")
|| path.hasSuffix("/auth/password/forgot")
} }
} }
@@ -47,6 +47,31 @@ final class AuthRepository {
return session 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 { func logout() async {
try? await api.logout() try? await api.logout()
tokenStore.clear() tokenStore.clear()
@@ -1,5 +1,7 @@
import Foundation import Foundation
/// Profilo RTMP allineato a MediaMTX slate + YoutubeRelay (copy A/V).
/// Audio: AAC 48 kHz mono 128 kbps.
struct BroadcastConfig: Sendable { struct BroadcastConfig: Sendable {
let rtmpUrl: String let rtmpUrl: String
var width: Int = 1280 var width: Int = 1280
@@ -11,6 +13,9 @@ struct BroadcastConfig: Sendable {
var portrait: Bool = false var portrait: Bool = false
var maxReconnectAttempts: Int = 10 var maxReconnectAttempts: Int = 10
var reconnectDelayMs: Int = 5_000 var reconnectDelayMs: Int = 5_000
static let audioSampleRateHz: Float64 = 48_000
static let audioChannels: UInt32 = 1
} }
enum BroadcastPhase: String, Sendable { enum BroadcastPhase: String, Sendable {
@@ -214,6 +214,14 @@ final class LiveBroadcastEngine: ObservableObject {
if let microphone = AVCaptureDevice.default(for: .audio) { if let microphone = AVCaptureDevice.default(for: .audio) {
try await mixer.attachAudio(microphone, track: 0) try await mixer.attachAudio(microphone, track: 0)
} }
// AAC 48 kHz mono allineato ad Android, slate MediaMTX e YoutubeRelay (-c:a copy).
try await mixer.setAudioMixerSettings(
AudioMixerSettings(
sampleRate: BroadcastConfig.audioSampleRateHz,
channels: BroadcastConfig.audioChannels,
tracks: [0: AudioMixerTrackSettings(downmix: true, channelMap: [0])]
)
)
if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) { if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
try await mixer.attachVideo(camera, track: 0) { videoUnit in try await mixer.attachVideo(camera, track: 0) { videoUnit in
videoUnit.isVideoMirrored = false videoUnit.isVideoMirrored = false
@@ -355,10 +363,10 @@ final class LiveBroadcastEngine: ObservableObject {
var audioSettings = await stream.audioSettings var audioSettings = await stream.audioSettings
audioSettings = AudioCodecSettings( audioSettings = AudioCodecSettings(
bitRate: config.audioBitrate, bitRate: config.audioBitrate,
downmix: audioSettings.downmix, downmix: true,
channelMap: audioSettings.channelMap, channelMap: [0],
sampleRate: 48_000, sampleRate: BroadcastConfig.audioSampleRateHz,
format: audioSettings.format format: .aac
) )
try await stream.setAudioSettings(audioSettings) try await stream.setAudioSettings(audioSettings)
} }
@@ -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 { struct LoginScreen: View {
@ObservedObject var container: AppContainer @ObservedObject var container: AppContainer
let onLoggedIn: () -> Void let onLoggedIn: () -> Void
let onForgotPassword: () -> Void
@State private var email = "" @State private var email = ""
@State private var password = "" @State private var password = ""
@@ -37,6 +38,16 @@ struct LoginScreen: View {
MatchSecureField(title: L10n.t("login.password"), text: $password, visible: $passwordVisible) MatchSecureField(title: L10n.t("login.password"), text: $password, visible: $passwordVisible)
} }
.padding(.top, 32) .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 { if let error {
Text(error) Text(error)
.foregroundStyle(MatchColors.primaryRed) .foregroundStyle(MatchColors.primaryRed)
@@ -5,7 +5,7 @@ struct MatchesScreen: View {
var refreshToken: Int = 0 var refreshToken: Int = 0
let onOpenSetup: (String) -> Void let onOpenSetup: (String) -> Void
let onOpenBroadcast: (String) -> Void let onOpenBroadcast: (String) -> Void
let onLogout: () -> Void let onOpenAccount: () -> Void
@State private var loading = true @State private var loading = true
@State private var refreshing = false @State private var refreshing = false
@@ -37,15 +37,12 @@ struct MatchesScreen: View {
} }
.accessibilityLabel(L10n.t("language.label")) .accessibilityLabel(L10n.t("language.label"))
Button { Button {
Task { onOpenAccount()
await container.authRepository.logout()
onLogout()
}
} label: { } label: {
Image(systemName: "rectangle.portrait.and.arrow.right") Image(systemName: "person.crop.circle")
.foregroundStyle(MatchColors.textSecondary) .foregroundStyle(MatchColors.textSecondary)
} }
.accessibilityLabel(L10n.t("action.logout")) .accessibilityLabel(L10n.t("account.title"))
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 8) .padding(.vertical, 8)
@@ -27,7 +27,16 @@ struct AppNavHost: View {
case .splash: case .splash:
EmptyView() EmptyView()
case .login: 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: case .matches:
MatchesScreen( MatchesScreen(
container: container, container: container,
@@ -38,9 +47,15 @@ struct AppNavHost: View {
onOpenBroadcast: { sessionId in onOpenBroadcast: { sessionId in
broadcastRoute = BroadcastRoute(sessionId: sessionId) broadcastRoute = BroadcastRoute(sessionId: sessionId)
}, },
onLogout: { path = [.login] } onOpenAccount: { path.append(.account) }
) )
.lockPortraitOrientation() .lockPortraitOrientation()
case .account:
AccountScreen(
container: container,
onBack: { path.removeLast() },
onLoggedOut: { path = [.login] }
)
case .setup, .broadcast: case .setup, .broadcast:
EmptyView() EmptyView()
} }
@@ -3,7 +3,9 @@ import Foundation
enum Routes: Hashable { enum Routes: Hashable {
case splash case splash
case login case login
case forgotPassword
case matches case matches
case account
case setup(matchId: String, step: Int) case setup(matchId: String, step: Int)
case broadcast(sessionId: String) case broadcast(sessionId: String)
} }
+11 -1
View File
@@ -13,10 +13,17 @@ if ssh "$SERVER" 'command -v rsync >/dev/null 2>&1'; then
--exclude '.git' \ --exclude '.git' \
--exclude 'native/android/build' \ --exclude 'native/android/build' \
--exclude 'native/android/.gradle' \ --exclude 'native/android/.gradle' \
--exclude 'native/android/app/build' \
--exclude 'native/android/dist' \
--exclude 'native/android/keystore' \
--exclude 'native/android/keystore.properties' \
--exclude 'native/android/local.properties' \
--exclude 'backend/log' \ --exclude 'backend/log' \
--exclude 'backend/tmp' \ --exclude 'backend/tmp' \
--exclude 'backend/.bundle' \ --exclude 'backend/.bundle' \
--exclude 'infra/.env' \ --exclude 'infra/.env' \
--exclude 'infra/garage/garage.prod.toml' \
--exclude 'infra/garage/prod-credentials.env' \
--exclude 'log/' \ --exclude 'log/' \
--exclude 'node_modules' \ --exclude 'node_modules' \
"$SRC/" "$SERVER:$DEST/" "$SRC/" "$SERVER:$DEST/"
@@ -24,7 +31,10 @@ else
echo "rsync non disponibile sul server, uso tar+scp..." echo "rsync non disponibile sul server, uso tar+scp..."
tar -C "$SRC" -czf /tmp/matchlivetv-deploy.tar.gz \ tar -C "$SRC" -czf /tmp/matchlivetv-deploy.tar.gz \
--exclude='./native/android/build' --exclude='./native/android/.gradle' \ --exclude='./native/android/build' --exclude='./native/android/.gradle' \
--exclude='./backend/log' --exclude='./backend/tmp' --exclude='./infra/.env' --exclude='./.git' . --exclude='./native/android/app/build' --exclude='./native/android/dist' \
--exclude='./native/android/keystore' --exclude='./native/android/keystore.properties' \
--exclude='./backend/log' --exclude='./backend/tmp' --exclude='./infra/.env' \
--exclude='./infra/garage/garage.prod.toml' --exclude='./.git' .
scp /tmp/matchlivetv-deploy.tar.gz "$SERVER:~/matchlivetv-deploy.tar.gz" scp /tmp/matchlivetv-deploy.tar.gz "$SERVER:~/matchlivetv-deploy.tar.gz"
ssh "$SERVER" "mkdir -p $DEST && tar -xzf ~/matchlivetv-deploy.tar.gz -C $DEST" ssh "$SERVER" "mkdir -p $DEST && tar -xzf ~/matchlivetv-deploy.tar.gz -C $DEST"
fi fi