The phone UI panel. Renders the lock screen, home grid, and apps for calls, messages, contacts, HZ BANK transfers, GPS map, photos, ads, invoices, emergencies and settings, with all actions sent to PhoneSystem and PhoneCamera.
@using Sandbox;
@using Sandbox.UI;
@namespace HorizonRP
@inherits PanelComponent
@*
Le téléphone (flèche ↑), un smartphone réaliste. Chaque joueur a son numéro (tiré de son
SteamID). On le sort verrouillé : l'écran verrouillé montre l'heure et les notifications non
lues (SMS, appels manqués, même reçus hors ligne) ; on le déverrouille en restant appuyé.
Applications : Téléphone (récents, contacts, clavier), Messages, Contacts, HZ BANK (carte,
opérations, virements, numéro de compte, amendes de l'UCPD), Plans (la carte de la ville, un
itinéraire par les rues), Annonces, Signaler, Réglages. Composer le 911 ouvre l'écran
« Urgences » (police ou secours, message, position) au lieu d'un appel vocal. Rien n'est
décidé ici : tout passe par PhoneSystem (et PoliceService pour le 911).
*@
<root>
@if ( !IsOpen && Incoming is not null )
{
<div class="ring-banner">
<div class="rb-avatar">@Initial( PhoneClient.NameOf( Incoming.CallerNumber ) )</div>
<div class="rb-text">
<div class="rb-title">@PhoneClient.NameOf( Incoming.CallerNumber )</div>
<div class="rb-sub">Appel entrant · <span class="kbd">↑</span> pour répondre</div>
</div>
<div class="rb-icon"><i>call</i></div>
</div>
}
@if ( IsOpen && Me is not null )
{
@* Un clic à côté du téléphone le range (comme Échap) ; le clic droit maintenu à côté fait
tourner le regard, pour voir derrière soi sans ranger le téléphone. *@
<div class="outside" onclick=@(() => SetOpen( false )) onmousedown=@((PanelEvent e) => OutsideDown( e )) onmouseup=@(() => looking = false)></div>
<div class="phone" @ref="PhonePanel" onmouseup=@(() => looking = false)>
<div class="side-button power"></div>
<div class="side-button vol-up"></div>
<div class="side-button vol-down"></div>
<div class="screen wall-@Wallpaper">
<div class="status">
<div class="time">@Clock</div>
<div class="island">
@if ( Call is { Active: true } )
{
<div class="island-call"><i>call</i>@Duration( Call )</div>
}
</div>
<div class="icons"><i>signal_cellular_alt</i><span class="net">5G</span><div class="battery"><div class="battery-fill"></div></div></div>
</div>
@if ( Call is { } call )
{
var other = call.OtherNumber( Me.SteamId );
<div class="call">
<div class="call-top">
<div class="call-state">
@if ( call.Active )
{
<span>@Duration( call )</span>
}
else if ( call.Callee == Me.SteamId )
{
<span>appel mobile…</span>
}
else
{
<span>appel en cours…</span>
}
</div>
<div class="call-name">@PhoneClient.NameOf( other )</div>
@if ( PhoneClient.NameOf( other ) != other )
{
<div class="call-number">@other</div>
}
</div>
<div class="call-avatar">@Initial( PhoneClient.NameOf( other ) )</div>
@if ( call.Active )
{
<div class="call-hint"><i>mic</i> Micro ouvert : parlez normalement</div>
}
<div class="call-buttons">
@if ( !call.Active && call.Callee == Me.SteamId )
{
<div class="call-action">
<div class="round red" onclick=@(() => HangUp())><i>call_end</i></div>
<div class="call-label">Refuser</div>
</div>
<div class="call-action">
<div class="round green pulse" onclick=@(() => Answer())><i>call</i></div>
<div class="call-label">Accepter</div>
</div>
}
else
{
<div class="call-action">
<div class="round red" onclick=@(() => HangUp())><i>call_end</i></div>
<div class="call-label">Raccrocher</div>
</div>
}
</div>
</div>
}
else if ( Locked )
{
<div class="lock" @ref="LockPanel" onmousedown=@(() => StartHold()) onmouseup=@(() => StopHold())>
<div class="lock-icon"><i>@(HoldProgress >= 1 ? "lock_open" : "lock")</i></div>
<div class="lock-date">@Today</div>
<div class="lock-clock">@Clock</div>
<div class="notifs">
@foreach ( var n in LockNotifications.Take( 4 ) )
{
<div class="notif">
<div class="notif-icon @n.Style"><i>@n.Icon</i></div>
<div class="notif-main">
<div class="notif-head"><span>@n.App</span><span>@n.At</span></div>
<div class="notif-title">@n.Title</div>
<div class="notif-text">@n.Text</div>
</div>
</div>
}
@if ( LockNotifications.Count > 4 )
{
<div class="notif-more">@($"+ {(LockNotifications.Count - 4)} autres notifications")</div>
}
</div>
<div class="lock-hint">Maintenez appuyé pour déverrouiller</div>
<div class="lock-bottom">
<div class="lock-tool"><i>flashlight_on</i></div>
<div class="lock-tool"><i>photo_camera</i></div>
</div>
@if ( holding )
{
<div class="hold" style="@HoldStyle">
<div class="hold-arc" style="@Arc( HoldProgress, "#ffffff" )"></div>
<div class="hold-core"><i>fingerprint</i></div>
</div>
}
</div>
}
else if ( Screen == "accueil" )
{
<div class="home">
<div class="widgets">
<div class="widget clock-widget">
<div class="w-day">@DayName</div>
<div class="w-date">@DateTime.Now.Day</div>
<div class="w-sub">@Months[DateTime.Now.Month - 1]</div>
</div>
<div class="widget bank-widget" onclick=@(() => Go( "banque" ))>
<div class="w-logo"><span>HZ</span> BANK</div>
<div class="w-label">Solde</div>
<div class="w-amount">@Format.Money( Me.Bank )</div>
<div class="w-sub">@($"Espèces {(Format.Money( Me.Wallet ))}")</div>
</div>
</div>
<div class="apps">
@foreach ( var app in HomeApps )
{
<div class="app-tile" onclick=@(() => Go( app.Screen ))>
<div class="app-icon @app.Style">
@if ( app.Screen == "banque" )
{
<div class="hz-logo">HZ</div>
}
else
{
<i>@app.Icon</i>
}
@if ( Badge( app.Screen ) is var badge and > 0 )
{
<div class="badge">@badge</div>
}
</div>
<div class="app-name">@app.Name</div>
</div>
}
</div>
<div class="page-dots"><div class="pd on"></div><div class="pd"></div></div>
<div class="dock">
@foreach ( var app in Dock )
{
<div class="app-icon @app.Style" onclick=@(() => Go( app.Screen ))>
@if ( app.Screen == "banque" )
{
<div class="hz-logo">HZ</div>
}
else
{
<i>@app.Icon</i>
}
@if ( Badge( app.Screen ) is var badge and > 0 )
{
<div class="badge">@badge</div>
}
</div>
}
</div>
</div>
}
else if ( Screen == "telephone" )
{
<div class="app dark">
@if ( PhoneTab == "recents" )
{
<div class="large-title">Récents</div>
<div class="list">
@if ( !PhoneClient.Calls.Any() )
{
<div class="empty"><i>history</i><div class="empty-text">Aucun appel pour l'instant.</div></div>
}
@foreach ( var c in PhoneClient.Calls.Take( 40 ) )
{
var mine = c.From == Me.PhoneNumber;
var number = mine ? c.To : c.From;
var missed = !mine && !c.Answered;
<div class="row" onclick=@(() => CallNumber( number ))>
<div class="row-icon"><i class="@(missed ? "missed" : "")">@(mine ? "call_made" : missed ? "call_missed" : "call_received")</i></div>
<div class="row-main">
<div class="row-title @(missed ? "missed" : "")">@PhoneClient.NameOf( number )</div>
<div class="row-sub">@CallLabel( c, mine )</div>
</div>
<div class="row-time">@When( c.CreatedAt )</div>
</div>
}
</div>
}
else if ( PhoneTab == "contacts" )
{
<div class="large-title with-action">Contacts<div class="title-action" onclick=@(() => EditContact( null ))><i>add</i></div></div>
<div class="list">
<div class="row" onclick=@(() => OpenEmergency())>
<div class="avatar emergency"><i>emergency</i></div>
<div class="row-main">
<div class="row-title">Urgences</div>
<div class="row-sub">@($"{(PoliceRules.EmergencyNumber)} · police, secours médicaux")</div>
</div>
<div class="mini red"><i>sos</i></div>
</div>
@if ( !PhoneClient.Contacts.Any() )
{
<div class="empty"><i>contacts</i><div class="empty-text">Répertoire vide. Ajoutez un contact avec +.</div></div>
}
@foreach ( var group in ContactGroups )
{
<div class="letter">@group.Key</div>
@foreach ( var c in group )
{
<div class="row">
<div class="avatar">@Initial( c.Name )</div>
<div class="row-main" onclick=@(() => EditContact( c ))>
<div class="row-title">@c.Name</div>
<div class="row-sub">@($"{(c.Number)}{(Online( c.Number ) ? " · en ville" : "")}")</div>
</div>
<div class="mini" onclick=@(() => OpenConversation( c.Number ))><i>sms</i></div>
<div class="mini green" onclick=@(() => CallNumber( c.Number ))><i>call</i></div>
</div>
}
}
</div>
}
else
{
<div class="dialer">
<div class="dial-number">@Dial</div>
@if ( IsEmergency( Dial ) )
{
<div class="dial-known emergency">Urgences · police, secours</div>
}
else if ( DialName is { } known )
{
<div class="dial-known">@known</div>
}
else if ( HorizonRP.Phone.Normalize( Dial ) is { } full )
{
<div class="dial-add" onclick=@(() => EditContact( new PhoneContact { Number = full } ))>Ajouter le numéro</div>
}
else
{
<div class="dial-known"></div>
}
<div class="keypad">
@foreach ( var key in Keys )
{
<div class="key" onclick=@(() => Press( key.Digit ))>
<div class="key-digit">@key.Digit</div>
<div class="key-letters">@key.Letters</div>
</div>
}
</div>
<div class="dial-actions">
<div class="dial-side" onclick=@(() => OpenConversation( Dial ))>
@if ( Dial.Length > 0 )
{
<i>sms</i>
}
</div>
<div class="round green" onclick=@(() => CallNumber( Dial ))><i>call</i></div>
<div class="dial-side" onclick=@(() => Erase())>
@if ( Dial.Length > 0 )
{
<i>backspace</i>
}
</div>
</div>
</div>
}
<div class="tabbar">
<div class="tab @(PhoneTab == "recents" ? "on" : "")" onclick=@(() => SetPhoneTab( "recents" ))>
<i>schedule</i><span>Récents</span>
@if ( Missed.Count > 0 )
{
<div class="badge small">@Missed.Count</div>
}
</div>
<div class="tab @(PhoneTab == "contacts" ? "on" : "")" onclick=@(() => SetPhoneTab( "contacts" ))><i>account_circle</i><span>Contacts</span></div>
<div class="tab @(PhoneTab == "clavier" ? "on" : "")" onclick=@(() => SetPhoneTab( "clavier" ))><i>dialpad</i><span>Clavier</span></div>
</div>
</div>
}
else if ( Screen == "messages" )
{
<div class="app dark">
<div class="large-title with-action">Messages<div class="title-action" onclick=@(() => Go( "nouveau" ))><i>edit</i></div></div>
<div class="list">
@if ( !PhoneClient.Conversations( Me.PhoneNumber ).Any() )
{
<div class="empty"><i>chat_bubble_outline</i><div class="empty-text">Aucun message. Écrivez à un contact.</div></div>
}
@foreach ( var (number, last) in PhoneClient.Conversations( Me.PhoneNumber ) )
{
<div class="row" onclick=@(() => OpenConversation( number ))>
<div class="unread-dot @(PhoneClient.Unread.Contains( number ) ? "on" : "")"></div>
<div class="avatar">@Initial( PhoneClient.NameOf( number ) )</div>
<div class="row-main">
<div class="row-head">
<div class="row-title">@PhoneClient.NameOf( number )</div>
<div class="row-time">@When( last.CreatedAt )</div>
</div>
<div class="row-sub two">@($"{(last.From == Me.PhoneNumber ? "Vous : " : "")}{(PhoneClient.Preview( last.Text ))}")</div>
</div>
</div>
}
</div>
</div>
}
else if ( Screen == "nouveau" )
{
<div class="app dark">
<div class="nav-bar">
<div class="nav-back" onclick=@(() => Go( "messages" ))><i>chevron_left</i>Messages</div>
<div class="nav-title">Nouveau message</div>
<div class="nav-right"></div>
</div>
<div class="form">
<div class="field-label">À : numéro</div>
<TextEntry class="input" Value=@NewPeer Placeholder="123-4567" OnTextEdited=@((string v) => { NewPeer = v; })></TextEntry>
<div class="btn primary" onclick=@(() => OpenConversation( NewPeer ))><i>chat</i> Écrire</div>
</div>
<div class="section-label">Contacts</div>
<div class="list">
@foreach ( var c in PhoneClient.Contacts )
{
<div class="row" onclick=@(() => OpenConversation( c.Number ))>
<div class="avatar">@Initial( c.Name )</div>
<div class="row-main">
<div class="row-title">@c.Name</div>
<div class="row-sub">@c.Number</div>
</div>
</div>
}
</div>
</div>
}
else if ( Screen == "conversation" )
{
<div class="app dark">
<div class="nav-bar thread">
<div class="nav-back" onclick=@(() => Go( "messages" ))><i>chevron_left</i></div>
<div class="nav-peer">
<div class="avatar small">@Initial( PhoneClient.NameOf( Peer ) )</div>
<div class="nav-title">@PhoneClient.NameOf( Peer )</div>
</div>
<div class="nav-right">
<div class="nav-icon" onclick=@(() => CallNumber( Peer ))><i>call</i></div>
<div class="nav-icon red" onclick=@(() => AskDeleteThread())><i>delete</i></div>
@if ( !PhoneClient.Contacts.Any( x => x.Number == Peer ) )
{
<div class="nav-icon" onclick=@(() => EditContact( new PhoneContact { Number = Peer } ))><i>person_add</i></div>
}
</div>
</div>
@if ( ConfirmDeleteThread )
{
<div class="confirm-bar">
<span>Supprimer toute la conversation ?</span>
<div class="pill" onclick=@(() => ConfirmDeleteThread = false)>Annuler</div>
<div class="pill danger" onclick=@(() => DeleteThread())>Supprimer</div>
</div>
}
<div class="bubbles">
@if ( !Thread.Any() )
{
<div class="thread-start">@($"Début de la conversation avec {(PhoneClient.NameOf( Peer ))}")</div>
}
@foreach ( var m in Thread )
{
@if ( HorizonRP.Phone.IsPhoto( m.Text, out var pid, out var pkey ) )
{
var mineShot = m.From == Me.PhoneNumber;
<div class="bubble photo @(mineShot ? "mine" : "theirs")" onclick=@(() => OpenPhoto( pid, pkey, m.CreatedAt, false )) onrightclick=@(() => SelectMessage( m.Id ))>
@if ( PhoneClient.PhotoTexture( pid, pkey ) is { } shot )
{
<Image class="bubble-image" Texture=@shot></Image>
}
else
{
<div class="bubble-loading"><i>image</i></div>
}
</div>
}
else
{
<div class="bubble @(m.From == Me.PhoneNumber ? "mine" : "theirs") @(SelectedMessage == m.Id ? "selected" : "")" onclick=@(() => SelectMessage( m.Id )) onrightclick=@(() => SelectMessage( m.Id ))>
<div class="bubble-text">@m.Text</div>
<div class="bubble-time">@ShortTime( m.CreatedAt )</div>
</div>
}
@if ( SelectedMessage == m.Id )
{
<div class="bubble-actions @(m.From == Me.PhoneNumber ? "mine" : "theirs")">
<div class="pill danger" onclick=@(() => DeleteMessage( m.Id ))><i>delete</i> Supprimer</div>
</div>
}
}
</div>
<div class="compose">
<div class="compose-photo" onclick=@(() => PickPhotoFor( Peer ))><i>photo_camera</i></div>
<TextEntry class="compose-input" Value=@Draft Placeholder="Message" OnTextEdited=@((string v) => { Draft = v; })></TextEntry>
<div class="send @(string.IsNullOrWhiteSpace( Draft ) ? "" : "on")" onclick=@(() => SendDraft())><i>arrow_upward</i></div>
</div>
</div>
}
else if ( Screen == "contacts" )
{
<div class="app dark">
<div class="large-title with-action">Contacts<div class="title-action" onclick=@(() => EditContact( null ))><i>add</i></div></div>
<div class="my-card">
<div class="avatar big">@Initial( Me.RpName )</div>
<div class="row-main">
<div class="row-title">@Me.RpName</div>
<div class="row-sub">@($"Ma fiche · {(Me.PhoneNumber)}")</div>
</div>
</div>
<div class="list">
@if ( !PhoneClient.Contacts.Any() )
{
<div class="empty"><i>contacts</i><div class="empty-text">Répertoire vide. Ajoutez un contact avec +.</div></div>
}
@foreach ( var group in ContactGroups )
{
<div class="letter">@group.Key</div>
@foreach ( var c in group )
{
<div class="row">
<div class="avatar">@Initial( c.Name )</div>
<div class="row-main" onclick=@(() => EditContact( c ))>
<div class="row-title">@c.Name</div>
<div class="row-sub">@($"{(c.Number)}{(Online( c.Number ) ? " · en ville" : "")}")</div>
</div>
<div class="mini" onclick=@(() => OpenConversation( c.Number ))><i>sms</i></div>
<div class="mini green" onclick=@(() => CallNumber( c.Number ))><i>call</i></div>
</div>
}
}
</div>
</div>
}
else if ( Screen == "contact" )
{
<div class="app dark">
<div class="nav-bar">
<div class="nav-back" onclick=@(() => Back())><i>chevron_left</i>Retour</div>
<div class="nav-title">@(ContactId > 0 ? "Modifier" : "Nouveau contact")</div>
<div class="nav-right"></div>
</div>
<div class="contact-head">
<div class="avatar huge">@Initial( string.IsNullOrWhiteSpace( ContactName ) ? "?" : ContactName )</div>
</div>
<div class="form">
<div class="field-label">Nom</div>
<TextEntry class="input" Value=@ContactName Placeholder="Prénom Nom" OnTextEdited=@((string v) => { ContactName = v; })></TextEntry>
<div class="field-label">Numéro</div>
<TextEntry class="input" Value=@ContactNumber Placeholder="123-4567" OnTextEdited=@((string v) => { ContactNumber = v; })></TextEntry>
<div class="btn primary" onclick=@(() => SaveContact())><i>check</i> Enregistrer</div>
@if ( ContactId > 0 )
{
<div class="btn-row">
<div class="btn" onclick=@(() => CallNumber( ContactNumber ))><i>call</i> Appeler</div>
<div class="btn" onclick=@(() => OpenConversation( ContactNumber ))><i>sms</i> Message</div>
</div>
<div class="btn danger" onclick=@(() => DeleteContact())><i>delete</i> Supprimer le contact</div>
}
</div>
</div>
}
else if ( Screen == "banque" )
{
<div class="app bank">
<div class="bank-head">
<div class="bank-logo"><div class="hz-mark">HZ</div><span>BANK</span></div>
<div class="bank-hello">@($"Bonjour, {(FirstName)}")</div>
</div>
<div class="bank-scroll">
@* Les comptes du joueur (Money/Atm.cs) : le compte courant seul pour l'instant ; les
comptes d'entreprise s'ajouteront à Atm.AccountsOf, choisis ici comme au distributeur. *@
<div class="bank-accounts">
@foreach ( var acc in HorizonRP.Atm.AccountsOf( Me ) )
{
<div class="bank-acc @(acc.Id == BankAccountId ? "on" : "")" onclick=@(() => BankAccountId = acc.Id)>
<i>@(acc.Kind == "entreprise" ? "business" : "account_balance_wallet")</i>
<div class="bank-acc-text"><span>@acc.Label</span><small>@HorizonRP.Atm.Masked( acc.Number )</small></div>
</div>
}
</div>
<div class="card">
<div class="card-top">
<div class="card-brand"><b>HZ</b> BANK</div>
<i>contactless</i>
</div>
<div class="card-chip"><div class="chip-line"></div><div class="chip-line"></div></div>
<div class="card-number">@CardNumber</div>
<div class="card-bottom">
<div class="card-holder">@((Me.RpName ?? "").ToUpperInvariant())</div>
<div class="card-type">DÉBIT</div>
</div>
</div>
<div class="balance">
<div class="balance-label">Solde disponible</div>
<div class="balance-amount">@Format.Money( HorizonRP.Atm.BalanceOf( Me, BankAccountId ) )</div>
<div class="balance-sub">@($"Espèces sur vous : {(Format.Money( Me.Wallet ))}")</div>
</div>
<div class="bank-actions">
<div class="bank-action" onclick=@(() => StartTransfer())><div class="ba-icon"><i>send</i></div><span>Virement</span></div>
<div class="bank-action" onclick=@(() => Go( "banque_compte" ))><div class="ba-icon"><i>qr_code_2</i></div><span>Recevoir</span></div>
<div class="bank-action" onclick=@(() => RefreshBank())><div class="ba-icon"><i>refresh</i></div><span>Actualiser</span></div>
</div>
<div class="section-label bank">Opérations récentes</div>
<div class="ops">
@if ( !PhoneClient.BankLoaded )
{
<div class="empty light"><i>hourglass_empty</i><div class="empty-text">Chargement des opérations…</div></div>
}
else if ( !PhoneClient.BankOps.Any() )
{
<div class="empty light"><i>receipt_long</i><div class="empty-text">Aucune opération pour l'instant.</div></div>
}
@foreach ( var group in PhoneClient.BankOps.Take( 40 ).GroupBy( x => DayOf( x.CreatedAt ) ) )
{
<div class="op-day">@group.Key</div>
@foreach ( var op in group )
{
<div class="op">
<div class="op-icon @op.Kind"><i>@OpIcon( op.Kind )</i></div>
<div class="op-main">
<div class="op-label">@op.Label</div>
<div class="op-sub">@OpSub( op )</div>
</div>
<div class="op-amount @(op.Amount > 0 ? "in" : "")">@SignedMoney( op.Amount )</div>
</div>
}
}
</div>
</div>
</div>
}
else if ( Screen == "banque_virement" )
{
<div class="app bank light-body">
<div class="bank-nav">
<div class="nav-back" onclick=@(() => Go( "banque" ))><i>chevron_left</i></div>
<div class="nav-title">Nouveau virement</div>
<div class="nav-right"></div>
</div>
<div class="bank-scroll form-bank">
<div class="field-label">Bénéficiaire</div>
<TextEntry class="input bank-input" Value=@TransferTo Placeholder="HZ12 3456 7890 ou 123-4567" OnTextEdited=@((string v) => { TransferTo = v; })></TextEntry>
<div class="field-hint @(RecipientName is null ? "" : "ok")">@RecipientHint</div>
@if ( PhoneClient.Contacts.Any() )
{
<div class="payees">
@foreach ( var c in PhoneClient.Contacts.Take( 8 ) )
{
<div class="payee @(HorizonRP.Phone.Normalize( TransferTo ) == c.Number ? "on" : "")" onclick=@(() => PickPayee( c.Number ))>
<div class="avatar small red">@Initial( c.Name )</div>
<span>@c.Name</span>
</div>
}
</div>
}
<div class="field-label">Montant</div>
<div class="amount-box">
<span class="currency">$</span>
<TextEntry class="amount-input" Value=@TransferAmountText Placeholder="0" OnTextEdited=@((string v) => { TransferAmountText = v; })></TextEntry>
</div>
<div class="chips">
@foreach ( var a in Amounts )
{
<div class="chip @(TransferAmount == a ? "on" : "")" onclick=@(() => PickAmount( a ))>@Format.Money( a )</div>
}
</div>
<div class="field-label">Motif (facultatif)</div>
<TextEntry class="input bank-input" Value=@TransferNote Placeholder="Loyer, remboursement…" OnTextEdited=@((string v) => { TransferNote = v; })></TextEntry>
<div class="field-hint">@($"Solde après virement : {(Format.Money( Math.Max( 0, Me.Bank - TransferAmount ) ))}")</div>
<div class="btn bank-send @(CanTransfer ? "" : "off")" onclick=@(() => SendTransfer())>
@if ( TransferPending )
{
<span>Envoi en cours…</span>
}
else
{
<i>send</i><span>@($"Envoyer {(Format.Money( TransferAmount ))}")</span>
}
</div>
</div>
</div>
}
else if ( Screen == "banque_ok" )
{
<div class="app bank">
<div class="done">
<div class="done-circle"><i>check</i></div>
<div class="done-title">Virement envoyé</div>
<div class="done-amount">@Format.Money( LastTransferAmount )</div>
<div class="done-sub">@($"à {(LastTransferName)}")</div>
<div class="btn white" onclick=@(() => Go( "banque" ))>Terminer</div>
</div>
</div>
}
else if ( Screen == "banque_compte" )
{
<div class="app bank light-body">
<div class="bank-nav">
<div class="nav-back" onclick=@(() => Go( "banque" ))><i>chevron_left</i></div>
<div class="nav-title">Mon compte</div>
<div class="nav-right"></div>
</div>
<div class="bank-scroll form-bank">
<div class="rib">
<div class="rib-title">Relevé d'identité bancaire</div>
<div class="field-label">Titulaire</div>
<div class="rib-value">@Me.RpName</div>
<div class="field-label">Numéro de compte</div>
<div class="rib-account">@Account</div>
<div class="field-label">Banque</div>
<div class="rib-value">HZ BANK · Agence centrale</div>
</div>
<div class="field-hint">Donnez ce numéro pour recevoir un virement, même quand vous n'êtes pas en ville. Votre numéro de téléphone marche aussi quand vous êtes connecté.</div>
<div class="section-label">Envoyer mon numéro par SMS</div>
<div class="list light">
@foreach ( var c in PhoneClient.Contacts )
{
<div class="row" onclick=@(() => ShareAccount( c.Number ))>
<div class="avatar small red">@Initial( c.Name )</div>
<div class="row-main">
<div class="row-title">@c.Name</div>
<div class="row-sub">@c.Number</div>
</div>
<i class="row-go">sms</i>
</div>
}
@if ( !PhoneClient.Contacts.Any() )
{
<div class="empty light"><i>contacts</i><div class="empty-text">Aucun contact à qui l'envoyer.</div></div>
}
</div>
</div>
</div>
}
else if ( Screen == "gps" )
{
<div class="app gps" onmouseup=@(() => EndMapDrag())>
<div class="map" @ref="MapPanel" onmousedown=@(() => StartMapDrag())>
<div class="map-image" style="@MapImageStyle">
@foreach ( var seg in RouteSegments )
{
<div class="route-seg" style="@seg"></div>
}
@foreach ( var dot in RouteJoints )
{
<div class="route-joint" style="@dot"></div>
}
@* Sur la carte, rien que les deux lieux fixes et les voitures du joueur (pas celles des autres). *@
@foreach ( var place in MapPins )
{
<div class="map-pin @place.Style" style="@PinStyle( place.Position, 30 )"><i>@place.Icon</i></div>
}
@if ( Gps.Target is { } target )
{
<div class="map-target" style="@PinStyle( target, 36 )"><i>location_on</i></div>
}
<div class="map-me" style="@MeStyle">
<div class="me-halo"></div>
<div class="me-dot"><i>navigation</i></div>
</div>
</div>
</div>
<div class="gps-top">
@if ( Gps.Target is not null )
{
<div class="nav-card">
<div class="nav-arrow" style="@ArrowStyle"><i>navigation</i></div>
<div class="nav-main">
<div class="nav-dist">@Gps.FormatDistance( Gps.RemainingMeters )</div>
<div class="nav-sub">@($"{(Gps.TargetName ?? "Destination")} · {(Eta)}")</div>
</div>
<div class="nav-close" onclick=@(() => Gps.Clear())><i>close</i></div>
</div>
}
else
{
<div class="search-card"><i>search</i>Touchez la carte pour y aller</div>
}
</div>
<div class="map-buttons">
<div class="map-btn" onclick=@(() => ZoomBy( 1 ))><i>add</i></div>
<div class="map-btn" onclick=@(() => ZoomBy( -1 ))><i>remove</i></div>
<div class="map-btn @(Follow ? "on" : "")" onclick=@(() => Recenter())><i>my_location</i></div>
</div>
<div class="places">
@foreach ( var place in MapPlaces.Take( 6 ) )
{
<div class="place" onclick=@(() => GoTo( place ))>
<i>@place.Icon</i>
<div class="place-text">
<div class="place-name">@place.Name</div>
<div class="place-dist">@Gps.FormatDistance( place.Position.WithZ( 0 ).Distance( Me.WorldPosition.WithZ( 0 ) ) * 0.0254f )</div>
</div>
</div>
}
</div>
</div>
}
else if ( Screen == "camera" )
{
<div class="app camera-app" onmouseup=@(() => EndAim())>
<div class="cam-top">
<i>flash_off</i>
<span class="cam-saving">@(PhoneCamera.Sending > 0 ? "Enregistrement…" : "")</span>
<i>hdr_auto</i>
</div>
<div class="viewfinder">
@if ( PhoneCamera.View is { } view )
{
<Image class="viewfinder-image" Texture=@view></Image>
}
<div class="grid-v one"></div><div class="grid-v two"></div>
<div class="grid-h one"></div><div class="grid-h two"></div>
<div class="cam-zoom">@($"{(PhoneCamera.Zoom.ToString( "0.#", System.Globalization.CultureInfo.InvariantCulture ))}×")</div>
<div class="cam-hint">@(PhoneCamera.Front ? "Glissez pour tourner le téléphone · molette : zoom" : "Glissez pour viser · molette : zoom")</div>
@if ( sinceShot < 0.2f )
{
<div class="shot-flash"></div>
}
@* Par-dessus tout le viseur : la molette (zoom) et l'appui (viser). *@
<WheelPanel class="wheel-catch" Wheel=@((float y) => CameraWheel( y )) onmousedown=@(() => StartAim())></WheelPanel>
</div>
<div class="cam-modes"><span>VIDÉO</span><span class="on">PHOTO</span><span>PORTRAIT</span></div>
<div class="cam-bottom">
<div class="cam-thumb" onclick=@(() => Go( "photos" ))>
@if ( PhoneCamera.LastShot is { } shot )
{
<Image class="thumb-image" Texture=@shot></Image>
}
</div>
<div class="shutter" onclick=@(() => Shoot())><div class="shutter-core"></div></div>
<div class="cam-flip" onclick=@(() => FlipCamera())><i>flip_camera_ios</i></div>
</div>
</div>
}
else if ( Screen == "photos" )
{
<div class="app dark">
<div class="large-title with-action">@(PickFor is null ? "Photos" : "Choisir")<div class="title-action" onclick=@(() => Go( "camera" ))><i>photo_camera</i></div></div>
@if ( PickFor is not null )
{
<div class="pick-hint">@($"Touchez une photo pour l'envoyer à {(PhoneClient.NameOf( PickFor ))}")</div>
}
<div class="gallery">
@for ( var k = 0; k < PhoneCamera.Sending; k++ )
{
<div class="tile sending"><i>cloud_upload</i></div>
}
@foreach ( var p in PhoneClient.Photos.Take( 45 ) )
{
<div class="tile" onclick=@(() => OpenPhoto( p.Id, p.Key, p.CreatedAt, true ))>
@if ( PhoneClient.PhotoTexture( p.Id, p.Key ) is { } tex )
{
<Image class="tile-image" Texture=@tex></Image>
}
else
{
<i>image</i>
}
</div>
}
</div>
@if ( !PhoneClient.PhotosLoaded )
{
<div class="empty"><i>hourglass_empty</i><div class="empty-text">Chargement de la galerie…</div></div>
}
else if ( !PhoneClient.Photos.Any() && PhoneCamera.Sending == 0 )
{
<div class="empty"><i>photo_library</i><div class="empty-text">Aucune photo. Prenez-en une avec l'appareil photo.</div></div>
}
else
{
<div class="gallery-count">@($"{(PhoneClient.Photos.Count)} photo{(PhoneClient.Photos.Count > 1 ? "s" : "")}")</div>
}
</div>
}
else if ( Screen == "photo" )
{
<div class="app viewer">
<div class="nav-bar">
<div class="nav-back" onclick=@(() => Back())><i>chevron_left</i>Retour</div>
<div class="nav-title">@PhotoDate</div>
<div class="nav-right"></div>
</div>
<div class="viewer-body" @ref="ViewerPanel">
@if ( PhoneClient.PhotoTexture( ViewId, ViewKey ) is { } big )
{
<Image class="viewer-image" style="@ViewerStyle" Texture=@big></Image>
}
else
{
<div class="empty"><i>hourglass_empty</i><div class="empty-text">Chargement…</div></div>
}
<WheelPanel class="wheel-catch" Wheel=@((float y) => ViewerWheel( y )) onmousedown=@(() => StartPan()) onmouseup=@(() => panning = false)></WheelPanel>
</div>
<div class="viewer-actions">
<div class="va" onclick=@(() => SharePhoto())><i>ios_share</i><span>Envoyer</span></div>
@if ( ViewMine )
{
<div class="va red" onclick=@(() => DeletePhoto())><i>delete</i><span>Supprimer</span></div>
}
</div>
</div>
}
else if ( Screen == "photo_envoi" )
{
<div class="app dark">
<div class="nav-bar">
<div class="nav-back" onclick=@(() => Back())><i>chevron_left</i>Photo</div>
<div class="nav-title">Envoyer la photo</div>
<div class="nav-right"></div>
</div>
<div class="form">
<div class="field-label">À : numéro</div>
<TextEntry class="input" Value=@NewPeer Placeholder="123-4567" OnTextEdited=@((string v) => { NewPeer = v; })></TextEntry>
<div class="btn primary" onclick=@(() => SendPhoto( NewPeer, ViewId, ViewKey ))><i>send</i> Envoyer par SMS</div>
</div>
<div class="section-label">Contacts</div>
<div class="list">
@foreach ( var c in PhoneClient.Contacts )
{
<div class="row" onclick=@(() => SendPhoto( c.Number, ViewId, ViewKey ))>
<div class="avatar">@Initial( c.Name )</div>
<div class="row-main">
<div class="row-title">@c.Name</div>
<div class="row-sub">@c.Number</div>
</div>
<i class="row-go blue">send</i>
</div>
}
</div>
</div>
}
else if ( Screen == "annonces" )
{
<div class="app dark">
<div class="large-title">Annonces</div>
<div class="form">
<TextEntry class="input" Value=@AdDraft Placeholder="Vends, cherche, recrute…" OnTextEdited=@((string v) => { AdDraft = v; })></TextEntry>
<div class="btn orange" onclick=@(() => PostAd())><i>campaign</i> @($"Publier ({(Format.Money( PhoneSystem.AdPrice ))})")</div>
</div>
<div class="list">
@if ( !PhoneClient.Ads.Any() )
{
<div class="empty"><i>campaign</i><div class="empty-text">Aucune annonce pour l'instant.</div></div>
}
@foreach ( var ad in PhoneClient.Ads )
{
<div class="ad">
<div class="ad-head"><b>@ad.Name</b><span>@ad.At</span></div>
<div class="ad-text">@ad.Text</div>
<div class="ad-actions">
<div class="mini" onclick=@(() => OpenConversation( ad.Number ))><i>sms</i></div>
<div class="mini green" onclick=@(() => CallNumber( ad.Number ))><i>call</i></div>
</div>
</div>
}
</div>
</div>
}
else if ( Screen == "urgences" )
{
<div class="app dark">
<div class="nav-bar">
<div class="nav-back" onclick=@(() => Go( "telephone" ))><i>chevron_left</i>Téléphone</div>
<div class="nav-title">@($"Urgences · {(PoliceRules.EmergencyNumber)}")</div>
<div class="nav-right"></div>
</div>
<div class="list">
<div class="form sos-form">
<div class="sos-head">
<div class="sos-badge"><i>emergency</i></div>
<div class="sos-text">Le 911 prévient tout de suite les unités en service. N'appelez qu'en cas de vrai besoin.</div>
</div>
<div class="field-label">Service demandé</div>
<div class="services">
<div class="service @(EmergencyService == "police" ? "on police" : "")" onclick=@(() => PickEmergency( "police" ))><i>local_police</i><span>Police</span></div>
<div class="service @(EmergencyService == "ems" ? "on ems" : "")" onclick=@(() => PickEmergency( "ems" ))><i>medical_services</i><span>Secours médicaux</span></div>
</div>
<div class="field-label">Que se passe-t-il ?</div>
<TextEntry class="input tall" Multiline=@true Value=@EmergencyDraft Placeholder=@(EmergencyService == "ems" ? "Un blessé, un malaise, un accident…" : "Un vol, une agression, des coups de feu…") OnTextEdited=@((string v) => { EmergencyDraft = v; })></TextEntry>
<div class="sos-toggle" onclick=@(() => { Tap(); EmergencyShare = !EmergencyShare; })>
<i>my_location</i>
<div class="row-main">
<div class="row-title">Envoyer ma position</div>
<div class="row-sub">@(EmergencyShare ? "Les unités reçoivent l'endroit où vous êtes." : "Sans position, décrivez bien le lieu.")</div>
</div>
<div class="switch @(EmergencyShare ? "on" : "")"><div class="knob"></div></div>
</div>
<div class="btn sos @(CanSendEmergency ? "" : "off")" onclick=@(() => SendEmergency())>
@if ( EmergencyPending )
{
<span>Appel en cours…</span>
}
else
{
<i>sos</i><span>@($"Appeler le {(PoliceRules.EmergencyNumber)}")</span>
}
</div>
</div>
@if ( Me.IsEms )
{
<div class="section-label">Appels de secours en cours</div>
@if ( !EmsCalls.Any() )
{
<div class="empty"><i>medical_services</i><div class="empty-text">Aucun appel de secours.</div></div>
}
@foreach ( var sos in EmsCalls )
{
var mine = sos.TakenBySteam.Contains( Me.SteamId );
<div class="sos-call">
<div class="row-main">
<div class="row-head">
<div class="row-title">@($"n°{(sos.Id)} · {(sos.CallerName)}")</div>
<div class="row-time">@sos.At</div>
</div>
<div class="row-sub two">@sos.Message</div>
<div class="row-sub">@EmsLine( sos )</div>
</div>
<div class="sos-actions">
@if ( !mine )
{
<div class="mini green" onclick=@(() => TakeEmsCall( sos ))><i>directions_run</i></div>
}
else if ( sos.HasPosition )
{
<div class="mini" onclick=@(() => RouteTo( sos ))><i>navigation</i></div>
}
<div class="mini red" onclick=@(() => CloseEmsCall( sos ))><i>@(ConfirmEmsClose == sos.Id ? "done_all" : "task_alt")</i></div>
</div>
</div>
}
}
</div>
</div>
}
else if ( Screen == "urgences_ok" )
{
<div class="app">
<div class="done sos-done">
<div class="done-circle"><i>check</i></div>
<div class="done-title">Appel transmis</div>
<div class="done-amount">@($"911 · n°{(PhoneClient.EmergencyId)}")</div>
<div class="done-sub">@EmergencyStatus</div>
<div class="btn white" onclick=@(() => Go( "accueil" ))>Terminer</div>
</div>
</div>
}
else if ( Screen == "factures" )
{
<div class="app dark factures">
<div class="large-title">Factures</div>
<div class="fx-scroll">
<div class="fx-summary">
<div class="fx-sum-top">
<div class="fx-sum-label">Reste à payer</div>
<i>receipt_long</i>
</div>
<div class="fx-sum-amount">@Format.Money( PhoneClient.UnpaidAll )</div>
<div class="fx-sum-sub">@SummaryText</div>
@if ( PhoneClient.UnpaidAll > Me.Bank )
{
<div class="fx-sum-warn"><i>error_outline</i><span>@ShortBalanceText</span></div>
}
</div>
<div class="fx-seg">
<div class="fx-seg-opt @(InvoiceTab == "apayer" ? "on" : "")" onclick=@(() => SetInvoiceTab( "apayer" ))>
<span>À payer</span>
@if ( PhoneClient.UnpaidCount > 0 )
{
<span class="fx-seg-count">@PhoneClient.UnpaidCount</span>
}
</div>
<div class="fx-seg-opt @(InvoiceTab == "reglees" ? "on" : "")" onclick=@(() => SetInvoiceTab( "reglees" ))><span>Historique</span></div>
</div>
@if ( !PhoneClient.FinesLoaded || !PhoneClient.BillsLoaded )
{
<div class="empty"><i>hourglass_empty</i><div class="empty-text">Chargement des factures…</div></div>
}
else if ( !VisibleInvoices.Any() )
{
<div class="empty"><i>@(InvoiceTab == "apayer" ? "task_alt" : "history")</i><div class="empty-text">@EmptyInvoicesText</div></div>
}
else
{
<div class="fx-list">
@foreach ( var inv in VisibleInvoices )
{
<div class="fx-row">
<div class="fx-icon @inv.Kind"><i>@(inv.Kind == "amende" ? "gavel" : "medical_services")</i></div>
<div class="fx-main">
<div class="fx-issuer">@(inv.Kind == "amende" ? "UCPD · Amende" : "UCFD EMS · Soins")</div>
<div class="fx-title">@inv.Title</div>
<div class="fx-sub">@InvoiceSub( inv )</div>
</div>
<div class="fx-side">
<div class="fx-amount">@Format.Money( inv.Amount )</div>
@if ( inv.Unpaid )
{
<div class="fx-pay @(CanPay( inv ) ? "" : "off")" onclick=@(() => Pay( inv ))>@(IsPaying( inv ) ? "…" : "Payer")</div>
}
else
{
<div class="fx-state @inv.Status">@InvoiceState( inv )</div>
}
</div>
</div>
}
</div>
@if ( InvoiceTab == "apayer" )
{
<div class="fx-foot">Réglées depuis votre compte HZ BANK. Vous pouvez aussi payer à l'accueil du commissariat ou de l'hôpital.</div>
}
}
</div>
</div>
}
else if ( Screen == "signaler" )
{
<div class="app dark">
<div class="large-title">Signaler</div>
<div class="form">
<div class="field-hint">Décrivez le problème : le staff connecté est prévenu tout de suite.</div>
<TextEntry class="input tall" Value=@ReportDraft Placeholder="Que se passe-t-il ?" OnTextEdited=@((string v) => { ReportDraft = v; })></TextEntry>
<div class="btn danger" onclick=@(() => SendReport())><i>flag</i> Envoyer au staff</div>
</div>
</div>
}
else if ( Screen == "reglages" )
{
<div class="app dark">
<div class="large-title">Réglages</div>
<div class="my-card">
<div class="avatar big">@Initial( Me.RpName )</div>
<div class="row-main">
<div class="row-title">@Me.RpName</div>
<div class="row-sub">Horizon Mobile</div>
</div>
</div>
<div class="group">
<div class="group-row"><div class="gr-icon green"><i>call</i></div><span>Mon numéro</span><b>@Me.PhoneNumber</b></div>
<div class="group-row"><div class="gr-icon red"><i>account_balance</i></div><span>Compte HZ BANK</span><b>@Account</b></div>
</div>
<div class="group">
<div class="group-row" onclick=@(() => ToggleKeySounds())>
<div class="gr-icon gray"><i>volume_up</i></div><span>Sons du clavier</span>
<div class="switch @(KeySounds ? "on" : "")"><div class="knob"></div></div>
</div>
</div>
<div class="section-label">Fond d'écran</div>
<div class="walls">
@for ( var i = 0; i < WallpaperCount; i++ )
{
var index = i;
<div class="wall-chip wall-@index @(Wallpaper == index ? "on" : "")" onclick=@(() => SetWallpaper( index ))></div>
}
</div>
</div>
}
<div class="home-bar-zone" onclick=@(() => HomeBar())><div class="home-bar"></div></div>
</div>
</div>
}
</root>
@code
{
sealed record AppTile( string Screen, string Name, string Icon, string Style );
sealed record Key( string Digit, string Letters );
sealed record LockNotification( string App, string Icon, string Style, string Title, string Text, string At, string Sort );
// La grille, sans les applications du dock (comme un vrai : chacune n'est qu'à un endroit).
// Une propriété recalculée, pas un champ : un champ statique survit au rechargement à chaud, et
// l'appli Factures ajoutée n'apparaissait pas (« Réglages » en double à sa place).
static AppTile[] HomeApps => new AppTile[]
{
new( "camera", "Appareil photo", "photo_camera", "camera" ),
new( "photos", "Photos", "photo_library", "photos" ),
new( "contacts", "Contacts", "account_circle", "gray" ),
new( "factures", "Factures", "receipt_long", "factures" ),
new( "annonces", "Annonces", "campaign", "orange" ),
new( "signaler", "Signaler", "flag", "pink" ),
new( "reglages", "Réglages", "settings", "steel" ),
};
static readonly AppTile[] Dock =
{
new( "telephone", "Téléphone", "call", "green" ),
new( "messages", "Messages", "chat_bubble", "green" ),
new( "banque", "HZ BANK", "", "hzbank" ),
new( "gps", "Plans", "navigation", "maps" ),
};
static readonly Key[] Keys =
{
new( "1", "" ), new( "2", "ABC" ), new( "3", "DEF" ),
new( "4", "GHI" ), new( "5", "JKL" ), new( "6", "MNO" ),
new( "7", "PQRS" ), new( "8", "TUV" ), new( "9", "WXYZ" ),
new( "*", "" ), new( "0", "+" ), new( "#", "" ),
};
static readonly int[] Amounts = { 50, 100, 500, 1000, 5000 };
static readonly float[] Zooms = { 0.25f, 0.35f, 0.5f, 0.7f, 1f, 1.4f };
static readonly string[] Days = { "Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi" };
static readonly string[] Months = { "janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre" };
const int WallpaperCount = 6;
const float HoldSeconds = 0.6f;
public bool IsOpen { get; private set; }
Panel PhonePanel { get; set; }
Panel LockPanel { get; set; }
Panel MapPanel { get; set; }
SoundHandle ring;
SoundHandle ringback;
long lastCallId;
bool lastCallUnanswered;
string Screen { get; set; } = "accueil";
string PhoneTab { get; set; } = "recents";
bool Locked { get; set; } = true;
string Peer { get; set; }
string NewPeer { get; set; }
string Dial { get; set; } = "";
string Draft { get; set; } = "";
string AdDraft { get; set; }
string ReportDraft { get; set; }
long ContactId { get; set; }
string ContactName { get; set; }
string ContactNumber { get; set; }
int Wallpaper { get; set; }
bool KeySounds { get; set; } = true;
string contactBack = "contacts";
// Le déverrouillage : rester appuyé au même endroit.
bool holding;
RealTimeSince holdSince;
Vector2 holdScreen;
Vector2 holdLocal;
float HoldProgress => holding ? Math.Clamp( holdSince / HoldSeconds, 0, 1 ) : 0;
// HZ BANK
string TransferTo { get; set; } = "";
string TransferAmountText { get; set; } = "";
string TransferNote { get; set; } = "";
bool TransferPending { get; set; }
int transferSerial;
int LastTransferAmount { get; set; }
string LastTransferName { get; set; }
// Plans : la carte suit le joueur, ou reste où on l'a glissée.
float Zoom { get; set; } = 0.5f;
bool Follow { get; set; } = true;
Vector2 mapCenter;
bool dragging;
bool dragMoved;
Vector2 dragStart;
Vector2 dragOrigin;
RoleplayPlayer Me => RoleplayPlayer.Local;
PhoneSystem Phone => PhoneSystem.Current;
PhoneCall Call => Me is null ? null : Phone?.CallOf( Me.SteamId );
PhoneCall Incoming => Call is { Active: false } c && c.Callee == Me.SteamId ? c : null;
List<PhoneCallLog> Missed => Me is null ? new() : PhoneClient.MissedCalls( Me.PhoneNumber );
// L'heure de la ville (GameClock) ; la date reste la vraie.
string Clock => GameClock.Text;
string DayName => Days[(int)DateTime.Now.DayOfWeek];
string Today => $"{Days[(int)DateTime.Now.DayOfWeek]} {DateTime.Now.Day} {Months[DateTime.Now.Month - 1]}";
string FirstName => (Me?.RpName ?? "").Split( ' ' ).FirstOrDefault() ?? "";
string Account => PhoneClient.Account ?? HorizonRP.Phone.AccountOf( Me?.SteamId ?? 0 ) ?? "";
/// <summary>Le compte affiché par l'application HZ BANK (Atm.AccountsOf).</summary>
string BankAccountId { get; set; } = HorizonRP.Atm.PersonalId;
string CardNumber => $"•••• •••• •••• {new string( Account.Where( char.IsDigit ).ToArray() ).PadLeft( 4, '0' )[^4..]}";
IEnumerable<IGrouping<string, PhoneContact>> ContactGroups =>
PhoneClient.Contacts.OrderBy( x => x.Name ).GroupBy( x => Initial( x.Name ) );
IEnumerable<PhoneMessage> Thread =>
PhoneClient.Messages.Where( x => (x.From == Peer && x.To == Me.PhoneNumber) || (x.To == Peer && x.From == Me.PhoneNumber) ).OrderBy( x => x.Id );
protected override void OnStart()
{
try { Wallpaper = FileSystem.Data.ReadJsonOrDefault<int>( "fond_telephone.json" ); }
catch { Wallpaper = 0; }
try { KeySounds = !FileSystem.Data.FileExists( "telephone_silencieux.json" ); }
catch { KeySounds = true; }
}
protected override void OnUpdate()
{
// Pas de téléphone avant d'avoir un personnage.
if ( Me is null || Me.NeedsCharacter )
{
if ( IsOpen ) SetOpen( false );
return;
}
// Échap referme ce menu (et ne laisse pas s'ouvrir le menu pause en même temps).
if ( IsOpen && Input.EscapePressed )
{
Input.EscapePressed = false;
SetOpen( false );
return;
}
if ( Input.Pressed( "Phone" ) )
SetOpen( !IsOpen );
// La sonnerie tant qu'un appel entrant attend ; la tonalité chez l'appelant ; le bip de fin.
if ( Incoming is not null )
{
if ( ring is null || !ring.IsPlaying )
ring = Sounds.PlayFile( "sounds/telephone/sonnerie.mp3", 0.55f );
}
else if ( ring is not null )
{
ring.Stop( 0.1f );
ring = null;
}
if ( Call is { Active: false } outgoing && outgoing.Caller == Me.SteamId )
{
if ( ringback is null || !ringback.IsPlaying )
ringback = Sounds.PlayFile( "sounds/telephone/appel.mp3", 0.45f );
}
else if ( ringback is not null )
{
ringback.Stop( 0.05f );
ringback = null;
}
// Fin d'appel : « occupé » chez l'appelant que personne n'a décroché (refus, pas de
// réponse), le bip de fin sinon.
var callId = Call?.Id ?? 0;
if ( lastCallId != 0 && callId == 0 )
{
if ( lastCallUnanswered )
Sounds.PlayFile( "sounds/telephone/occupe.mp3", 0.5f );
else
Sounds.Play( "raccroche", 0.4f );
}
lastCallId = callId;
lastCallUnanswered = Call is { Active: false } dialing && dialing.Caller == Me.SteamId;
// L'appareil photo ne rend la ville que tant qu'on le regarde.
if ( IsOpen && !Locked && Call is null && Screen == "camera" )
{
PhoneCamera.Update( Me );
}
else
PhoneCamera.Stop();
var selfie = IsOpen && Screen == "camera" && PhoneCamera.Front;
if ( Me.PhoneSelfie != selfie )
Me.PhoneSelfie = selfie;
if ( !IsOpen )
return;
if ( Screen == "conversation" && Peer is not null )
PhoneClient.Unread.Remove( Peer );
UpdateHold();
UpdateMapDrag();
UpdateAim();
UpdateLook();
UpdateViewer();
// L'appel au 911 a reçu sa réponse.
if ( EmergencyPending && PhoneClient.EmergencySerial != emergencySerial )
{
EmergencyPending = false;
if ( PhoneClient.EmergencyOk )
{
EmergencyDraft = "";
Screen = "urgences_ok";
}
}
// Le virement a reçu sa réponse.
if ( TransferPending && PhoneClient.TransferSerial != transferSerial )
{
TransferPending = false;
if ( PhoneClient.LastTransferOk )
{
Sounds.Play( "argent", 0.45f );
Screen = "banque_ok";
TransferTo = "";
TransferAmountText = "";
TransferNote = "";
}
}
}
void SetOpen( bool open )
{
if ( open == IsOpen )
return;
IsOpen = open;
UiLock.Set( "telephone", open );
if ( open && !PhoneClient.FinesLoaded )
Phone?.RequestFines();
// Il le tient en main (les autres le voient) : l'arme sortie se range, la clé ou les billets
// passent derrière le téléphone le temps qu'il le regarde.
if ( Me is not null )
{
Me.PhoneOut = open;
Me.PhoneSelfie = false;
if ( open && WeaponHolder.Local?.ActiveSerial is { } serial
&& Me.PublishedInventory?.FirstOrDefault( x => WeaponData.Read( x.Data )?.Serial == serial ) is { } weapon )
InventoryActions.RequestUse( weapon.Slot );
}
// On le range verrouillé, comme un vrai : il faudra le déverrouiller en le ressortant.
if ( !open )
{
Locked = true;
holding = false;
dragging = false;
Sounds.PlayFile( "sounds/telephone/verrouille.mp3", 0.5f );
}
}
protected override void OnDisabled() => UiLock.Set( "telephone", false );
void Tap()
{
if ( KeySounds )
Sounds.Play( "tap", 0.25f );
}
// Écran verrouillé
void StartHold()
{
holding = true;
holdSince = 0;
holdScreen = Mouse.Position;
holdLocal = LocalOf( LockPanel, Mouse.Position );
}
void StopHold() => holding = false;
void UpdateHold()
{
if ( !holding )
return;
// Le pointeur a glissé : ce n'était pas un appui.
if ( Mouse.Position.Distance( holdScreen ) > 30 || !Locked )
{
holding = false;
return;
}
if ( holdSince >= HoldSeconds )
{
holding = false;
Locked = false;
Sounds.PlayFile( "sounds/telephone/deverrouille.mp3", 0.5f );
if ( Screen == "telephone" && PhoneTab == "recents" )
MarkCallsSeen();
}
}
string HoldStyle => $"left: {F( holdLocal.x - 44 )}px; top: {F( holdLocal.y - 44 )}px;";
List<LockNotification> LockNotifications
{
get
{
var list = new List<LockNotification>();
foreach ( var group in Missed.GroupBy( x => x.From ) )
{
var last = group.OrderByDescending( x => x.Id ).First();
var count = group.Count();
list.Add( new( "TÉLÉPHONE", "call_missed", "green", PhoneClient.NameOf( group.Key ),
count > 1 ? $"{count} appels manqués" : "Appel manqué", When( last.CreatedAt ), last.CreatedAt ?? "" ) );
}
foreach ( var number in PhoneClient.Unread )
{
var messages = PhoneClient.Messages.Where( x => x.From == number && x.To == Me.PhoneNumber ).OrderBy( x => x.Id ).ToList();
if ( messages.Count == 0 )
continue;
var last = messages[^1];
list.Add( new( "MESSAGES", "chat_bubble", "green", PhoneClient.NameOf( number ), PhoneClient.Preview( last.Text ), When( last.CreatedAt ), last.CreatedAt ?? "" ) );
}
return list.OrderByDescending( x => x.Sort ).ToList();
}
}
// Navigation
void Go( string screen )
{
Tap();
if ( screen == "telephone" )
{
if ( PhoneTab == "recents" )
MarkCallsSeen();
Dial = "";
}
if ( screen == "banque" )
RefreshBank();
if ( screen == "factures" )
{
InvoiceTab = "apayer";
Phone?.RequestFines();
}
if ( screen == "urgences" )
{
EmergencyPending = false;
ConfirmEmsClose = 0;
}
if ( screen == "gps" )
Follow = true;
if ( screen == "nouveau" || screen == "photo_envoi" )
NewPeer = "";
if ( screen == "photos" )
Phone?.RequestPhotos();
if ( screen != "photos" && screen != "photo" )
PickFor = null;
Screen = screen;
}
void Back() => Go( Screen switch
{
"contact" => contactBack,
"photo" => viewBack,
"photo_envoi" => "photo",
_ => "accueil",
} );
// Appareil photo et Photos
string PickFor { get; set; }
long ViewId { get; set; }
string ViewKey { get; set; }
string ViewDate { get; set; }
bool ViewMine { get; set; }
string viewBack = "photos";
RealTimeSince sinceShot = 10;
bool aiming;
Vector2 aimLast;
// La visionneuse : la molette zoome (1× à 5×), on glisse pour se déplacer dans la photo.
Panel ViewerPanel { get; set; }
float ViewZoom { get; set; } = 1f;
Vector2 viewPan;
bool panning;
Vector2 panLast;
// La molette, reçue par le panneau lui-même (WheelPanel) : sous le pointeur, l'interface la
// garde et Input.MouseWheel reste à zéro. Un cran vers l'avant (y négatif) zoome.
void CameraWheel( float y ) => PhoneCamera.ZoomBy( -y );
void ViewerWheel( float y )
{
ViewZoom = Math.Clamp( ViewZoom * MathF.Pow( 1.2f, -y ), 1f, 5f );
if ( ViewZoom <= 1.01f )
viewPan = Vector2.Zero;
}
void StartPan()
{
panning = true;
panLast = Mouse.Position;
}
void UpdateViewer()
{
if ( Screen != "photo" )
{
panning = false;
return;
}
if ( panning )
{
if ( PhonePanel is not null && !PhonePanel.Box.Rect.IsInside( Mouse.Position ) )
panning = false;
else
{
viewPan += (Mouse.Position - panLast) * (PhonePanel?.ScaleFromScreen ?? 1f);
panLast = Mouse.Position;
}
}
// On ne sort pas de la photo en glissant : le bord reste au bord de l'écran.
var size = ViewerSize;
var limit = size * (ViewZoom - 1) / 2;
viewPan = new Vector2( Math.Clamp( viewPan.x, -limit.x, limit.x ), Math.Clamp( viewPan.y, -limit.y, limit.y ) );
}
Vector2 ViewerSize => ViewerPanel is null || ViewerPanel.Box.Rect.Width < 10
? new Vector2( 328, 520 )
: ViewerPanel.Box.Rect.Size * ViewerPanel.ScaleFromScreen;
string ViewerStyle
{
get
{
var size = ViewerSize;
var w = size.x * ViewZoom;
var h = size.y * ViewZoom;
return $"width: {F( w )}px; height: {F( h )}px; left: {F( (size.x - w) / 2 + viewPan.x )}px; top: {F( (size.y - h) / 2 + viewPan.y )}px;";
}
}
string PhotoDate => ViewDate is { Length: >= 16 } ? $"{DayOf( ViewDate )} · {ShortTime( ViewDate )}" : "";
// Le regard, clic droit maintenu à côté du téléphone (Julien, 29/10/2026). La souris est libre
// (on tape sur le téléphone) : on suit son déplacement à l'écran, comme pour le viseur photo.
bool looking;
Vector2 lookLast;
/// <summary>Degrés de regard par pixel de souris.</summary>
public static float LookSpeed { get; set; } = 0.22f;
void OutsideDown( PanelEvent e )
{
if ( e is not MousePanelEvent { MouseButton: MouseButtons.Right } )
return;
looking = true;
lookLast = Mouse.Position;
}
void UpdateLook()
{
if ( !looking )
return;
if ( !IsOpen || Me?.GetComponent<PlayerController>() is not { } controller )
{
looking = false;
return;
}
var delta = Mouse.Position - lookLast;
lookLast = Mouse.Position;
var angles = controller.EyeAngles;
angles.yaw -= delta.x * LookSpeed;
angles.pitch = Math.Clamp( angles.pitch + delta.y * LookSpeed, -85f, 85f );
controller.EyeAngles = angles;
}
void StartAim()
{
aiming = true;
aimLast = Mouse.Position;
}
void EndAim() => aiming = false;
void UpdateAim()
{
if ( !aiming )
return;
if ( Screen != "camera" || (PhonePanel is not null && !PhonePanel.Box.Rect.IsInside( Mouse.Position )) )
{
aiming = false;
return;
}
var scale = PhonePanel?.ScaleFromScreen ?? 1f;
PhoneCamera.Aim( Me, (Mouse.Position - aimLast) * scale );
aimLast = Mouse.Position;
}
void Shoot()
{
PhoneCamera.Shoot();
Sounds.PlayFile( "sounds/telephone/obturateur.mp3", 0.6f );
sinceShot = 0;
}
void FlipCamera()
{
Tap();
PhoneCamera.Front = !PhoneCamera.Front;
PhoneCamera.OrbitYaw = 0;
PhoneCamera.OrbitPitch = 0;
}
void PickPhotoFor( string peer )
{
Go( "photos" );
PickFor = peer;
}
void OpenPhoto( long id, string key, string createdAt, bool mine )
{
if ( mine && PickFor is not null )
{
SendPhoto( PickFor, id, key );
return;
}
Tap();
viewBack = Screen == "conversation" ? "conversation" : "photos";
ViewId = id;
ViewKey = key;
ViewDate = createdAt;
ViewMine = mine;
ViewZoom = 1f;
viewPan = Vector2.Zero;
Screen = "photo";
}
void SharePhoto()
{
Tap();
NewPeer = "";
Screen = "photo_envoi";
}
void SendPhoto( string number, long id, string key )
{
var normalized = HorizonRP.Phone.Normalize( number );
if ( normalized is null )
{
HudNotifications.Push( "Numéro invalide : 7 chiffres, « 123-4567 ».", "erreur" );
return;
}
Phone?.RequestSms( normalized, HorizonRP.Phone.PhotoMarker( id, key ) );
Sounds.Play( "envoye", 0.4f );
PickFor = null;
Peer = normalized;
Draft = "";
Screen = "conversation";
}
void DeletePhoto()
{
Tap();
Phone?.RequestPhotoDelete( ViewId );
PhoneClient.Photos.RemoveAll( x => x.Id == ViewId );
PhoneClient.Version++;
Screen = "photos";
}
void HomeBar()
{
if ( Call is not null || Locked )
return;
if ( Screen != "accueil" )
Go( "accueil" );
}
int Badge( string screen ) => screen switch
{
"telephone" => Missed.Count,
"messages" => PhoneClient.Unread.Count,
"factures" => PhoneClient.UnpaidCount,
_ => 0,
};
// Téléphone
void SetPhoneTab( string tab )
{
Tap();
PhoneTab = tab;
if ( tab == "recents" )
MarkCallsSeen();
if ( tab == "clavier" )
Dial = "";
}
void MarkCallsSeen()
{
var missed = Missed;
if ( missed.Count == 0 )
return;
foreach ( var c in missed )
c.Seen = true;
PhoneClient.Version++;
Phone?.RequestCallsSeen();
}
void Answer()
{
Tap();
Phone?.RequestAnswer();
}
void HangUp()
{
Tap();
Phone?.RequestHangUp();
}
string DialName
{
get
{
var number = HorizonRP.Phone.Normalize( Dial );
if ( number is null )
return null;
var name = PhoneClient.NameOf( number );
return name != number ? name : null;
}
}
void Press( string key )
{
if ( KeySounds )
Sounds.Play( $"dtmf_{key}", 0.3f );
if ( !char.IsDigit( key[0] ) )
return;
var digits = new string( Dial.Where( char.IsDigit ).ToArray() );
if ( digits.Length >= 7 )
return;
digits += key;
Dial = digits.Length > 3 ? $"{digits[..3]}-{digits[3..]}" : digits;
}
void Erase()
{
Tap();
var digits = new string( Dial.Where( char.IsDigit ).ToArray() );
digits = digits.Length == 0 ? "" : digits[..^1];
Dial = digits.Length > 3 ? $"{digits[..3]}-{digits[3..]}" : digits;
}
void CallNumber( string number )
{
// Le 911 n'est pas un appel vocal : l'écran « Urgences » (PoliceService.Dispatch.cs).
if ( IsEmergency( number ) )
{
OpenEmergency();
return;
}
Tap();
var normalized = HorizonRP.Phone.Normalize( number );
if ( normalized is null )
{
HudNotifications.Push( "Numéro invalide : 7 chiffres, « 123-4567 ».", "erreur" );
return;
}
Phone?.RequestCall( normalized );
}
static string CallLabel( PhoneCallLog c, bool mine )
{
if ( c.Answered )
return $"{(mine ? "Sortant" : "Entrant")} · {c.Duration / 60}:{c.Duration % 60:00}";
return mine ? "Sortant · sans réponse" : "Appel manqué";
}
// Messages et contacts
void OpenConversation( string number )
{
var normalized = HorizonRP.Phone.Normalize( number );
if ( normalized is null )
{
HudNotifications.Push( "Numéro invalide : 7 chiffres, « 123-4567 ».", "erreur" );
return;
}
Tap();
if ( Peer != normalized && !(Draft ?? "").StartsWith( "Mon compte HZ BANK" ) )
Draft = "";
SelectedMessage = 0;
ConfirmDeleteThread = false;
Peer = normalized;
Screen = "conversation";
}
// Supprimer : un message (clic dessus, ou clic droit sur une photo), ou toute la conversation
// (corbeille de la barre). Chez soi seulement : l'autre garde ses messages.
long SelectedMessage { get; set; }
bool ConfirmDeleteThread { get; set; }
void SelectMessage( long id )
{
Tap();
SelectedMessage = SelectedMessage == id ? 0 : id;
}
void DeleteMessage( long id )
{
Tap();
PhoneClient.Messages.RemoveAll( x => x.Id == id );
PhoneClient.Version++;
SelectedMessage = 0;
Phone?.RequestDeleteSms( Peer, id );
}
void AskDeleteThread()
{
Tap();
ConfirmDeleteThread = !ConfirmDeleteThread;
}
void DeleteThread()
{
Tap();
var peer = Peer;
var me = Me.PhoneNumber;
PhoneClient.Messages.RemoveAll( x => (x.From == me && x.To == peer) || (x.To == me && x.From == peer) );
PhoneClient.Unread.Remove( peer );
PhoneClient.Version++;
ConfirmDeleteThread = false;
Phone?.RequestDeleteSms( peer, 0 );
Go( "messages" );
}
void SendDraft()
{
if ( string.IsNullOrWhiteSpace( Draft ) || Peer is null )
return;
Phone?.RequestSms( Peer, Draft );
Sounds.Play( "envoye", 0.4f );
Draft = "";
}
void EditContact( PhoneContact contact )
{
Tap();
contactBack = Screen == "telephone" ? "telephone" : Screen == "conversation" ? "conversation" : "contacts";
ContactId = contact?.Id ?? 0;
ContactName = contact?.Name ?? "";
ContactNumber = contact?.Number ?? "";
Screen = "contact";
}
void SaveContact()
{
Phone?.RequestSaveContact( ContactId, ContactName, ContactNumber );
Go( contactBack );
}
void DeleteContact()
{
Phone?.RequestDeleteContact( ContactId );
Go( contactBack == "conversation" ? "contacts" : contactBack );
}
// HZ BANK
void RefreshBank() => Phone?.RequestBankHistory();
void StartTransfer()
{
Tap();
TransferPending = false;
Screen = "banque_virement";
}
void PickPayee( string number )
{
Tap();
TransferTo = number;
}
void PickAmount( int amount )
{
Tap();
TransferAmountText = amount.ToString();
}
int TransferAmount => int.TryParse( new string( (TransferAmountText ?? "").Where( char.IsDigit ).Take( 9 ).ToArray() ), out var v ) ? v : 0;
bool CanTransfer => !TransferPending && TransferAmount > 0 && TransferAmount <= (Me?.Bank ?? 0) && RecipientName is not null;
/// <summary>Le destinataire reconnu : un joueur en ville (par son téléphone ou son compte), ou un numéro de compte bien formé.</summary>
string RecipientName
{
get
{
var number = HorizonRP.Phone.Normalize( TransferTo );
if ( number is not null )
return HorizonRP.Phone.PlayerOf( number ) is null ? null : PayeeName( number );
var account = HorizonRP.Phone.NormalizeAccount( TransferTo );
if ( account is null )
return null;
return HorizonRP.Phone.PlayerOfAccount( account )?.RpName ?? account;
}
}
string RecipientHint
{
get
{
if ( string.IsNullOrWhiteSpace( TransferTo ) )
return "Un numéro de compte HZ BANK, ou le téléphone d'une personne en ville.";
var number = HorizonRP.Phone.Normalize( TransferTo );
if ( number is not null )
return HorizonRP.Phone.PlayerOf( number ) is not null ? $"✓ {PayeeName( number )}" : "Ce numéro n'est pas en ville : demandez-lui son numéro de compte.";
var account = HorizonRP.Phone.NormalizeAccount( TransferTo );
if ( account is null )
return "Numéro incomplet.";
return HorizonRP.Phone.PlayerOfAccount( account ) is { } q ? $"✓ {q.RpName}" : $"✓ Compte {account}";
}
}
/// <summary>Le nom d'un destinataire en ville : celui du répertoire s'il y est, sinon celui du personnage.</summary>
static string PayeeName( string number )
{
var contact = PhoneClient.NameOf( number );
return contact != number ? contact : HorizonRP.Phone.PlayerOf( number )?.RpName ?? number;
}
void SendTransfer()
{
if ( !CanTransfer )
{
if ( TransferAmount > (Me?.Bank ?? 0) )
HudNotifications.Push( "Solde insuffisant.", "erreur" );
return;
}
Tap();
TransferPending = true;
transferSerial = PhoneClient.TransferSerial;
LastTransferAmount = TransferAmount;
LastTransferName = RecipientName;
Phone?.RequestTransfer( TransferTo, TransferAmount, TransferNote ?? "" );
}
// Factures : les amendes de l'UCPD (payées sur le compte HZ BANK, PhoneSystem.RequestFinePay)
static string FineTitle( Backend.MdtFine f ) => string.IsNullOrWhiteSpace( f.Title ) ? "Amende" : f.Title;
// Factures : les factures de soins de l'UCFD EMS (PhoneSystem.RequestBillPay)
static string BillTitle( Backend.EmsBill b ) => string.IsNullOrWhiteSpace( b.Title ) ? "Soins" : b.Title;
// Factures : les amendes de l'UCPD et les factures de soins de l'UCFD EMS dans une seule liste
// (Julien, 30/10/2026 : une appli à part, plus un écran de HZ BANK). Le paiement reste celui
// d'avant (PhoneSystem.RequestFinePay / RequestBillPay), débité du compte HZ BANK.
sealed record Invoice( string Kind, long Id, string Title, string Date, int Amount, string Status, bool Unpaid );
/// <summary>« apayer » ou « reglees ».</summary>
string InvoiceTab { get; set; } = "apayer";
void SetInvoiceTab( string tab )
{
Tap();
InvoiceTab = tab;
}
static IEnumerable<Invoice> AllInvoices =>
PhoneClient.Fines.Select( f => new Invoice( "amende", f.Id, FineTitle( f ), f.IssuedAt, f.Amount, f.Status, f.Unpaid ) )
.Concat( PhoneClient.Bills.Select( b => new Invoice( "soins", b.Id, BillTitle( b ), b.IssuedAt, b.Amount, b.Status, b.Unpaid ) ) )
.OrderByDescending( x => x.Date ?? "" );
IEnumerable<Invoice> VisibleInvoices => InvoiceTab == "apayer"
? AllInvoices.Where( x => x.Unpaid )
: AllInvoices.Where( x => !x.Unpaid ).Take( 40 );
// Dans le balisage, une apostrophe au milieu d'une interpolation imbriquée coupe l'analyse de Razor :
// les textes composés sont ici.
static string SummaryText
{
get
{
var parts = new List<string>();
if ( PhoneClient.UnpaidFines.Count() is var f and > 0 )
parts.Add( f > 1 ? $"{f} amendes" : "1 amende" );
if ( PhoneClient.UnpaidBills.Count() is var b and > 0 )
parts.Add( b > 1 ? $"{b} factures de soins" : "1 facture de soins" );
return parts.Count == 0 ? "Aucune facture en attente" : string.Join( " · ", parts );
}
}
string ShortBalanceText => $"Solde HZ BANK de {Format.Money( Me?.Bank ?? 0 )} : pas assez pour tout régler";
string EmptyInvoicesText => InvoiceTab == "apayer" ? "Vous êtes à jour : rien à payer." : "Aucune facture réglée pour l'instant.";
static string InvoiceSub( Invoice inv ) => $"n°{inv.Id} · {DayOf( inv.Date )}";
static string InvoiceState( Invoice inv ) => inv.Status switch
{
"payee" => "Payée",
"annulee" => "Annulée",
_ => inv.Status ?? "",
};
bool IsPaying( Invoice inv ) => inv.Kind == "amende" ? PhoneClient.FinePaying == inv.Id : PhoneClient.BillPaying == inv.Id;
bool CanPay( Invoice inv ) => PhoneClient.FinePaying == 0 && PhoneClient.BillPaying == 0 && inv.Amount <= (Me?.Bank ?? 0);
void Pay( Invoice inv )
{
if ( inv.Kind == "amende" )
{
if ( PhoneClient.Fines.FirstOrDefault( x => x.Id == inv.Id ) is { } fine )
PayFine( fine );
}
else if ( PhoneClient.Bills.FirstOrDefault( x => x.Id == inv.Id ) is { } bill )
PayBill( bill );
}
void PayBill( Backend.EmsBill bill )
{
if ( PhoneClient.BillPaying != 0 )
return;
if ( bill.Amount > (Me?.Bank ?? 0) )
{
HudNotifications.Push( "Solde insuffisant pour régler cette facture.", "erreur" );
return;
}
Tap();
PhoneClient.BillPaying = bill.Id;
PhoneClient.Version++;
Phone?.RequestBillPay( bill.Id );
}
void PayFine( Backend.MdtFine fine )
{
if ( PhoneClient.FinePaying != 0 )
return;
if ( fine.Amount > (Me?.Bank ?? 0) )
{
HudNotifications.Push( "Solde insuffisant pour régler cette amende.", "erreur" );
return;
}
Tap();
PhoneClient.FinePaying = fine.Id;
PhoneClient.Version++;
Phone?.RequestFinePay( fine.Id );
}
// Urgences (911)
string EmergencyService { get; set; } = "police";
string EmergencyDraft { get; set; } = "";
bool EmergencyShare { get; set; } = true;
bool EmergencyPending { get; set; }
int emergencySerial;
string EmergencySentService { get; set; }
/// <summary>L'appel des EMS qui attend un second appui pour être clos.</summary>
long ConfirmEmsClose { get; set; }
/// <summary>Le numéro composé est-il le 911 (PoliceRules.EmergencyNumber) ?</summary>
static bool IsEmergency( string number ) =>
new string( (number ?? "").Where( char.IsDigit ).ToArray() ) == PoliceRules.EmergencyNumber;
void OpenEmergency() => Go( "urgences" );
void PickEmergency( string service )
{
Tap();
EmergencyService = service;
}
bool CanSendEmergency => !EmergencyPending && !string.IsNullOrWhiteSpace( EmergencyDraft );
void SendEmergency()
{
if ( !CanSendEmergency )
{
if ( string.IsNullOrWhiteSpace( EmergencyDraft ) )
HudNotifications.Push( "911 : décrivez la situation en quelques mots.", "erreur" );
return;
}
Tap();
EmergencyPending = true;
emergencySerial = PhoneClient.EmergencySerial;
EmergencySentService = EmergencyService;
PoliceService.RequestEmergency( EmergencyService, EmergencyDraft, EmergencyShare );
Sounds.Play( "envoye", 0.4f );
}
/// <summary>L'appel qu'on vient de passer, tel que l'hôte le publie (qui y va).</summary>
DispatchCall MyEmergency => PoliceService.Current?.Dispatch.FirstOrDefault( x => x.Id == PhoneClient.EmergencyId );
string EmergencyStatus => MyEmergency switch
{
{ Status: "clos" } => "Votre appel est clos.",
{ Units: { } units } => $"En route : {units}",
_ => EmergencySentService == "ems"
? "Les secours sont prévenus. Restez où vous êtes si vous le pouvez."
: "Les agents en service sont prévenus. Restez où vous êtes si vous le pouvez.",
};
/// <summary>Les secouristes de l'UCFD en service prennent leurs appels ici : ils n'ont pas de tablette.</summary>
IEnumerable<DispatchCall> EmsCalls =>
(PoliceService.Current?.Dispatch ?? (IReadOnlyList<DispatchCall>)Array.Empty<DispatchCall>()).Where( x => x.Service == "ems" && x.Open );
string EmsLine( DispatchCall call )
{
var where = call.HasPosition ? Gps.FormatDistance( Me.WorldPosition.WithZ( 0 ).Distance( call.Position.WithZ( 0 ) ) * 0.0254f ) : "position non transmise";
return call.Units is { } units ? $"{where} · {units}" : where;
}
void TakeEmsCall( DispatchCall call )
{
Tap();
PoliceService.RequestTakeCall( call.Id );
RouteTo( call );
}
void RouteTo( DispatchCall call )
{
if ( !call.HasPosition )
return;
Gps.SetTarget( call.Position, $"911 n°{call.Id}" );
Go( "gps" );
}
void CloseEmsCall( DispatchCall call )
{
Tap();
if ( ConfirmEmsClose != call.Id )
{
ConfirmEmsClose = call.Id;
HudNotifications.Push( $"Appel n°{call.Id} : touchez encore pour le clore.", "info" );
return;
}
ConfirmEmsClose = 0;
PoliceService.RequestCloseCall( call.Id );
}
void ShareAccount( string number )
{
Draft = $"Mon compte HZ BANK : {Account} ({Me.RpName})";
OpenConversation( number );
}
static string OpIcon( string kind ) => kind switch
{
"amende" => "gavel",
"soins" => "medical_services",
"depot" => "savings",
"retrait" => "local_atm",
"carte" => "credit_card",
"virement_envoye" => "north_east",
"virement_recu" => "south_west",
"remboursement" => "undo",
"staff" => "admin_panel_settings",
"paie" => "work",
_ => "receipt_long",
};
static string OpSub( Bank.Op op )
{
var what = op.Kind switch
{
"depot" => "Dépôt au distributeur",
"retrait" => "Retrait au distributeur",
"carte" => "Paiement par carte",
"virement_envoye" => "Virement émis",
"virement_recu" => "Virement reçu",
"remboursement" => "Remboursement",
"staff" => "Opération de la banque",
"paie" => "Salaire",
"amende" => "Amende de l'UCPD",
"soins" => "Soins de l'UCFD",
_ => "Opération",
};
return $"{what} · {ShortTime( op.CreatedAt )}";
}
static string SignedMoney( int amount ) => amount > 0 ? $"+{Format.Money( amount )}" : $"−{Format.Money( -amount )}";
static string DayOf( string createdAt )
{
if ( createdAt is not { Length: >= 10 } || !DateTime.TryParse( createdAt[..10], out var day ) )
return "";
var today = DateTime.Now.Date;
if ( day == today ) return "Aujourd'hui";
if ( day == today.AddDays( -1 ) ) return "Hier";
return $"{Days[(int)day.DayOfWeek]} {day.Day} {Months[day.Month - 1]}";
}
// Plans
List<Gps.Place> MapPlaces => Gps.Places( Me.WorldPosition );
/// <summary>Les logos de la carte : commissariat, hôpital, et les voitures sorties du joueur.</summary>
List<Gps.Place> MapPins
{
get
{
var pins = new List<Gps.Place> { Gps.Police, Gps.Hospital };
foreach ( var car in Vehicle.All.Where( x => x.IsValid() && x.OwnerId == Me.SteamId ) )
pins.Add( new( string.IsNullOrEmpty( car.Plate ) ? "Ma voiture" : $"Ma voiture ({car.Plate})", "directions_car", car.WorldPosition, "voiture" ) );
return pins;
}
}
Vector2 MapViewSize
{
get
{
if ( MapPanel is null || MapPanel.Box.Rect.Width < 10 )
return new Vector2( 336, 690 );
return MapPanel.Box.Rect.Size * MapPanel.ScaleFromScreen;
}
}
Vector2 MapCenter => Follow ? GpsMap.ToImage( Me.WorldPosition ) : mapCenter;
/// <summary>Une position du monde dans l'image affichée (unités d'interface).</summary>
Vector2 ToView( Vector3 world ) => GpsMap.ToImage( world ) * Zoom;
string MapImageStyle
{
get
{
var offset = MapViewSize / 2 - MapCenter * Zoom;
var size = GpsMap.ImageSize * Zoom;
return $"left: {F( offset.x )}px; top: {F( offset.y )}px; width: {F( size )}px; height: {F( size )}px;";
}
}
IEnumerable<string> RouteSegments
{
get
{
var route = Gps.Route;
for ( var k = 0; k < route.Count - 1; k++ )
{
var a = ToView( route[k] );
var b = ToView( route[k + 1] );
var mid = (a + b) / 2;
var length = (b - a).Length;
var angle = MathF.Atan2( b.y - a.y, b.x - a.x ) * 180f / MathF.PI;
yield return $"left: {F( mid.x - length / 2 )}px; top: {F( mid.y - 3.5f )}px; width: {F( length )}px; transform: rotate( {F( angle )}deg );";
}
}
}
IEnumerable<string> RouteJoints
{
get
{
var route = Gps.Route;
for ( var k = 1; k < route.Count - 1; k++ )
{
var p = ToView( route[k] );
yield return $"left: {F( p.x - 3.5f )}px; top: {F( p.y - 3.5f )}px;";
}
}
}
string PinStyle( Vector3 world, float size )
{
var p = ToView( world );
return $"left: {F( p.x - size / 2 )}px; top: {F( p.y - size )}px; width: {F( size )}px; height: {F( size )}px;";
}
string MeStyle
{
get
{
var p = ToView( Me.WorldPosition );
return $"left: {F( p.x - 22 )}px; top: {F( p.y - 22 )}px; transform: rotate( {F( Gps.PlayerHeading( Me ) )}deg );";
}
}
string ArrowStyle => $"transform: rotate( {F( Gps.NextBearing( Me.WorldPosition ) )}deg );";
string Eta
{
get
{
// Une vitesse moyenne en ville : 35 km/h en voiture, 5 km/h à pied.
var speed = Vehicle.OfLocalPlayer is not null ? 35f / 3.6f : 5f / 3.6f;
var minutes = (int)MathF.Ceiling( Gps.RemainingMeters / speed / 60f );
return minutes <= 1 ? "1 min" : $"{minutes} min";
}
}
void ZoomBy( int step )
{
Tap();
var index = Array.IndexOf( Zooms, Zoom );
if ( index < 0 ) index = 2;
var next = Math.Clamp( index + step, 0, Zooms.Length - 1 );
if ( !Follow )
mapCenter = MapCenter;
Zoom = Zooms[next];
}
void Recenter()
{
Tap();
Follow = true;
}
void StartMapDrag()
{
dragging = true;
dragMoved = false;
dragStart = Mouse.Position;
dragOrigin = MapCenter;
}
void UpdateMapDrag()
{
if ( !dragging || MapPanel is null )
return;
// Le pointeur est sorti du téléphone sans relâcher ici : on lâche la carte.
if ( PhonePanel is not null && !PhonePanel.Box.Rect.IsInside( Mouse.Position ) )
{
dragging = false;
return;
}
var delta = (Mouse.Position - dragStart) * MapPanel.ScaleFromScreen;
if ( !dragMoved && delta.Length < 6 )
return;
dragMoved = true;
Follow = false;
mapCenter = dragOrigin - delta / Zoom;
}
void EndMapDrag()
{
if ( !dragging )
return;
dragging = false;
if ( dragMoved || MapPanel is null )
return;
// Un simple appui : sur un logo, l'itinéraire vers ce lieu ; ailleurs, la destination est posée là.
var local = LocalOf( MapPanel, Mouse.Position );
var image = MapCenter + (local - MapViewSize / 2) / Zoom;
var pin = MapPins
.Select( p => (Place: p, Distance: (GpsMap.ToImage( p.Position ) * Zoom - image * Zoom).Length) )
.Where( x => x.Distance < 24 )
.OrderBy( x => x.Distance )
.Select( x => x.Place )
.FirstOrDefault();
if ( pin is not null )
{
GoTo( pin );
return;
}
Gps.SetTarget( GpsMap.ToWorld( image ) );
Sounds.Play( "info", 0.3f );
}
void GoTo( Gps.Place place )
{
Tap();
Gps.SetTarget( place.Position, place.Name );
Follow = true;
}
// Annonces, signalement, réglages
void PostAd()
{
if ( string.IsNullOrWhiteSpace( AdDraft ) )
return;
Phone?.RequestAd( AdDraft );
Sounds.Play( "envoye", 0.4f );
AdDraft = "";
}
void SendReport()
{
if ( string.IsNullOrWhiteSpace( ReportDraft ) )
return;
Phone?.RequestReport( ReportDraft );
Sounds.Play( "envoye", 0.4f );
ReportDraft = "";
Screen = "accueil";
}
void SetWallpaper( int index )
{
Tap();
Wallpaper = index;
try { FileSystem.Data.WriteJson( "fond_telephone.json", index ); }
catch { }
}
void ToggleKeySounds()
{
KeySounds = !KeySounds;
Tap();
try
{
if ( KeySounds ) FileSystem.Data.DeleteFile( "telephone_silencieux.json" );
else FileSystem.Data.WriteJson( "telephone_silencieux.json", true );
}
catch { }
}
// Outils
/// <summary>La position du pointeur dans un panneau, en unités d'interface (celles du style).</summary>
static Vector2 LocalOf( Panel panel, Vector2 screen ) =>
panel is null ? Vector2.Zero : (screen - panel.Box.Rect.Position) * panel.ScaleFromScreen;
static string F( float v ) => v.ToString( "0.##", System.Globalization.CultureInfo.InvariantCulture );
static string Arc( float fraction, string color )
{
// Les arrêts en pourcentages du tour : le lecteur de styles de s&box ne prend pas les degrés.
var a = F( Math.Clamp( fraction, 0, 1 ) * 100f );
return $"background: conic-gradient( {color} 0%, {color} {a}%, rgba( 255, 255, 255, 0.18 ) {a}%, rgba( 255, 255, 255, 0.18 ) 100% );";
}
static bool Online( string number ) => HorizonRP.Phone.PlayerOf( number ) is not null;
static string Initial( string name ) => string.IsNullOrEmpty( name ) ? "?" : name[..1].ToUpperInvariant();
static string ShortTime( string createdAt ) => createdAt is { Length: >= 16 } ? createdAt.Substring( 11, 5 ) : "";
/// <summary>« 14:32 » aujourd'hui, « Hier », sinon « 27/10 ».</summary>
static string When( string createdAt )
{
if ( createdAt is not { Length: >= 16 } || !DateTime.TryParse( createdAt[..10], out var day ) )
return "";
var today = DateTime.Now.Date;
if ( day == today ) return createdAt.Substring( 11, 5 );
if ( day == today.AddDays( -1 ) ) return "Hier";
return $"{day.Day:00}/{day.Month:00}";
}
static string Duration( PhoneCall call )
{
var seconds = Math.Max( 0, DateTimeOffset.UtcNow.ToUnixTimeSeconds() - call.AnsweredAt );
return $"{seconds / 60:00}:{seconds % 60:00}";
}
protected override int BuildHash()
{
var call = Call;
// Les plans, l'appui sur l'écran verrouillé et la carte glissée bougent à chaque image.
var live = IsOpen && (Screen == "gps" || Screen == "camera" || holding || dragging || panning || Screen == "photo") ? RealTime.Now : 0;
return HashCode.Combine(
HashCode.Combine( IsOpen, Screen, PhoneTab, Locked, Peer, Dial, Draft, HashCode.Combine( live, InvoiceTab ) ),
HashCode.Combine( AdDraft, ReportDraft, NewPeer, ContactName, ContactNumber, Wallpaper, KeySounds ),
HashCode.Combine( TransferTo, TransferAmountText, TransferNote, TransferPending, Zoom, Follow, PickFor, HashCode.Combine( ViewId, SelectedMessage, ConfirmDeleteThread ) ),
HashCode.Combine( PhoneClient.Version, PhoneClient.Unread.Count, PhoneClient.Ads.Count, Gps.Version ),
HashCode.Combine( call?.Id, call?.Active, call is { Active: true } ? Duration( call ) : null ),
HashCode.Combine( Me?.Bank, Me?.Wallet, Clock, RoleplayPlayer.All.Count() ),
// Le 911 : le formulaire, et les appels publiés (EMS, « en route »).
HashCode.Combine( EmergencyService, EmergencyDraft, EmergencyShare, EmergencyPending, ConfirmEmsClose,
Screen is "urgences" or "urgences_ok" ? PoliceService.Current?.DispatchJson : null ) );
}
}