Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d13b88aebe | |||
| b824f62878 | |||
| 00739ac8a0 | |||
| 94cfd72690 | |||
| e3a2378c34 | |||
| 8b452a5593 | |||
| 492db55a04 | |||
| 617dd05267 | |||
| 5246a97db2 | |||
| 933a74c900 | |||
| 9a638545dd | |||
| 69687d0acf | |||
| 4e3d7de32c | |||
| 414d39c835 | |||
| 2fd4c2e988 | |||
| e5d0e54b80 | |||
| bd51fef5cc | |||
| 79ad411d7e | |||
| f903e21e3a | |||
| 923a6a01bd | |||
| 9985b79aa6 | |||
| b82f50f888 | |||
| 4f2cbdbdc4 | |||
| 90a1390716 |
+178
@@ -4,6 +4,184 @@ Všechny podstatné změny v aplikaci Šepot. Formát dle
|
||||
[Keep a Changelog](https://keepachangelog.com/cs/1.1.0/),
|
||||
verzování dle [ajtak standardu](#verzování).
|
||||
|
||||
## [Nevydáno]
|
||||
|
||||
## [1.2.1] – 2026-10-09
|
||||
|
||||
### Opraveno
|
||||
- **Hovory se nedaly přijmout.** Příchozí signalizace hovoru se zahazovala dřív,
|
||||
než se dostala k obsluze hovoru; volaný jen „zazvonil“ z budíčku a pak nic.
|
||||
- **Obrazovka hovoru po zavěšení:** avatar a jméno uskakovaly vlevo nahoru.
|
||||
- **Obnova ze 24 slov na novém telefonu** končila nekonečným čekáním — server
|
||||
odmítal už existující identitu. Nové zařízení ji teď převezme podpisem.
|
||||
- **Tichá ztráta zpráv:** úklid mizících zpráv mohl přepsat čerstvě přijatou
|
||||
zprávu; automatický zámek během zpracování zprávy ji mohl nenávratně zahodit;
|
||||
souběžné šifrování pro téhož příjemce dávalo obálky, které druhá strana
|
||||
zahodila. Všechny tři cesty opraveny.
|
||||
- **Velká fronta po delším výpadku** (stovky zpráv) se už neodpojuje v půlce
|
||||
a server ji po osmi pokusech nemaže.
|
||||
- **Nátlakový PIN** otevíral relaci se skutečnou identitou, frází i přístupem ke
|
||||
smazání účtu. Teď je izolovaná: náhodná identita, žádná síť, žádný zásah do
|
||||
klíčů; smazání v ní nechá skutečný účet být.
|
||||
- Zvonění z budíčku bez skutečné nabídky končí po 12 s; server budíčky hovoru
|
||||
omezuje na cíl, takže cizí člověk se znalostí adresy telefon nerozezvoní
|
||||
pořád dokola.
|
||||
- Dodatečné odeslání z fronty neztrácí anketu ani zmínky; budíček při
|
||||
„připojeno“ bez provozu obnoví spojení.
|
||||
|
||||
### Bezpečnost
|
||||
- Hádání PINu: po pěti chybách prodleva 30 s, dál dvojnásobná až 15 minut.
|
||||
- Nátlakový a skutečný PIN se ověřují jednou derivací — čas odemčení neprozradí,
|
||||
který byl zadán.
|
||||
- Biometrie jen biometricky (zámek telefonu appku neodemkne); zabezpečení
|
||||
obrazovky je výchozí zapnuté a po odemčení skutečně platí; na iOS klíče
|
||||
nepřecházejí do iCloud Keychain ani záloh; filtr proti dotykům přes překryvná okna.
|
||||
- Server: doplňování jednorázových klíčů a rotace podepsaných klíčů
|
||||
(`POST /v1/keys`), omezení výdeje jednorázových klíčů na adresu, chyby v logu
|
||||
bez adres push endpointů, připnutí DNS u budíčků, globální strop příloh,
|
||||
limity délek, `/v1/ready`, uvolnění spojení při vypnutí, revize a zdroj
|
||||
v `/v1/server-info`.
|
||||
|
||||
## [1.2.0] – 2026-10-01
|
||||
|
||||
### Nový vzhled
|
||||
- **Domovská obrazovka s kartami Chaty, Kontakty a Nastavení.** Velký
|
||||
nadpis, hledání v chatech i v obsahu zpráv (najde „klic“ i „klíč“),
|
||||
filtry Vše / Nepřečtené / Skupiny, připnuté chaty nahoře.
|
||||
- **Konverzace s hloubkou.** Průsvitná hlavička a pole pro psaní nad jemnou
|
||||
tapetou, zprávy od stejného člověka se skládají do bloků, ve skupině
|
||||
avatar a jméno autora v jeho barvě.
|
||||
- **Odpověď tahem doprava**, řádek „N nových zpráv“ na místě, kde jsi
|
||||
skončil, a tlačítko na nejnovější zprávy s počtem nových.
|
||||
- **Hledání v konverzaci** se zvýrazněním a přeskakováním mezi výsledky;
|
||||
klepnutí na citaci skočí na původní zprávu.
|
||||
- **Profil kontaktu a skupiny** po klepnutí na jméno nebo avatar: ověření,
|
||||
sdílené fotky, společné skupiny, mizící zprávy, ztlumení, blokování;
|
||||
u skupiny členové, přidání lidí, přejmenování a odchod.
|
||||
- **Fotky přes celou obrazovku s listováním** všemi fotkami konverzace.
|
||||
- **Každý člověk má vlastní barvu avataru** — všude stejnou, i po
|
||||
přejmenování.
|
||||
- **„Přečteno“ je poznat na první pohled** — plná pilulka místo
|
||||
modrošedých fajfek, které na korálové bublině skoro nebyly vidět.
|
||||
|
||||
### Přidáno
|
||||
- **Hovory a videohovory** 1:1, šifrované end-to-end. Tlačítka v hlavičce
|
||||
chatu; telefon zazvoní i se zavřenou aplikací (na iPhonu přes CallKit).
|
||||
Když přímé spojení nejde, hovor šifrovaně přepošle náš server.
|
||||
V chatu zůstane záznam „Hovor · 3:12“ nebo „Zmeškaný hovor“.
|
||||
- **Rozepsaná zpráva zůstane.** Když odejdeš z chatu, text v poli
|
||||
počká, a v seznamu chatů je vidět jako „Koncept: …“.
|
||||
- **Přeposlání** zprávy, fotky, souboru i hlasovky do jiného chatu nebo
|
||||
skupiny (podržet zprávu → Přeposlat).
|
||||
- **Poznámky pro sebe** v kartě Kontakty: chat sám se sebou na odkazy,
|
||||
fotky a poznámky. Zůstává jen v telefonu, šifrovaně, nic nejde na server.
|
||||
- **@zmínky ve skupinách.** Po napsání „@“ se nabídnou členové; zmíněný
|
||||
dostane upozornění, i když má skupinu ztlumenou.
|
||||
- **Rychlejší přehrávání hlasovek** — 1,5× a 2×, rychlost si Šepot pamatuje.
|
||||
- **Správci skupin.** Zakladatel je správce a může udělat správcem
|
||||
i další členy. Odebrat člena, měnit správce a přejmenovat skupinu smí
|
||||
jen správce; každou takovou změnu podepíše svým klíčem a ostatní telefony
|
||||
podpis ověří, takže ji nikdo nemůže podvrhnout ani za správce vydávat.
|
||||
U členů je odznak „Správce“, ostatní vidí akce neaktivní s vysvětlením.
|
||||
Dokud má někdo ve skupině starší verzi Šepotu, přidávat lidi smí dál
|
||||
každý, aby se skupina nerozdělila; se staršími verzemi skupiny fungují
|
||||
jako dosud. Existující skupiny se převedou samy, bez jediné zprávy.
|
||||
- **Profilová fotka.** V Nastavení klepni na svůj avatar. Fotka se ořízne,
|
||||
zmenší a zbaví metadat a tvým kontaktům dorazí šifrovaně s další
|
||||
zprávou; u nich se ukáže všude místo iniciál.
|
||||
- **Ankety** v chatu i ve skupině (plus → Anketa): jedna nebo víc
|
||||
odpovědí, pruhy výsledků a kdo jak hlasoval, hlas jde změnit i zrušit.
|
||||
Starší verze Šepotu uvidí otázku a možnosti jako text.
|
||||
- **„píše…“** v hlavičce chatu, v seznamu chatů a jako tři tečky pod
|
||||
poslední zprávou. Stejně jako potvrzení přečtení je volitelné (výchozí
|
||||
vypnuto) a oboustranné. Offline protistraně se nic neposílá do fronty ani
|
||||
ji to nebudí — server takové oznámení doručí jen živému spojení.
|
||||
- **Čas doručení a přečtení.** Pod poslední odeslanou zprávou je vidět, kdy
|
||||
dorazila a kdy ji druhá strana přečetla. Po podržení zprávy ukáže „Info
|
||||
o zprávě“ čas odeslání, doručení a přečtení — ve skupině u každého člena
|
||||
zvlášť. Čas přečtení se ukazuje jen lidem, kteří potvrzení přečtení
|
||||
sdílejí oboustranně (výchozí vypnuto).
|
||||
- **Přidání lidí do skupiny.** Do existující skupiny jde nově přidat
|
||||
kontakty z nabídky skupiny nebo ze seznamu členů.
|
||||
|
||||
### Změněno
|
||||
- **Notifikace jsou na Androidu zapnuté od začátku.** Bez nich systém
|
||||
Šepot na pozadí uspí a zprávy čekají, dokud aplikaci neotevřeš. Kdo má
|
||||
UnifiedPush distributor (např. ntfy), dostane ho; ostatním se zapne
|
||||
budíček přes Google (FCM). Google přitom vidí jen to, že má telefon
|
||||
probudit — ne obsah ani odesílatele. Přepnout či vypnout jde v Nastavení.
|
||||
- **Dvojitá fajfka ve skupině** svítí až ve chvíli, kdy zpráva dorazila
|
||||
všem členům, ne jen prvnímu.
|
||||
|
||||
### Opraveno
|
||||
- **Ztlumená skupina dál posílala upozornění.** Ztlumení se u skupinové
|
||||
zprávy kontrolovalo v chatu s autorem, ne ve skupině.
|
||||
- **Odesílané fotky nesly polohu (GPS) a údaje o telefonu.** Zpracování
|
||||
fotky před odesláním metadata EXIF nečekaně zachovávalo — v odeslané
|
||||
fotce i v jejím náhledu. Nově se před odesláním vždy odstraní.
|
||||
- **Zablokovaný kontakt se mohl sám odblokovat**, když změnil adresu pro
|
||||
doručování.
|
||||
- **„Přečteno“ se už nevrací na „doručeno“.** Když potvrzení o doručení
|
||||
dorazilo po potvrzení přečtení (typicky po výpadku spojení), zpráva se
|
||||
tvářila jako nepřečtená.
|
||||
- **Zpráva označená jako neodeslaná** se po doručení sama opraví na
|
||||
doručenou.
|
||||
- **Nastavení ukazovalo starou verzi aplikace.** Číslo verze bylo v kódu
|
||||
napsané ručně a při vydání 1.1.1 a 1.1.2 se ho zapomnělo přepsat, takže
|
||||
se všude hlásila 1.1.0 — včetně hlášení zneužití, u kterých pak nešlo
|
||||
dohledat, ze které verze přišla. Verze se nově čte z nainstalovaného
|
||||
balíčku a k hlášení se přikládá i číslo buildu.
|
||||
|
||||
## [1.1.2] – 2026-08-13
|
||||
|
||||
### Opraveno
|
||||
- **Zprávy už nečekají na otevření aplikace.** Když mobilní síť tiše
|
||||
zahodila nečinné spojení, Šepot o tom nevěděl: tvářil se připojeně, ale
|
||||
odeslaná zpráva skončila chybou a příchozí nedorazily — a všechny se pak
|
||||
vysypaly naráz po restartu aplikace. Spojení se teď hlídá pravidelným
|
||||
pingem na obou stranách, takže výpadek se pozná a obnoví v řádu sekund.
|
||||
- **Neodeslaná zpráva se pošle sama.** Dosud zůstala označená jako
|
||||
neodeslaná (u příloh dokonce natrvalo na „odesílá se") a poslat znovu ji
|
||||
musel člověk ručně. Nově se po obnovení spojení odešle sama; opakované
|
||||
doručení téže zprávy druhá strana pozná a nezobrazí ji dvakrát.
|
||||
- **Zprávy, které nešly doručit, se přestaly hromadit.** Když se cestou
|
||||
ztratilo potvrzení o doručení, obálka na serveru zůstala a znovu a znovu
|
||||
se posílala, aniž by ji šlo přečíst. Server teď takové zprávy po několika
|
||||
marných pokusech zahodí, místo aby jimi zdržoval doručování ostatních.
|
||||
|
||||
### Přidáno
|
||||
- **Upozornění, když nechodí notifikace.** Bez nich Android aplikaci na
|
||||
pozadí uspí a zprávy uvidíš, až ji otevřeš. Šepot to teď řekne rovnou v
|
||||
seznamu chatů a nabídne nastavení; hlášku jde odmítnout. Selhání
|
||||
registrace u distributora se už nezamlčí.
|
||||
|
||||
## [1.1.1] – 2026-07-18
|
||||
|
||||
### Opraveno
|
||||
- **Odeslání první zprávy hned po instalaci už neselže.** Krátce po
|
||||
založení účtu, než se aplikace stihla připojit k relayi, mohla zpráva
|
||||
spadnout s hláškou „Odeslání selhalo". Nově se v tu chvíli ukáže
|
||||
srozumitelné „Šepot se ještě připojuje — zkus to za chvíli" a připojení
|
||||
proběhne na pozadí.
|
||||
- **QR kód a odkaz se ukážou až s doručovací adresou.** Když sis chtěl
|
||||
ukázat vlastní QR dřív, než se aplikace poprvé připojila, vygeneroval se
|
||||
neúplný odkaz — protistrana pak nemohla poslat první zprávu („Kontakt
|
||||
nemá doručovací adresu"). Nově se místo neúplného kódu ukáže stav
|
||||
připojování a QR se objeví, jakmile je hotové.
|
||||
- **Neúplný odkaz kontaktu se odmítne při přidání**, ne až při prvním
|
||||
odeslání — s jasnou výzvou požádat protistranu o nový odkaz.
|
||||
- **Zamčení po nečinnosti už neumlčí aplikaci.** Po automatickém zamknutí
|
||||
a opětovném odemčení se zprávy zase přijímají i odesílají — dřív mohl
|
||||
messaging zůstat po odemčení vypnutý až do restartu aplikace.
|
||||
- **Skupinová zpráva se při chybě neodeslání označí jako neodeslaná**
|
||||
místo aby zůstala navždy ve stavu „odesílá se".
|
||||
- Ošetřeno vzácné zdvojení spojení s relayem při rychlém přepnutí Toru
|
||||
nebo zamčení během připojování.
|
||||
|
||||
### Změněno
|
||||
- Interní úklid závislostí notifikací (odstraněna nepoužívaná větev, která
|
||||
komplikovala sestavení verze pro iOS).
|
||||
|
||||
## [1.1.0] – 2026-07-13
|
||||
|
||||
### Přidáno
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
The design goal is **metadata minimization with a public threat model** — never "zero metadata". The relay is deliberately dumb: it cannot read message content, does not learn who sent a message, and does not log IP addresses. What it *does* see is documented honestly below.
|
||||
|
||||
> Status: pre-1.0. The Android client is the primary target; iOS builds run through TestFlight. See [`CHANGELOG.md`](CHANGELOG.md).
|
||||
> Status: 1.2.x. Android is on Google Play (production 1.1.2, 1.2.0 in internal testing); iOS builds run through TestFlight. See [`CHANGELOG.md`](CHANGELOG.md).
|
||||
|
||||
## Features
|
||||
|
||||
@@ -14,7 +14,7 @@ The design goal is **metadata minimization with a public threat model** — neve
|
||||
- **Group chats** — up to 128 members, pairwise fan-out so every member gets full forward secrecy; the relay never learns a group exists.
|
||||
- **Attachments** — images and files are encrypted client-side (AES-256-GCM) and uploaded as opaque blobs with a TTL; image metadata (EXIF/GPS) is stripped on send.
|
||||
- **Disappearing messages**, read receipts (off by default), voice messages, replies, reactions, edits, contact verification (safety numbers).
|
||||
- **Push without Google by default** — UnifiedPush/ntfy; FCM (Android) and APNs (iOS) are opt-in. A push wake-up never carries content, sender, or count.
|
||||
- **Push with or without Google** — UnifiedPush/ntfy when a distributor is installed, otherwise FCM on Android (default since 1.2.0, can be switched off) and APNs on iOS. A push wake-up never carries content, sender, or count.
|
||||
- **Tor transport** (Android, via Orbot) — routes relay traffic over a `.onion` to hide the client IP. Off by default.
|
||||
- **Five languages** — Czech, English, German, Russian, Italian. The UI follows the system language and can be overridden in Settings.
|
||||
|
||||
@@ -71,10 +71,10 @@ UI strings live in `app/lib/l10n/app_*.arb` (`cs`, `en`, `de`, `ru`, `it`) and a
|
||||
|
||||
## Security
|
||||
|
||||
Cryptography is implemented by Signal's `libsignal`, not by hand-rolled primitives. If you find a vulnerability, please report it privately to **bezpecnost@sepot.cz** rather than opening a public issue.
|
||||
Cryptography is implemented by Signal's `libsignal`, not by hand-rolled primitives. If you find a vulnerability, please report it privately to **security@sepot.cz** rather than opening a public issue — see [`SECURITY.md`](SECURITY.md) and [`/.well-known/security.txt`](https://sepot.cz/.well-known/security.txt).
|
||||
|
||||
## License
|
||||
|
||||
Šepot is licensed under the **GNU Affero General Public License v3.0** — see [`LICENSE`](LICENSE).
|
||||
|
||||
The license includes an **AGPL §7 additional permission for app-store distribution**: Šepot may be distributed through App Store / Google Play provided the complete corresponding source stays available under the AGPL through a channel *without* those store restrictions (this repository, F-Droid, direct APK). That permission covers only Šepot's own code. The bundled Signal `libsignal` is AGPL-3.0 held by Signal Messenger, LLC — its app-store status is documented in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).
|
||||
The license includes an **AGPL §7 additional permission for app-store distribution**: Šepot may be distributed through App Store / Google Play provided the complete corresponding source stays available under the AGPL through a channel *without* those store restrictions (this repository and the direct APK releases). That permission covers only Šepot's own code. The bundled Signal `libsignal` is AGPL-3.0 held by Signal Messenger, LLC — its app-store status is documented in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report security issues privately — do not open a public issue.
|
||||
|
||||
- E-mail: **security@sepot.cz**
|
||||
- Web form and policy: <https://sepot.cz/zodpovedne-hlaseni>
|
||||
- Machine-readable contact: <https://sepot.cz/.well-known/security.txt>
|
||||
|
||||
You will get an acknowledgement within 72 hours and a status update at least
|
||||
every 14 days until the issue is resolved. We aim to ship a fix within 30 days
|
||||
for issues that affect message confidentiality, authentication, or the relay's
|
||||
metadata guarantees, and we credit reporters in the changelog unless they
|
||||
prefer otherwise.
|
||||
|
||||
## Scope
|
||||
|
||||
- the Flutter client (`app/`), including the Signal protocol integration,
|
||||
local storage (SQLCipher, PIN/biometrics, duress session) and calls;
|
||||
- the relay (`rust/sepot-relay/`): authentication, routing, push wake-ups,
|
||||
attachments, rate limiting, TURN credentials;
|
||||
- the public relay at `relay.sepot.cz` and the website `sepot.cz`.
|
||||
|
||||
Please do not run denial-of-service tests, mass registrations or scans
|
||||
against the public relay; a local instance (`deploy/relay/`) is enough to
|
||||
reproduce almost everything.
|
||||
|
||||
## What the relay can and cannot see
|
||||
|
||||
The threat model is documented in the README: the relay sees routing tokens,
|
||||
message sizes, timing and push tokens, never content or sender identity.
|
||||
Reports that demonstrate the relay learning more than that are the ones we
|
||||
care about most.
|
||||
@@ -26,7 +26,7 @@ their app-store status is documented in detail below.
|
||||
|
||||
Šepot relies on that stated position and, as an AGPL work, keeps its
|
||||
complete corresponding source available under the AGPL through channels
|
||||
without app-store restrictions (this public repository, F-Droid, direct
|
||||
without app-store restrictions (this public repository, direct
|
||||
APK). A written clarification for the current Rust `libsignal` has been
|
||||
requested from Signal (tracking: <https://github.com/signalapp/libsignal/issues/684>).
|
||||
|
||||
|
||||
@@ -42,6 +42,14 @@ android {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
// Lint na release buildu vynechán: jeho síťové kontroly (novější verze
|
||||
// závislostí) na omezené build síti visí donekonečna a pro Flutter appku
|
||||
// nemají přínos. Analýzu Dartu dělá `flutter analyze`, Kotlin je minimální.
|
||||
lint {
|
||||
checkReleaseBuilds = false
|
||||
abortOnError = false
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "cz.sepot.sepot"
|
||||
// libsignal binárky vyžadují Android 7.0+ (API 24)
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Nahrávání hlasových zpráv -->
|
||||
<!-- Nahrávání hlasových zpráv a hovory -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- Hovory (WebRTC): kamera u videohovoru, přepínání reproduktoru
|
||||
a Bluetooth sluchátka, sledování změny sítě během hovoru. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<!-- Firebase Messaging tiše přidává AD_ID oprávnění; Šepot inzertní ID
|
||||
nepoužívá (žádné reklamy/tracking) → odebíráme, ať sedí Play deklarace. -->
|
||||
<uses-permission android:name="com.google.android.gms.permission.AD_ID"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cz.sepot.sepot
|
||||
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
@@ -7,10 +8,20 @@ import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací
|
||||
// Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací.
|
||||
// Výchozí stav; Dart přepínač „Zabezpečení obrazovky" ho může po
|
||||
// odemčení vypnout (default zapnuto, jako u Signalu).
|
||||
// Debug build: screenshoty povolené kvůli QA na emulátoru (adb screencap
|
||||
// by u FLAG_SECURE okna selhal); release vždy chráněný.
|
||||
val debuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||
if (!debuggable) {
|
||||
window.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
)
|
||||
}
|
||||
// Tapjacking: dotyky přes překryvné okno jiné appky (částečně
|
||||
// průhledný overlay nad PIN klávesnicí) se zahodí.
|
||||
window.decorView.filterTouchesWhenObscured = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import CallKit
|
||||
import Flutter
|
||||
import PushKit
|
||||
import UIKit
|
||||
import WebRTC
|
||||
import flutter_callkit_incoming
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate {
|
||||
// PushKit registry musí žít po celou dobu běhu appky, jinak VoIP pushe nechodí.
|
||||
private var voipRegistry: PKPushRegistry?
|
||||
private var apnsChannel: FlutterMethodChannel?
|
||||
// Token dorazí asynchronně z APNs; když si o něj Dart řekne dřív, podržíme ho.
|
||||
private var pendingToken: String?
|
||||
@@ -29,13 +35,100 @@ import UIKit
|
||||
apnsChannel = channel
|
||||
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
|
||||
// Hovory: VoIP push z relaye → CallKit.
|
||||
let registry = PKPushRegistry(queue: DispatchQueue.main)
|
||||
registry.delegate = self
|
||||
registry.desiredPushTypes = [.voIP]
|
||||
voipRegistry = registry
|
||||
|
||||
// Zvukovou relaci u hovoru řídí CallKit; WebRTC ji smí zapnout až po
|
||||
// jeho aktivaci (didActivateAudioSession), jinak hovor z uzamčené
|
||||
// obrazovky nemá zvuk.
|
||||
RTCAudioSession.sharedInstance().useManualAudio = true
|
||||
RTCAudioSession.sharedInstance().isAudioEnabled = false
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
// MARK: - PushKit (VoIP)
|
||||
|
||||
func pushRegistry(_ registry: PKPushRegistry, didUpdate credentials: PKPushCredentials, for type: PKPushType) {
|
||||
let token = credentials.token.map { String(format: "%02x", $0) }.joined()
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(token)
|
||||
}
|
||||
|
||||
func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("")
|
||||
}
|
||||
|
||||
// iOS vyžaduje, aby každý VoIP push HNED skončil hovorem v CallKitu — jinak
|
||||
// appku ukončí a po opakování VoIP pushe přestane doručovat. Relay neví,
|
||||
// kdo volá (sealed sender), proto zvoní „Šepot“; Dart zvonění spáruje
|
||||
// s nabídkou podle `extra.push`, až ji po připojení dešifruje.
|
||||
func pushRegistry(
|
||||
_ registry: PKPushRegistry,
|
||||
didReceiveIncomingPushWith payload: PKPushPayload,
|
||||
for type: PKPushType,
|
||||
completion: @escaping () -> Void
|
||||
) {
|
||||
guard type == .voIP else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
let data = flutter_callkit_incoming.Data(
|
||||
id: UUID().uuidString, nameCaller: "Šepot", handle: "Šepot", type: 0)
|
||||
data.appName = "Šepot"
|
||||
data.duration = 45000
|
||||
data.extra = ["push": true]
|
||||
data.handleType = "generic"
|
||||
data.supportsVideo = true
|
||||
data.maximumCallGroups = 1
|
||||
data.maximumCallsPerCallGroup = 1
|
||||
data.supportsHolding = false
|
||||
data.supportsDTMF = false
|
||||
data.includesCallsInRecents = false
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.showCallkitIncoming(data, fromPushKit: true) {
|
||||
completion()
|
||||
}
|
||||
// Běží-li Dart, ať se hned připojí a vyzvedne nabídku z fronty.
|
||||
apnsChannel?.invokeMethod("onWake", arguments: nil)
|
||||
}
|
||||
|
||||
// MARK: - CallKit
|
||||
// Rozhodnutí (přijmout/odmítnout) řeší Dart přes události pluginu;
|
||||
// tady jen potvrdit akci CallKitu, jinak by ji systém po čase zrušil.
|
||||
|
||||
func onAccept(_ call: Call, _ action: CXAnswerCallAction) {
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
func onDecline(_ call: Call, _ action: CXEndCallAction) {
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
func onEnd(_ call: Call, _ action: CXEndCallAction) {
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
func onTimeOut(_ call: Call) {}
|
||||
|
||||
func didActivateAudioSession(_ audioSession: AVAudioSession) {
|
||||
RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
|
||||
RTCAudioSession.sharedInstance().isAudioEnabled = true
|
||||
}
|
||||
|
||||
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {
|
||||
RTCAudioSession.sharedInstance().audioSessionDidDeactivate(audioSession)
|
||||
RTCAudioSession.sharedInstance().isAudioEnabled = false
|
||||
}
|
||||
|
||||
func providerDidReset() {}
|
||||
|
||||
// APNs vydal device token → předáme Dartu (ten ho pošle relayi).
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Foundation.Data
|
||||
) {
|
||||
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
|
||||
pendingToken = token
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
<string>audio</string>
|
||||
<string>voip</string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>The camera is used to scan contacts' QR codes and to take photos for attachments.</string>
|
||||
<string>The camera is used to scan contacts' QR codes, take photos for attachments and for video calls.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>The photo library is used to pick photos to send in a conversation.</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
@@ -41,7 +43,7 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<true/>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
@@ -66,6 +68,6 @@
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Šepot needs the microphone to record voice messages.</string>
|
||||
<string>Šepot needs the microphone for voice messages and calls.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Šepot — texty oprávnění iOS (čeština) */
|
||||
"NSCameraUsageDescription" = "Kamera slouží ke skenování QR kódů kontaktů a focení příloh.";
|
||||
"NSCameraUsageDescription" = "Kamera slouží ke skenování QR kódů kontaktů, focení příloh a videohovorům.";
|
||||
"NSPhotoLibraryUsageDescription" = "Galerie slouží k výběru fotek pro odeslání v konverzaci.";
|
||||
"NSFaceIDUsageDescription" = "Face ID odemyká aplikaci místo PINu.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot potřebuje mikrofon pro nahrávání hlasových zpráv.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot potřebuje mikrofon pro hlasové zprávy a hovory.";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Šepot — iOS-Berechtigungstexte (Deutsch) */
|
||||
"NSCameraUsageDescription" = "Die Kamera dient zum Scannen der QR-Codes deiner Kontakte und zum Aufnehmen von Fotos für Anhänge.";
|
||||
"NSCameraUsageDescription" = "Die Kamera dient zum Scannen der QR-Codes deiner Kontakte, zum Aufnehmen von Fotos für Anhänge und für Videoanrufe.";
|
||||
"NSPhotoLibraryUsageDescription" = "Die Fotomediathek dient zum Auswählen von Fotos, die du in einer Unterhaltung sendest.";
|
||||
"NSFaceIDUsageDescription" = "Face ID entsperrt die App anstelle der PIN.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot benötigt das Mikrofon zum Aufnehmen von Sprachnachrichten.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot benötigt das Mikrofon für Sprachnachrichten und Anrufe.";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Šepot — iOS permission strings (English) */
|
||||
"NSCameraUsageDescription" = "The camera is used to scan contacts' QR codes and to take photos for attachments.";
|
||||
"NSCameraUsageDescription" = "The camera is used to scan contacts' QR codes, take photos for attachments and for video calls.";
|
||||
"NSPhotoLibraryUsageDescription" = "The photo library is used to pick photos to send in a conversation.";
|
||||
"NSFaceIDUsageDescription" = "Face ID unlocks the app instead of your PIN.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot needs the microphone to record voice messages.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot needs the microphone for voice messages and calls.";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Šepot — testi delle autorizzazioni iOS (italiano) */
|
||||
"NSCameraUsageDescription" = "La fotocamera serve a scansionare i codici QR dei contatti e a scattare foto per gli allegati.";
|
||||
"NSCameraUsageDescription" = "La fotocamera serve a scansionare i codici QR dei contatti, a scattare foto per gli allegati e per le videochiamate.";
|
||||
"NSPhotoLibraryUsageDescription" = "La libreria foto serve a scegliere le foto da inviare in una conversazione.";
|
||||
"NSFaceIDUsageDescription" = "Face ID sblocca l'app al posto del PIN.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot ha bisogno del microfono per registrare i messaggi vocali.";
|
||||
"NSMicrophoneUsageDescription" = "Šepot ha bisogno del microfono per i messaggi vocali e le chiamate.";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Šepot — тексты разрешений iOS (русский) */
|
||||
"NSCameraUsageDescription" = "Камера используется для сканирования QR-кодов контактов и съёмки фотографий для вложений.";
|
||||
"NSCameraUsageDescription" = "Камера используется для сканирования QR-кодов контактов, съёмки фотографий для вложений и видеозвонков.";
|
||||
"NSPhotoLibraryUsageDescription" = "Галерея используется для выбора фотографий, которые вы отправляете в чате.";
|
||||
"NSFaceIDUsageDescription" = "Face ID разблокирует приложение вместо PIN-кода.";
|
||||
"NSMicrophoneUsageDescription" = "Для записи голосовых сообщений приложению Šepot нужен доступ к микрофону.";
|
||||
"NSMicrophoneUsageDescription" = "Приложению Šepot нужен доступ к микрофону для голосовых сообщений и звонков.";
|
||||
|
||||
+46
-2
@@ -5,7 +5,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/core/calls/call_controller.dart';
|
||||
import 'package:sepot/features/auth/change_pin_screen.dart';
|
||||
import 'package:sepot/features/calls/call_screen.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/features/onboarding/onboarding_screen.dart';
|
||||
import 'package:sepot/features/onboarding/restore_screen.dart';
|
||||
@@ -17,6 +19,8 @@ import 'package:sepot/features/conversation/conversation_screen.dart';
|
||||
import 'package:sepot/features/contacts/contact_verify_screen.dart';
|
||||
import 'package:sepot/features/contacts/contacts_screen.dart';
|
||||
import 'package:sepot/features/groups/create_group_screen.dart';
|
||||
import 'package:sepot/features/profile/contact_profile_screen.dart';
|
||||
import 'package:sepot/features/profile/group_profile_screen.dart';
|
||||
import 'package:sepot/features/settings/settings_screen.dart';
|
||||
import 'package:sepot/features/safety/report_screen.dart';
|
||||
import 'package:sepot/features/qr_pairing/qr_share_screen.dart';
|
||||
@@ -62,6 +66,21 @@ final _router = GoRouter(
|
||||
path: '/conversation/:contactId',
|
||||
builder: (context, state) => ConversationScreen(
|
||||
contactId: state.pathParameters['contactId']!,
|
||||
startSearch: state.uri.queryParameters['search'] == '1',
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/contact/:contactId',
|
||||
builder: (context, state) => ContactProfileScreen(
|
||||
contactId: state.pathParameters['contactId']!,
|
||||
fromChat: state.uri.queryParameters['from'] == 'chat',
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/group/:groupId',
|
||||
builder: (context, state) => GroupProfileScreen(
|
||||
groupId: state.pathParameters['groupId']!,
|
||||
fromChat: state.uri.queryParameters['from'] == 'chat',
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
@@ -72,6 +91,11 @@ final _router = GoRouter(
|
||||
path: '/create-group',
|
||||
builder: (context, state) => const CreateGroupScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/group/:groupId/add',
|
||||
builder: (context, state) =>
|
||||
CreateGroupScreen(addToGroupId: state.pathParameters['groupId']),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/verify/:contactId',
|
||||
builder: (context, state) => ContactVerifyScreen(
|
||||
@@ -101,6 +125,10 @@ final _router = GoRouter(
|
||||
path: '/change-pin',
|
||||
builder: (context, state) => const ChangePinScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/call',
|
||||
builder: (context, state) => const CallScreen(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -116,6 +144,16 @@ class SepotApp extends ConsumerWidget {
|
||||
// null = systémový jazyk; než se volba načte z keystore, jede systém.
|
||||
final locale = ref.watch(localeProvider).valueOrNull;
|
||||
|
||||
// Příchozí i odchozí hovor otevře obrazovku hovoru odkudkoli.
|
||||
ref.listen(callProvider.select((s) => s.phase), (previous, phase) {
|
||||
final starting =
|
||||
phase == CallPhase.incoming || phase == CallPhase.outgoing;
|
||||
final path = _router.routerDelegate.currentConfiguration.uri.path;
|
||||
if (starting && previous != phase && path != '/call') {
|
||||
_router.push('/call');
|
||||
}
|
||||
});
|
||||
|
||||
return _AutoLockGate(
|
||||
child: MaterialApp.router(
|
||||
onGenerateTitle: (context) => context.l10n.appTitle,
|
||||
@@ -196,8 +234,12 @@ class _AutoLockGateState extends ConsumerState<_AutoLockGate>
|
||||
final kde = _router.state.uri.path;
|
||||
if (_bezZamku.contains(kde)) return;
|
||||
|
||||
// Zastavit službu a zamknout DB. messagingStartProvider ZÁMĚRNĚ
|
||||
// neinvalidujeme tady — invalidace by ho eager-rebuildla ještě nad
|
||||
// odemčeným storage (těsně před lock) a messaging by nastartoval nad DB,
|
||||
// kterou hned zavíráme. Restart řeší odemykací obrazovky přes
|
||||
// ref.invalidate(messagingStartProvider) až po úspěšném unlocku.
|
||||
await ref.read(messagingServiceProvider).stop();
|
||||
ref.invalidate(messagingStartProvider);
|
||||
storage.lock();
|
||||
_router.go('/pin-lock');
|
||||
}
|
||||
@@ -247,7 +289,9 @@ class _LoadingScreenState extends ConsumerState<_LoadingScreen> {
|
||||
final storage = ref.read(storageProvider);
|
||||
final legacyPin = await storage.hasLegacyPin();
|
||||
await storage.unlockWithoutPin();
|
||||
if (mounted) context.go(legacyPin ? '/pin-setup' : '/chats');
|
||||
if (!mounted) return;
|
||||
ref.invalidate(screenSecurityProvider);
|
||||
context.go(legacyPin ? '/pin-setup' : '/chats');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/// Verze aplikace čtená z nainstalovaného balíčku.
|
||||
///
|
||||
/// Proč ne konstanta: verze se dřív psala ručně do `AppConstants` a musela se
|
||||
/// hlídat proti `version:` v pubspec.yaml. Nehlídala se — produkční 1.1.2+22
|
||||
/// posílala v hlášeních zneužití „1.1.0" a podle hlášení tak nešlo dohledat,
|
||||
/// ze kterého buildu přišlo. `package_info_plus` čte `versionName` (Android)
|
||||
/// / `CFBundleShortVersionString` (iOS), které Flutter generuje přímo
|
||||
/// z pubspecu — rozejít se to už nemůže a ruční editace odpadá.
|
||||
library;
|
||||
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
class AppInfo {
|
||||
AppInfo._();
|
||||
|
||||
/// Zástupný text, dokud verzi nezná (nebo když ji nešlo přečíst). Znak, ne
|
||||
/// slovo — appka běží v pěti jazycích a české „neznámá" by v nich čouhalo.
|
||||
static const String unknownVersion = '—';
|
||||
|
||||
static PackageInfo? _info;
|
||||
|
||||
/// Načte verzi z balíčku. Volat jednou při startu, před prvním `runApp`.
|
||||
static Future<void> init() async {
|
||||
_info = await PackageInfo.fromPlatform();
|
||||
}
|
||||
|
||||
/// Verze pro uživatele: `1.1.2`.
|
||||
static String get version => _info?.version ?? unknownVersion;
|
||||
|
||||
/// Verze pro diagnostiku: `1.1.2+22`. Build číslo je jediné, čím se hlášení
|
||||
/// zneužití přiřadí konkrétnímu buildu — do UI nepatří, do hlášení ano.
|
||||
/// `null`, dokud verzi nezná: hlášení pak pole vynechá, ať server dostane
|
||||
/// prázdno místo vymyšlené hodnoty.
|
||||
static String? get versionWithBuild {
|
||||
final info = _info;
|
||||
if (info == null) return null;
|
||||
// iOS bez vyplněného CFBundleVersion vrací v buildNumber rovnou verzi
|
||||
// (dokumentované chování balíčku) — nelepit „1.1.2+1.1.2".
|
||||
if (info.buildNumber.isEmpty || info.buildNumber == info.version) {
|
||||
return info.version;
|
||||
}
|
||||
return '${info.version}+${info.buildNumber}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' hide MessageType;
|
||||
import 'package:sepot/core/calls/call_ringer.dart';
|
||||
import 'package:sepot/models/call_log.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/push_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Fáze hovoru.
|
||||
enum CallPhase { idle, outgoing, incoming, connecting, active, ended }
|
||||
|
||||
class CallState {
|
||||
const CallState({
|
||||
this.phase = CallPhase.idle,
|
||||
this.callId,
|
||||
this.contactId,
|
||||
this.contactName = '',
|
||||
this.video = false,
|
||||
this.outgoing = false,
|
||||
this.connectedAt,
|
||||
this.muted = false,
|
||||
this.speaker = false,
|
||||
this.cameraOff = false,
|
||||
this.outcome,
|
||||
});
|
||||
|
||||
final CallPhase phase;
|
||||
final String? callId;
|
||||
final String? contactId;
|
||||
final String contactName;
|
||||
final bool video;
|
||||
final bool outgoing;
|
||||
final DateTime? connectedAt;
|
||||
final bool muted;
|
||||
final bool speaker;
|
||||
final bool cameraOff;
|
||||
|
||||
/// Proč hovor skončil (jen ve fázi [CallPhase.ended]).
|
||||
final CallOutcome? outcome;
|
||||
|
||||
bool get busy => phase != CallPhase.idle && phase != CallPhase.ended;
|
||||
|
||||
CallState copyWith({
|
||||
CallPhase? phase,
|
||||
DateTime? connectedAt,
|
||||
bool? muted,
|
||||
bool? speaker,
|
||||
bool? cameraOff,
|
||||
CallOutcome? outcome,
|
||||
}) =>
|
||||
CallState(
|
||||
phase: phase ?? this.phase,
|
||||
callId: callId,
|
||||
contactId: contactId,
|
||||
contactName: contactName,
|
||||
video: video,
|
||||
outgoing: outgoing,
|
||||
connectedAt: connectedAt ?? this.connectedAt,
|
||||
muted: muted ?? this.muted,
|
||||
speaker: speaker ?? this.speaker,
|
||||
cameraOff: cameraOff ?? this.cameraOff,
|
||||
outcome: outcome ?? this.outcome,
|
||||
);
|
||||
}
|
||||
|
||||
/// Řídí jeden hovor 1:1: WebRTC spojení, signalizaci přes E2E kanál
|
||||
/// Šepotu a záznam výsledku do chatu. Druhý souběžný hovor se odmítne
|
||||
/// jako `busy`.
|
||||
class CallController extends Notifier<CallState> {
|
||||
/// Nabídka starší než tohle už nezvoní — jen se zapíše jako zmeškaná.
|
||||
/// Stejná mez jako expirace VoIP pushe na relayi.
|
||||
static const ringTimeout = Duration(seconds: 45);
|
||||
|
||||
/// ICE bez trickle: nabídka jde až po sběru kandidátů, nejdéle po této
|
||||
/// době (jinak by pomalé rozhraní zdrželo zvonění).
|
||||
static const _iceGatherTimeout = Duration(seconds: 3);
|
||||
|
||||
/// Jak dlouho je vidět obrazovka „Hovor skončil“, než se zavře.
|
||||
static const endedLinger = Duration(seconds: 2);
|
||||
|
||||
RTCPeerConnection? _pc;
|
||||
MediaStream? _localStream;
|
||||
String? _pendingOfferSdp;
|
||||
Contact? _peer;
|
||||
Timer? _ringTimer;
|
||||
|
||||
/// Id systémového zvonění (CallKit / Android) k běžícímu příchozímu
|
||||
/// hovoru; null = zvoní jen obrazovka appky.
|
||||
String? _ringId;
|
||||
|
||||
final localRenderer = RTCVideoRenderer();
|
||||
final remoteRenderer = RTCVideoRenderer();
|
||||
bool _renderersReady = false;
|
||||
|
||||
@override
|
||||
CallState build() {
|
||||
CallRinger.listen(
|
||||
onAccept: _onRingAccept,
|
||||
onDecline: _onRingDecline,
|
||||
onEnded: _onRingEnded,
|
||||
onVoipToken: () => ref.read(pushServiceProvider).refreshVoipToken(),
|
||||
);
|
||||
ref.onDispose(() {
|
||||
unawaited(_cleanup());
|
||||
localRenderer.dispose();
|
||||
remoteRenderer.dispose();
|
||||
});
|
||||
return const CallState();
|
||||
}
|
||||
|
||||
// ── Odchozí ────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> startCall(Contact contact, {required bool video}) async {
|
||||
if (state.busy) return;
|
||||
_peer = contact;
|
||||
state = CallState(
|
||||
phase: CallPhase.outgoing,
|
||||
callId: const Uuid().v4(),
|
||||
contactId: contact.id,
|
||||
contactName: contact.displayName,
|
||||
video: video,
|
||||
outgoing: true,
|
||||
speaker: video,
|
||||
);
|
||||
try {
|
||||
await _openMedia(video: video);
|
||||
final pc = await _createPeerConnection();
|
||||
final offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
final sdp = await _gatheredSdp(pc);
|
||||
await ref.read(messagingServiceProvider).sendCallSignal(
|
||||
contact,
|
||||
{'cid': state.callId, 'k': 'offer', 'sdp': sdp, 'vid': video},
|
||||
ring: true,
|
||||
);
|
||||
_ringTimer = Timer(ringTimeout, () {
|
||||
if (state.phase == CallPhase.outgoing) {
|
||||
unawaited(_hangup(CallOutcome.unanswered, notify: true));
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
await _finish(CallOutcome.failed);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Příchozí ───────────────────────────────────────────────────────
|
||||
|
||||
/// Obsluha příchozí signalizace z [MessagingService.onCallSignal].
|
||||
Future<void> onSignal(Contact contact, Map<String, dynamic> signal) async {
|
||||
final cid = signal['cid'];
|
||||
final kind = signal['k'];
|
||||
if (cid is! String || kind is! String) return;
|
||||
|
||||
if (kind == 'offer') {
|
||||
await _onOffer(contact, cid, signal);
|
||||
return;
|
||||
}
|
||||
// Ostatní patří jen k právě běžícímu hovoru s tímtéž člověkem.
|
||||
if (cid != state.callId || contact.id != state.contactId) return;
|
||||
switch (kind) {
|
||||
case 'answer':
|
||||
final sdp = signal['sdp'];
|
||||
if (sdp is! String || state.phase != CallPhase.outgoing) return;
|
||||
_ringTimer?.cancel();
|
||||
state = state.copyWith(phase: CallPhase.connecting);
|
||||
await _pc?.setRemoteDescription(RTCSessionDescription(sdp, 'answer'));
|
||||
case 'hangup':
|
||||
final answered = state.connectedAt != null;
|
||||
await _finish(answered
|
||||
? CallOutcome.completed
|
||||
: state.outgoing
|
||||
? CallOutcome.rejected
|
||||
: CallOutcome.missed);
|
||||
case 'reject':
|
||||
await _finish(CallOutcome.rejected);
|
||||
case 'busy':
|
||||
await _finish(CallOutcome.busy);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onOffer(
|
||||
Contact contact, String cid, Map<String, dynamic> signal) async {
|
||||
final sdp = signal['sdp'];
|
||||
if (sdp is! String) return;
|
||||
final video = signal['vid'] == true;
|
||||
final sentAt = DateTime.fromMillisecondsSinceEpoch(
|
||||
(signal['ts'] as num?)?.toInt() ?? 0);
|
||||
// Pozdě doručená nabídka (telefon byl mimo signál) — nezvonit.
|
||||
if (DateTime.now().difference(sentAt) > ringTimeout) {
|
||||
await _log(contact.id,
|
||||
CallLog(outcome: CallOutcome.missed, outgoing: false, video: video),
|
||||
at: sentAt);
|
||||
return;
|
||||
}
|
||||
if (state.busy) {
|
||||
if (cid != state.callId) {
|
||||
await ref
|
||||
.read(messagingServiceProvider)
|
||||
.sendCallSignal(contact, {'cid': cid, 'k': 'busy'});
|
||||
}
|
||||
return;
|
||||
}
|
||||
_peer = contact;
|
||||
_pendingOfferSdp = sdp;
|
||||
state = CallState(
|
||||
phase: CallPhase.incoming,
|
||||
callId: cid,
|
||||
contactId: contact.id,
|
||||
contactName: contact.displayName,
|
||||
video: video,
|
||||
speaker: video,
|
||||
);
|
||||
_ringTimer = Timer(ringTimeout, () {
|
||||
if (state.phase == CallPhase.incoming) {
|
||||
unawaited(_finish(CallOutcome.missed));
|
||||
}
|
||||
});
|
||||
await _attachSystemRing(cid, contact.displayName, video);
|
||||
}
|
||||
|
||||
/// Spáruje hovor se systémovým zvoněním. Zvonění z budíčku relaye už
|
||||
/// běží (možná ho uživatel mezitím přijal na zamčené obrazovce); jinak
|
||||
/// se zvonění spustí, jen když appka není v popředí — v popředí stačí
|
||||
/// vlastní obrazovka hovoru.
|
||||
Future<void> _attachSystemRing(String cid, String name, bool video) async {
|
||||
final pushRing = await CallRinger.pendingPushRing();
|
||||
if (pushRing != null) {
|
||||
_ringId = pushRing.id;
|
||||
if (pushRing.accepted) {
|
||||
await accept();
|
||||
return;
|
||||
}
|
||||
if (CallRinger.declinedEarly.remove(pushRing.id)) {
|
||||
await reject();
|
||||
return;
|
||||
}
|
||||
// Spárováno s nabídkou: aby grace z `ringFromPush` zvonění neukončila,
|
||||
// nahradí se jménem volajícího (bez značky push).
|
||||
await _claimPushRing(pushRing.id, name, video);
|
||||
return;
|
||||
}
|
||||
final foreground =
|
||||
WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed;
|
||||
if (!foreground) {
|
||||
_ringId = cid;
|
||||
await CallRinger.ringFor(callId: cid, name: name, video: video);
|
||||
}
|
||||
}
|
||||
|
||||
/// Převezme zvonění z budíčku: jméno volajícího místo „Šepot" a bez
|
||||
/// značky push (grace v [CallRinger.ringFromPush] ho pak nechá být).
|
||||
Future<void> _claimPushRing(String pushId, String name, bool video) async {
|
||||
if (await CallRinger.isAccepted(pushId)) return; // uživatel už zvedl
|
||||
await CallRinger.end(pushId);
|
||||
_ringId = state.callId;
|
||||
await CallRinger.ringFor(callId: state.callId!, name: name, video: video);
|
||||
}
|
||||
|
||||
void _onRingAccept(String id) {
|
||||
if (id == _ringId && state.phase == CallPhase.incoming) {
|
||||
unawaited(accept());
|
||||
}
|
||||
}
|
||||
|
||||
void _onRingDecline(String id) {
|
||||
if (id == _ringId && state.phase == CallPhase.incoming) {
|
||||
unawaited(reject());
|
||||
} else if (!state.busy) {
|
||||
CallRinger.declinedEarly.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Zavěšení ze systémového rozhraní (zamčená obrazovka, sluchátka).
|
||||
void _onRingEnded(String id) {
|
||||
if (id == _ringId && state.busy) unawaited(hangUp());
|
||||
}
|
||||
|
||||
Future<void> accept() async {
|
||||
final peer = _peer;
|
||||
final offer = _pendingOfferSdp;
|
||||
if (state.phase != CallPhase.incoming || peer == null || offer == null) {
|
||||
return;
|
||||
}
|
||||
_ringTimer?.cancel();
|
||||
state = state.copyWith(phase: CallPhase.connecting);
|
||||
try {
|
||||
await _openMedia(video: state.video);
|
||||
final pc = await _createPeerConnection();
|
||||
await pc.setRemoteDescription(RTCSessionDescription(offer, 'offer'));
|
||||
final answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
final sdp = await _gatheredSdp(pc);
|
||||
await ref.read(messagingServiceProvider).sendCallSignal(
|
||||
peer, {'cid': state.callId, 'k': 'answer', 'sdp': sdp});
|
||||
} catch (_) {
|
||||
await _hangup(CallOutcome.failed, notify: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reject() async {
|
||||
if (state.phase != CallPhase.incoming) return;
|
||||
await _hangup(CallOutcome.rejected, notify: true, kind: 'reject');
|
||||
}
|
||||
|
||||
/// Zavěšení v jakékoli fázi (vytáčení, spojování, hovor).
|
||||
Future<void> hangUp() async {
|
||||
if (!state.busy) return;
|
||||
final outcome = state.connectedAt != null
|
||||
? CallOutcome.completed
|
||||
: state.outgoing
|
||||
? CallOutcome.unanswered
|
||||
: CallOutcome.rejected;
|
||||
await _hangup(outcome, notify: true);
|
||||
}
|
||||
|
||||
// ── Ovládání během hovoru ──────────────────────────────────────────
|
||||
|
||||
void toggleMute() {
|
||||
final muted = !state.muted;
|
||||
for (final t in _localStream?.getAudioTracks() ?? const []) {
|
||||
t.enabled = !muted;
|
||||
}
|
||||
state = state.copyWith(muted: muted);
|
||||
}
|
||||
|
||||
Future<void> toggleSpeaker() async {
|
||||
final speaker = !state.speaker;
|
||||
await Helper.setSpeakerphoneOn(speaker);
|
||||
state = state.copyWith(speaker: speaker);
|
||||
}
|
||||
|
||||
void toggleCamera() {
|
||||
final off = !state.cameraOff;
|
||||
for (final t in _localStream?.getVideoTracks() ?? const []) {
|
||||
t.enabled = !off;
|
||||
}
|
||||
state = state.copyWith(cameraOff: off);
|
||||
}
|
||||
|
||||
Future<void> switchCamera() async {
|
||||
final track = _localStream?.getVideoTracks().firstOrNull;
|
||||
if (track != null) await Helper.switchCamera(track);
|
||||
}
|
||||
|
||||
// ── WebRTC ─────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _openMedia({required bool video}) async {
|
||||
if (!_renderersReady) {
|
||||
await localRenderer.initialize();
|
||||
await remoteRenderer.initialize();
|
||||
_renderersReady = true;
|
||||
}
|
||||
final stream = await navigator.mediaDevices.getUserMedia({
|
||||
'audio': true,
|
||||
'video': video ? {'facingMode': 'user'} : false,
|
||||
});
|
||||
_localStream = stream;
|
||||
localRenderer.srcObject = video ? stream : null;
|
||||
await Helper.setSpeakerphoneOn(video);
|
||||
}
|
||||
|
||||
Future<RTCPeerConnection> _createPeerConnection() async {
|
||||
final pc = await createPeerConnection({
|
||||
'iceServers': await _iceServers(),
|
||||
'sdpSemantics': 'unified-plan',
|
||||
});
|
||||
final local = _localStream;
|
||||
for (final track in local?.getTracks() ?? const <MediaStreamTrack>[]) {
|
||||
await pc.addTrack(track, local!);
|
||||
}
|
||||
pc.onTrack = (event) {
|
||||
if (event.streams.isNotEmpty) {
|
||||
remoteRenderer.srcObject = event.streams.first;
|
||||
}
|
||||
};
|
||||
pc.onConnectionState = (s) {
|
||||
switch (s) {
|
||||
case RTCPeerConnectionState.RTCPeerConnectionStateConnected:
|
||||
if (state.connectedAt == null) {
|
||||
state = state.copyWith(
|
||||
phase: CallPhase.active, connectedAt: DateTime.now());
|
||||
final ring = _ringId;
|
||||
if (ring != null) unawaited(CallRinger.connected(ring));
|
||||
}
|
||||
case RTCPeerConnectionState.RTCPeerConnectionStateFailed:
|
||||
unawaited(_hangup(
|
||||
state.connectedAt != null
|
||||
? CallOutcome.completed
|
||||
: CallOutcome.failed,
|
||||
notify: true));
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
_pc = pc;
|
||||
return pc;
|
||||
}
|
||||
|
||||
/// STUN bez údajů: coturn na témž hostiteli odpovídá na STUN bez
|
||||
/// přihlášení, takže i když relay TURN údaje nevydá (503, timeout),
|
||||
/// jde aspoň přímé spojení přes NAT. Dřív byl seznam prázdný = jen host
|
||||
/// kandidáti = hovor jen v jedné LAN.
|
||||
static const _stunFallback = {
|
||||
'urls': ['stun:turn.sepot.cz:3478'],
|
||||
};
|
||||
|
||||
/// STUN i TURN běží na stejném serveru; bez údajů z relaye aspoň STUN
|
||||
/// (přímé spojení, když obě strany nejsou za symetrickým NATem).
|
||||
Future<List<Map<String, dynamic>>> _iceServers() async {
|
||||
final turn = await ref.read(messagingServiceProvider).turnCredentials();
|
||||
if (turn == null || turn.urls.isEmpty) return const [_stunFallback];
|
||||
return [
|
||||
{
|
||||
'urls': turn.urls,
|
||||
'username': turn.username,
|
||||
'credential': turn.credential,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/// SDP po sběru ICE kandidátů (non-trickle).
|
||||
Future<String> _gatheredSdp(RTCPeerConnection pc) async {
|
||||
final done = Completer<void>();
|
||||
pc.onIceGatheringState = (s) {
|
||||
if (s == RTCIceGatheringState.RTCIceGatheringStateComplete &&
|
||||
!done.isCompleted) {
|
||||
done.complete();
|
||||
}
|
||||
};
|
||||
if (pc.iceGatheringState ==
|
||||
RTCIceGatheringState.RTCIceGatheringStateComplete) {
|
||||
done.complete();
|
||||
}
|
||||
await done.future.timeout(_iceGatherTimeout, onTimeout: () {});
|
||||
final local = await pc.getLocalDescription();
|
||||
final sdp = local?.sdp;
|
||||
if (sdp == null) throw StateError('no local description');
|
||||
return sdp;
|
||||
}
|
||||
|
||||
// ── Konec hovoru ───────────────────────────────────────────────────
|
||||
|
||||
Future<void> _hangup(CallOutcome outcome,
|
||||
{required bool notify, String kind = 'hangup'}) async {
|
||||
final peer = _peer;
|
||||
final cid = state.callId;
|
||||
if (notify && peer != null && cid != null) {
|
||||
try {
|
||||
await ref
|
||||
.read(messagingServiceProvider)
|
||||
.sendCallSignal(peer, {'cid': cid, 'k': kind});
|
||||
} catch (_) {
|
||||
// Protistrana zavěšení nedostala — její hovor skončí sám, až
|
||||
// spojení vypadne (ICE failed) nebo vyprší zvonění.
|
||||
}
|
||||
}
|
||||
await _finish(outcome);
|
||||
}
|
||||
|
||||
Future<void> _finish(CallOutcome outcome) async {
|
||||
if (state.phase == CallPhase.idle || state.phase == CallPhase.ended) {
|
||||
return;
|
||||
}
|
||||
final connectedAt = state.connectedAt;
|
||||
final contactId = state.contactId;
|
||||
final log = CallLog(
|
||||
outcome: outcome,
|
||||
outgoing: state.outgoing,
|
||||
video: state.video,
|
||||
durationSeconds: connectedAt == null
|
||||
? 0
|
||||
: DateTime.now().difference(connectedAt).inSeconds,
|
||||
);
|
||||
state = state.copyWith(phase: CallPhase.ended, outcome: outcome);
|
||||
await _cleanup();
|
||||
if (contactId != null) await _log(contactId, log);
|
||||
Timer(endedLinger, () {
|
||||
if (state.phase == CallPhase.ended) state = const CallState();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _cleanup() async {
|
||||
_ringTimer?.cancel();
|
||||
_ringTimer = null;
|
||||
final ring = _ringId;
|
||||
_ringId = null;
|
||||
// Konec zvonění vyvolá událost „ukončeno“ — to už je stav mimo hovor,
|
||||
// takže ji _onRingEnded ignoruje.
|
||||
if (ring != null) unawaited(CallRinger.end(ring));
|
||||
_pendingOfferSdp = null;
|
||||
_peer = null;
|
||||
for (final t in _localStream?.getTracks() ?? const <MediaStreamTrack>[]) {
|
||||
await t.stop();
|
||||
}
|
||||
await _localStream?.dispose();
|
||||
_localStream = null;
|
||||
await _pc?.close();
|
||||
_pc = null;
|
||||
if (_renderersReady) {
|
||||
localRenderer.srcObject = null;
|
||||
remoteRenderer.srcObject = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Řádek „Hovor · 3:12“ do chatu. Jen lokálně — nic se neposílá.
|
||||
Future<void> _log(String contactId, CallLog log, {DateTime? at}) async {
|
||||
final myId = ref.read(identityProvider).valueOrNull?.id;
|
||||
if (myId == null) return;
|
||||
final message = Message(
|
||||
id: const Uuid().v4(),
|
||||
senderId: log.outgoing ? myId : contactId,
|
||||
recipientId: log.outgoing ? contactId : myId,
|
||||
type: MessageType.text,
|
||||
timestamp: at ?? DateTime.now(),
|
||||
// Záznam hovoru není nepřečtená zpráva — kromě zmeškaného.
|
||||
isRead: log.outcome != CallOutcome.missed,
|
||||
isDelivered: true,
|
||||
callLog: log,
|
||||
);
|
||||
await ref.read(storageProvider).addMessage(contactId, message);
|
||||
ref.invalidate(conversationsProvider);
|
||||
ref.invalidate(conversationProvider(contactId));
|
||||
}
|
||||
}
|
||||
|
||||
final callProvider =
|
||||
NotifierProvider<CallController, CallState>(CallController.new);
|
||||
@@ -0,0 +1,191 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_callkit_incoming/entities/entities.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Systémové zvonění (CallKit na iOS, celoobrazovkové na Androidu) pro
|
||||
/// hovory, které přijdou, když appka není v popředí.
|
||||
///
|
||||
/// Budíček z relaye nenese, kdo volá — relay to neví. Zvonění z budíčku
|
||||
/// proto ukáže „Šepot“ a označí se `extra.push`; až appka dešifruje
|
||||
/// nabídku, [CallController] ho spáruje s hovorem podle toho.
|
||||
class CallRinger {
|
||||
CallRinger._();
|
||||
|
||||
static const _appName = 'Šepot';
|
||||
|
||||
/// Zvonění vzniklé z budíčku, ke kterému ještě nedorazila nabídka.
|
||||
static const pushMarker = 'push';
|
||||
|
||||
/// Jak dlouho zvoní — stejné jako expirace budíčku na relayi.
|
||||
static const ringMillis = 45000;
|
||||
|
||||
/// Zvonění z budíčku, ke kterému do té doby nedorazila nabídka, se
|
||||
/// ukončí samo. Relay budíček hovoru nijak neověřuje (sealed sender),
|
||||
/// takže kdokoli se znalostí adresy by jinak nechal telefon zvonit
|
||||
/// 45 s na každou obálku s hintem CALL. Nabídka od
|
||||
/// skutečného volajícího dorazí do pár sekund po připojení.
|
||||
static const pushRingGrace = Duration(seconds: 12);
|
||||
|
||||
static StreamSubscription<CallEvent?>? _sub;
|
||||
|
||||
/// Id zvonění, která uživatel odmítl dřív, než dorazila nabídka.
|
||||
/// (Přijetí si pamatuje samo zvonění — `isAccepted` v [pendingPushRing].)
|
||||
static final declinedEarly = <String>{};
|
||||
|
||||
static void listen({
|
||||
required void Function(String id) onAccept,
|
||||
required void Function(String id) onDecline,
|
||||
required void Function(String id) onEnded,
|
||||
required void Function() onVoipToken,
|
||||
}) {
|
||||
_sub ??= FlutterCallkitIncoming.onEvent.listen((event) {
|
||||
switch (event) {
|
||||
case CallEventActionCallAccept(:final callKitParams):
|
||||
onAccept(callKitParams.id);
|
||||
case CallEventActionCallDecline(:final callKitParams):
|
||||
onDecline(callKitParams.id);
|
||||
case CallEventActionCallEnded(:final callKitParams):
|
||||
onEnded(callKitParams.id);
|
||||
case CallEventActionDidUpdateDevicePushTokenVoip():
|
||||
onVoipToken();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static Future<String?> voipToken() async {
|
||||
final token = await FlutterCallkitIncoming.getDevicePushTokenVoIP();
|
||||
return token != null && token.isNotEmpty ? token : null;
|
||||
}
|
||||
|
||||
/// Zvonění pro dešifrovanou nabídku (appka běží na pozadí).
|
||||
static Future<void> ringFor({
|
||||
required String callId,
|
||||
required String name,
|
||||
required bool video,
|
||||
}) =>
|
||||
FlutterCallkitIncoming.showCallkitIncoming(
|
||||
_params(id: callId, name: name, video: video));
|
||||
|
||||
/// Zvonění z budíčku relaye — jméno ani typ hovoru ještě neznáme.
|
||||
/// Vrací až po [pushRingGrace]: pokud se zvonění do té doby nespárovalo
|
||||
/// s nabídkou (přijetí nebo ukončení ho z aktivních hovorů odebere, nebo
|
||||
/// ho [CallController] převezme), tiše se ukončí. Background isolate FCM
|
||||
/// handleru tím zůstane naživu přesně tak dlouho, jak je potřeba.
|
||||
static Future<void> ringFromPush() async {
|
||||
final id = const Uuid().v4();
|
||||
await FlutterCallkitIncoming.showCallkitIncoming(_params(
|
||||
id: id, name: _appName, video: false, extra: {pushMarker: true}));
|
||||
await Future<void>.delayed(pushRingGrace);
|
||||
await endIfUnmatched(id);
|
||||
}
|
||||
|
||||
/// iOS: zvonění z VoIP pushe zakládá nativní AppDelegate (CallKit musí
|
||||
/// zazvonit hned), takže grace z [ringFromPush] tam neběží. Po startu
|
||||
/// Dartu se proto každé nepřevzaté zvonění z budíčku ohlídá odsud.
|
||||
static Future<void> expireStalePushRings() async {
|
||||
final calls = await FlutterCallkitIncoming.activeCalls();
|
||||
for (final call in calls) {
|
||||
if (call.extra?[pushMarker] == true && !call.isAccepted) {
|
||||
final id = call.id;
|
||||
unawaited(Future<void>.delayed(pushRingGrace, () => endIfUnmatched(id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ukončí zvonění z budíčku, které nikdo nepřevzal (bez záznamu
|
||||
/// o zmeškaném hovoru — nebylo od koho).
|
||||
static Future<void> endIfUnmatched(String id) async {
|
||||
final calls = await FlutterCallkitIncoming.activeCalls();
|
||||
for (final call in calls) {
|
||||
if (call.id == id && call.extra?[pushMarker] == true && !call.isAccepted) {
|
||||
await FlutterCallkitIncoming.endCall(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zvonění z budíčku, které čeká na nabídku (nejnovější), a jestli ho
|
||||
/// uživatel už přijal — typicky na zamčené obrazovce, než appka naběhla.
|
||||
static Future<({String id, bool accepted})?> pendingPushRing() async {
|
||||
final calls = await FlutterCallkitIncoming.activeCalls();
|
||||
for (final call in calls.reversed) {
|
||||
if (call.extra?[pushMarker] == true) {
|
||||
return (id: call.id, accepted: call.isAccepted);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<bool> isAccepted(String id) async {
|
||||
final calls = await FlutterCallkitIncoming.activeCalls();
|
||||
for (final call in calls) {
|
||||
if (call.id == id) return call.isAccepted;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<void> connected(String id) =>
|
||||
FlutterCallkitIncoming.setCallConnected(id);
|
||||
|
||||
static Future<void> end(String id) => FlutterCallkitIncoming.endCall(id);
|
||||
|
||||
/// Texty zvonění. Zvonění z budíčku běží v izolátu na pozadí bez
|
||||
/// lokalizace appky, proto vlastní malý slovník podle jazyka systému.
|
||||
static const _texts = {
|
||||
'cs': ('Přijmout', 'Odmítnout', 'Příchozí hovory'),
|
||||
'de': ('Annehmen', 'Ablehnen', 'Eingehende Anrufe'),
|
||||
'it': ('Accetta', 'Rifiuta', 'Chiamate in arrivo'),
|
||||
'ru': ('Принять', 'Отклонить', 'Входящие звонки'),
|
||||
'en': ('Accept', 'Decline', 'Incoming calls'),
|
||||
};
|
||||
|
||||
static (String, String, String) get _localTexts =>
|
||||
_texts[PlatformDispatcher.instance.locale.languageCode] ??
|
||||
_texts['en']!;
|
||||
|
||||
static CallKitParams _params({
|
||||
required String id,
|
||||
required String name,
|
||||
required bool video,
|
||||
Map<String, dynamic>? extra,
|
||||
}) =>
|
||||
CallKitParams(
|
||||
id: id,
|
||||
nameCaller: name,
|
||||
appName: _appName,
|
||||
handle: _appName,
|
||||
type: video ? 1 : 0,
|
||||
duration: ringMillis,
|
||||
extra: extra ?? const {},
|
||||
missedCallNotification: const NotificationParams(
|
||||
showNotification: false,
|
||||
),
|
||||
android: AndroidParams(
|
||||
isCustomNotification: true,
|
||||
isShowLogo: false,
|
||||
ringtonePath: 'system_ringtone_default',
|
||||
backgroundColor: '#0B0C12',
|
||||
actionColor: '#7FAE8C',
|
||||
textColor: '#F0EEE8',
|
||||
incomingCallNotificationChannelName: _localTexts.$3,
|
||||
textAccept: _localTexts.$1,
|
||||
textDecline: _localTexts.$2,
|
||||
isShowFullLockedScreen: true,
|
||||
),
|
||||
ios: const IOSParams(
|
||||
handleType: 'generic',
|
||||
supportsVideo: true,
|
||||
maximumCallGroups: 1,
|
||||
maximumCallsPerCallGroup: 1,
|
||||
supportsDTMF: false,
|
||||
supportsHolding: false,
|
||||
supportsGrouping: false,
|
||||
supportsUngrouping: false,
|
||||
ringtonePath: 'system_ringtone_default',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,12 @@ class AppConstants {
|
||||
AppConstants._();
|
||||
|
||||
static const String appName = 'Šepot';
|
||||
/// Musí sedět s `version:` v pubspec.yaml (bez build čísla).
|
||||
static const String appVersion = '1.1.0';
|
||||
static const String appAuthor = 'ajtak.it';
|
||||
|
||||
/// Id konverzace „Poznámky pro sebe“. Nekoliduje s fingerprintem (hex)
|
||||
/// ani s gid skupiny (UUID); podle něj se pozná, že nic nejde do sítě.
|
||||
static const String notesConversationId = 'notes';
|
||||
|
||||
// Relay server (REST + WS na jednom portu). Přepsatelné při buildu:
|
||||
// flutter build --dart-define=SEPOT_RELAY=http://<dev-relay-host>:8470
|
||||
static const String relayUrl = String.fromEnvironment(
|
||||
|
||||
@@ -53,16 +53,20 @@ class DekWrap {
|
||||
int memoryKiB = defaultMemoryKiB,
|
||||
int iterations = defaultIterations,
|
||||
int parallelism = defaultParallelism,
|
||||
Uint8List? salt,
|
||||
}) async {
|
||||
if (dek.length != 32) {
|
||||
throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes');
|
||||
}
|
||||
_enforceFloor(memoryKiB, iterations);
|
||||
|
||||
final salt = _randomBytes(16);
|
||||
// Volitelná sdílená sůl: nátlakový blob se stejnou solí a parametry
|
||||
// jako skutečný odemyká jedinou derivací (viz unwrapFirstMatching) —
|
||||
// bez ní by decoy PIN trval 2× déle a šel poznat stopkami.
|
||||
final useSalt = salt ?? _randomBytes(16);
|
||||
final unlockKey = await _deriveUnlockKey(
|
||||
pin: pin,
|
||||
salt: salt,
|
||||
salt: useSalt,
|
||||
memoryKiB: memoryKiB,
|
||||
iterations: iterations,
|
||||
parallelism: parallelism,
|
||||
@@ -80,13 +84,72 @@ class DekWrap {
|
||||
'$memoryKiB',
|
||||
'$iterations',
|
||||
'$parallelism',
|
||||
base64Encode(salt),
|
||||
base64Encode(useSalt),
|
||||
base64Encode(box.nonce),
|
||||
base64Encode(box.cipherText),
|
||||
base64Encode(box.mac.bytes),
|
||||
].join(':');
|
||||
}
|
||||
|
||||
/// Sůl použitá v zabaleném blobu (pro sdílení s nátlakovým blobem).
|
||||
static Uint8List saltOf(String wrapped) {
|
||||
final parts = wrapped.split(':');
|
||||
if (parts.length != 8 || parts[0] != _version) {
|
||||
throw FormatException('Unknown wrapped DEK format', wrapped);
|
||||
}
|
||||
return Uint8List.fromList(base64Decode(parts[4]));
|
||||
}
|
||||
|
||||
/// Zkusí PIN proti několika blobům (skutečný + nátlakový) a vrátí index
|
||||
/// prvního, který sedí, s rozbaleným DEK. Argon2id se počítá jen jednou
|
||||
/// pro každou odlišnou kombinaci parametrů a soli, takže u blobů se
|
||||
/// sdílenou solí stojí odemčení stejně, ať sedí kterýkoli — čas
|
||||
/// odemčení neprozradí, který PIN byl zadán. Nic nesedí →
|
||||
/// [WrongPinException].
|
||||
static Future<(int, Uint8List)> unwrapFirstMatching({
|
||||
required List<String> wrapped,
|
||||
required String pin,
|
||||
}) async {
|
||||
final parsed = <List<String>>[];
|
||||
for (final w in wrapped) {
|
||||
final parts = w.split(':');
|
||||
if (parts.length != 8 || parts[0] != _version) {
|
||||
throw FormatException('Unknown wrapped DEK format', w);
|
||||
}
|
||||
_enforceFloor(int.parse(parts[1]), int.parse(parts[2]));
|
||||
parsed.add(parts);
|
||||
}
|
||||
final keys = <String, Uint8List>{};
|
||||
for (final parts in parsed) {
|
||||
final group = parts.sublist(1, 5).join(':');
|
||||
keys[group] ??= await _deriveUnlockKey(
|
||||
pin: pin,
|
||||
salt: base64Decode(parts[4]),
|
||||
memoryKiB: int.parse(parts[1]),
|
||||
iterations: int.parse(parts[2]),
|
||||
parallelism: int.parse(parts[3]),
|
||||
);
|
||||
}
|
||||
final aes = AesGcm.with256bits();
|
||||
for (var i = 0; i < parsed.length; i++) {
|
||||
final parts = parsed[i];
|
||||
try {
|
||||
final dek = await aes.decrypt(
|
||||
SecretBox(
|
||||
base64Decode(parts[6]),
|
||||
nonce: base64Decode(parts[5]),
|
||||
mac: Mac(base64Decode(parts[7])),
|
||||
),
|
||||
secretKey: SecretKey(keys[parts.sublist(1, 5).join(':')]!),
|
||||
);
|
||||
return (i, Uint8List.fromList(dek));
|
||||
} on SecretBoxAuthenticationError {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw const WrongPinException();
|
||||
}
|
||||
|
||||
/// Rozbal DEK. Na špatném PINu (nebo poškozeném blobu) vyhodí
|
||||
/// [WrongPinException]; na neplatném formátu [FormatException].
|
||||
static Future<Uint8List> unwrap({
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/// Admin-signed membership pro skupiny.
|
||||
///
|
||||
/// Původní skupiny nemají role: roster update přijímají od kteréhokoli člena. V2 přidává
|
||||
/// admin/member role a KAŽDOU změnu členství/role/jména podepisuje admin svým
|
||||
/// identity klíčem (XEdDSA). Ostatní klienti podpis ověří proti identity klíči
|
||||
/// admina z rosteru — server tu roli nehraje (sealed sender, hloupý relay).
|
||||
///
|
||||
/// Podpis pokrývá VÝSLEDNÝ STAV (setříděné množiny členů a adminů + jméno +
|
||||
/// epocha) zřetězený na hash předchozího stavu (`prev`) — admin autorizuje
|
||||
/// konkrétní výsledek navázaný na známou historii, ne jen delta. Tím padá
|
||||
/// nejednoznačnost i fork: kdo nesedí na `prev`, ví, že se rozešel a musí
|
||||
/// resync.
|
||||
///
|
||||
/// Kanonická serializace je záměrně délkově-prefixovaná: přesun bajtů mezi poli
|
||||
/// (fingerprint ↔ jméno apod.) MUSÍ změnit podepisované bajty, jinak by šlo
|
||||
/// zaměnit význam podpisu.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:libsignal/libsignal.dart' show PublicKey;
|
||||
import 'package:pointycastle/digests/sha256.dart';
|
||||
|
||||
/// Typ admin operace. Pořadí (index) je součást podpisu — NIKDY nepřehazovat,
|
||||
/// jen přidávat na konec (jinak se rozbijí staré podpisy).
|
||||
///
|
||||
/// `sync` není změna: správce jím podepisuje svůj aktuální stav jako odpověď
|
||||
/// na žádost o resync (mezera v epoše / rozchod historie u příjemce).
|
||||
enum GroupAdminAction { add, remove, promote, demote, rename, sync }
|
||||
|
||||
/// Doménové separátory — jiný kontext = jiné podepisované bajty.
|
||||
const String _opDomain = 'sepot-group-adm-v1';
|
||||
const String _stateDomain = 'sepot-group-state-v1';
|
||||
|
||||
/// Jedna admin operace = autorizovaný přechod skupiny do výsledného stavu.
|
||||
class GroupAdminOp {
|
||||
GroupAdminOp({
|
||||
required this.gid,
|
||||
required this.epoch,
|
||||
required this.action,
|
||||
required this.actor,
|
||||
required List<String> members,
|
||||
required List<String> admins,
|
||||
required this.name,
|
||||
required this.prev,
|
||||
}) : members = _sortedUnique(members),
|
||||
admins = _sortedUnique(admins);
|
||||
|
||||
/// UUID skupiny (`gid` na drátu).
|
||||
final String gid;
|
||||
|
||||
/// Epocha, kterou tato operace ustavuje (monotónní, prev + 1).
|
||||
final int epoch;
|
||||
|
||||
final GroupAdminAction action;
|
||||
|
||||
/// Fingerprint podepisujícího admina. MUSÍ se rovnat sealed-sender
|
||||
/// fingerprintu odesílatele (anti-spoof).
|
||||
final String actor;
|
||||
|
||||
/// Výsledný roster (setříděné fingerprinty, včetně nezměněných členů).
|
||||
final List<String> members;
|
||||
|
||||
/// Výsledná množina adminů (setříděné fingerprinty, podmnožina `members`).
|
||||
final List<String> admins;
|
||||
|
||||
/// Výsledné jméno skupiny.
|
||||
final String name;
|
||||
|
||||
/// Hash předchozího stavu (`stateHash` epochy `epoch-1`); prázdné pro
|
||||
/// založení (epoch 0).
|
||||
final Uint8List prev;
|
||||
|
||||
/// Podepisované bajty — kanonické, délkově-prefixované, deterministické.
|
||||
Uint8List canonicalBytes() {
|
||||
final b = BytesBuilder(copy: false);
|
||||
b.add(_lpStr(_opDomain));
|
||||
b.add(_lpStr(gid));
|
||||
b.add(_u64be(epoch));
|
||||
b.addByte(action.index);
|
||||
b.add(_lpStr(actor));
|
||||
b.add(_lpList(members));
|
||||
b.add(_lpList(admins));
|
||||
b.add(_lpStr(name));
|
||||
b.add(_lp(prev));
|
||||
return b.toBytes();
|
||||
}
|
||||
|
||||
/// Hash tohoto výsledného stavu — vstupuje jako `prev` do NÁSLEDUJÍCÍ
|
||||
/// operace (řetěz proti forku).
|
||||
Uint8List stateHash() => hashState(
|
||||
gid: gid,
|
||||
epoch: epoch,
|
||||
members: members,
|
||||
admins: admins,
|
||||
name: name,
|
||||
);
|
||||
|
||||
/// XEdDSA podpis operace daným signerem (typicky `SepotCrypto.sign`).
|
||||
Uint8List signWith(Uint8List Function(List<int> message) signer) =>
|
||||
signer(canonicalBytes());
|
||||
|
||||
/// Ověří podpis proti identity klíči admina (33 B serializovaný pub klíč).
|
||||
/// Volající NAVÍC musí ověřit, že `actor` je v aktuální množině adminů a že
|
||||
/// `actor` == sealed-sender fingerprint — to zde nevidíme.
|
||||
bool verify(Uint8List signature, Uint8List actorIdentityKey) {
|
||||
final pk = PublicKey.deserialize(bytes: actorIdentityKey.toList());
|
||||
return pk.verify(
|
||||
message: canonicalBytes().toList(),
|
||||
signature: signature.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash stavu skupiny (setříděné množiny) — sdílený mezi op.stateHash a
|
||||
/// výpočtem `prev` u příjemce.
|
||||
Uint8List hashState({
|
||||
required String gid,
|
||||
required int epoch,
|
||||
required List<String> members,
|
||||
required List<String> admins,
|
||||
required String name,
|
||||
}) {
|
||||
final b = BytesBuilder(copy: false);
|
||||
b.add(_lpStr(_stateDomain));
|
||||
b.add(_lpStr(gid));
|
||||
b.add(_u64be(epoch));
|
||||
b.add(_lpList(_sortedUnique(members)));
|
||||
b.add(_lpList(_sortedUnique(admins)));
|
||||
b.add(_lpStr(name));
|
||||
return Uint8List.fromList(SHA256Digest().process(b.toBytes()));
|
||||
}
|
||||
|
||||
// ── Kanonické kódování ────────────────────────────────────────────────────
|
||||
|
||||
List<String> _sortedUnique(List<String> xs) {
|
||||
final s = xs.toSet().toList()..sort();
|
||||
return s;
|
||||
}
|
||||
|
||||
Uint8List _lp(List<int> bytes) {
|
||||
final b = BytesBuilder(copy: false);
|
||||
b.add(_u32be(bytes.length));
|
||||
b.add(bytes);
|
||||
return b.toBytes();
|
||||
}
|
||||
|
||||
Uint8List _lpStr(String s) => _lp(utf8.encode(s));
|
||||
|
||||
Uint8List _lpList(List<String> items) {
|
||||
final b = BytesBuilder(copy: false);
|
||||
b.add(_u32be(items.length));
|
||||
for (final item in items) {
|
||||
b.add(_lpStr(item));
|
||||
}
|
||||
return b.toBytes();
|
||||
}
|
||||
|
||||
Uint8List _u32be(int n) {
|
||||
final d = ByteData(4)..setUint32(0, n, Endian.big);
|
||||
return d.buffer.asUint8List();
|
||||
}
|
||||
|
||||
Uint8List _u64be(int n) {
|
||||
final d = ByteData(8)..setUint64(0, n, Endian.big);
|
||||
return d.buffer.asUint8List();
|
||||
}
|
||||
@@ -81,6 +81,32 @@ class SepotSealedMessage {
|
||||
}
|
||||
|
||||
/// Jeden one-time prekey k publikaci na relay.
|
||||
/// Nový signed prekey k nahrání na relay (rotace).
|
||||
class SepotSignedPreKey {
|
||||
SepotSignedPreKey({
|
||||
required this.id,
|
||||
required this.publicKey,
|
||||
required this.signature,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final Uint8List publicKey;
|
||||
final Uint8List signature;
|
||||
}
|
||||
|
||||
/// Nový last-resort Kyber prekey k nahrání na relay.
|
||||
class SepotKyberPreKey {
|
||||
SepotKyberPreKey({
|
||||
required this.id,
|
||||
required this.publicKey,
|
||||
required this.signature,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final Uint8List publicKey;
|
||||
final Uint8List signature;
|
||||
}
|
||||
|
||||
class SepotOneTimePreKey {
|
||||
SepotOneTimePreKey({required this.id, required this.publicKey});
|
||||
|
||||
@@ -420,4 +446,64 @@ class SepotCrypto {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Nový signed prekey (rotace). Starý zůstává ve store — relace založené
|
||||
/// z bundle těsně před rotací ho ještě potřebují.
|
||||
Future<SepotSignedPreKey> generateSignedPreKey({
|
||||
required int id,
|
||||
required int timestampMs,
|
||||
}) async {
|
||||
final identityPrivate = PrivateKey.deserialize(
|
||||
bytes: _identity.privateKey.toList(),
|
||||
);
|
||||
final private = PrivateKey.generate();
|
||||
final public = private.getPublicKey();
|
||||
final signature = identityPrivate.sign(
|
||||
message: public.serialize().toList(),
|
||||
);
|
||||
await _stores.signedPreKey.storeSignedPreKey(
|
||||
id,
|
||||
SignedPreKeyRecord(
|
||||
id: id,
|
||||
publicKey: public,
|
||||
privateKey: private,
|
||||
signature: signature.toList(),
|
||||
timestamp: BigInt.from(timestampMs),
|
||||
),
|
||||
);
|
||||
return SepotSignedPreKey(
|
||||
id: id,
|
||||
publicKey: Uint8List.fromList(public.serialize().toList()),
|
||||
signature: Uint8List.fromList(signature.toList()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Nový last-resort Kyber prekey (rotace PQXDH kotvy).
|
||||
Future<SepotKyberPreKey> generateKyberPreKey({
|
||||
required int id,
|
||||
required int timestampMs,
|
||||
}) async {
|
||||
final identityPrivate = PrivateKey.deserialize(
|
||||
bytes: _identity.privateKey.toList(),
|
||||
);
|
||||
final keyPair = KyberKeyPair.generate();
|
||||
final public = keyPair.getPublicKey();
|
||||
final signature = identityPrivate.sign(
|
||||
message: public.serialize().toList(),
|
||||
);
|
||||
await _stores.kyberPreKey.storeKyberPreKey(
|
||||
id,
|
||||
KyberPreKeyRecord.create(
|
||||
id: id,
|
||||
keyPair: keyPair,
|
||||
signature: signature.toList(),
|
||||
timestamp: BigInt.from(timestampMs),
|
||||
),
|
||||
);
|
||||
return SepotKyberPreKey(
|
||||
id: id,
|
||||
publicKey: Uint8List.fromList(public.serialize().toList()),
|
||||
signature: Uint8List.fromList(signature.toList()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,24 @@ class RelayUnavailableError extends SepotUserError {
|
||||
String toString() => 'relay unavailable';
|
||||
}
|
||||
|
||||
/// Vlastní registrace na relay ještě nedoběhla — nemáme sender adresu.
|
||||
/// Přechodný stav po čerstvé instalaci; registrace běží na pozadí.
|
||||
class NotYetRegisteredError extends SepotUserError {
|
||||
const NotYetRegisteredError();
|
||||
|
||||
@override
|
||||
String toString() => 'not registered on relay yet';
|
||||
}
|
||||
|
||||
/// Odkaz/QR kontaktu je neúplný (chybí routing aid+da) — vygenerovaný
|
||||
/// dřív, než měla protistrana dokončenou registraci na relay.
|
||||
class ContactLinkIncompleteError extends SepotUserError {
|
||||
const ContactLinkIncompleteError();
|
||||
|
||||
@override
|
||||
String toString() => 'contact link is missing routing (incomplete)';
|
||||
}
|
||||
|
||||
/// Příloha přesahuje serverový limit.
|
||||
class AttachmentTooLargeError extends SepotUserError {
|
||||
const AttachmentTooLargeError(this.maxBytes);
|
||||
@@ -38,6 +56,30 @@ class AttachmentTooLargeError extends SepotUserError {
|
||||
String toString() => 'attachment too large (max $maxBytes bytes)';
|
||||
}
|
||||
|
||||
/// Příloha pro přeposlání už v telefonu není (smazaná, mizící, nestažená).
|
||||
class AttachmentUnavailableError extends SepotUserError {
|
||||
const AttachmentUnavailableError();
|
||||
|
||||
@override
|
||||
String toString() => 'attachment not available locally';
|
||||
}
|
||||
|
||||
/// Změnu skupiny smí udělat jen správce.
|
||||
class NotGroupAdminError extends SepotUserError {
|
||||
const NotGroupAdminError();
|
||||
|
||||
@override
|
||||
String toString() => 'only a group admin can do this';
|
||||
}
|
||||
|
||||
/// Poslední správce se nesmí odebrat ani zbavit role.
|
||||
class LastGroupAdminError extends SepotUserError {
|
||||
const LastGroupAdminError();
|
||||
|
||||
@override
|
||||
String toString() => 'the last group admin cannot step down';
|
||||
}
|
||||
|
||||
/// Skupina bez jediného dalšího člena nedává smysl.
|
||||
class GroupNeedsMemberError extends SepotUserError {
|
||||
const GroupNeedsMemberError();
|
||||
|
||||
@@ -11,9 +11,14 @@ import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||
String describeError(AppLocalizations l10n, Object error) => switch (error) {
|
||||
TrustRootChangedError() => l10n.errorTrustRootChanged,
|
||||
RelayUnavailableError() => l10n.errorRelayUnavailable,
|
||||
NotYetRegisteredError() => l10n.errorNotYetRegistered,
|
||||
ContactLinkIncompleteError() => l10n.errorContactLinkIncomplete,
|
||||
AttachmentTooLargeError(:final maxMb) =>
|
||||
l10n.errorAttachmentTooLarge(maxMb),
|
||||
AttachmentUnavailableError() => l10n.errorAttachmentUnavailable,
|
||||
GroupNeedsMemberError() => l10n.errorGroupNeedsMember,
|
||||
NotGroupAdminError() => l10n.errorGroupNotAdmin,
|
||||
LastGroupAdminError() => l10n.errorGroupLastAdmin,
|
||||
GroupTooLargeError(:final maxMembers) =>
|
||||
l10n.errorGroupMaxMembers(maxMembers),
|
||||
MemberNoDeliveryAddressError(:final displayName) =>
|
||||
|
||||
@@ -63,9 +63,18 @@ class RelayClient {
|
||||
StreamController<RelayConnectionState>.broadcast();
|
||||
final _deliverController = StreamController<pb.Envelope>.broadcast();
|
||||
final _receiptController = StreamController<pb.Receipt>.broadcast();
|
||||
final _turnController = StreamController<pb.TurnCredentials>.broadcast();
|
||||
final _prekeysLowController = StreamController<int>.broadcast();
|
||||
|
||||
var _state = RelayConnectionState.disconnected;
|
||||
|
||||
/// Kdy naposled přišel aplikační rámec od relaye.
|
||||
DateTime _lastFrameAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
static const _staleAfter = Duration(seconds: 20);
|
||||
|
||||
/// Relay hlásí docházející one-time prekeys (zbývající počet).
|
||||
Stream<int> get prekeysLow => _prekeysLowController.stream;
|
||||
|
||||
RelayConnectionState get state => _state;
|
||||
Stream<RelayConnectionState> get stateStream => _stateController.stream;
|
||||
|
||||
@@ -75,6 +84,9 @@ class RelayClient {
|
||||
|
||||
void _setState(RelayConnectionState s) {
|
||||
_state = s;
|
||||
// Po dispose() je controller zavřený — pozdní přechod z doběhnutého
|
||||
// _connect (zombie) by jinak hodil StateError do listenerů.
|
||||
if (_stateController.isClosed) return;
|
||||
_stateController.add(s);
|
||||
}
|
||||
|
||||
@@ -96,9 +108,18 @@ class RelayClient {
|
||||
required List<SepotOneTimePreKey> oneTimePreKeys,
|
||||
required Uint8List deliveryAddress,
|
||||
}) async {
|
||||
// Důkaz držení identity klíče: obnova ze 24 slov na novém telefonu
|
||||
// narazí na relayi na už existující identitu (409) a bez tohohle
|
||||
// podpisu by ji nikdy nepřevzala. Starší relay hlavičku
|
||||
// ignoruje.
|
||||
final proofTs = DateTime.now().millisecondsSinceEpoch;
|
||||
final proof = _crypto.sign(utf8.encode('POST /v1/register\n$proofTs'));
|
||||
final resp = await _transport.httpClient().post(
|
||||
Uri.parse('$baseUrl/v1/register'),
|
||||
headers: {'content-type': 'application/json'},
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-sepot-identity-proof': '$proofTs.${base64Encode(proof)}',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'identity_key': base64Encode(_crypto.identityPublicKey),
|
||||
'device_id': 1,
|
||||
@@ -140,6 +161,49 @@ class RelayClient {
|
||||
_crypto.bindAccount(accountId);
|
||||
}
|
||||
|
||||
/// POST /v1/keys — doplnění one-time prekeys a rotace signed / Kyber
|
||||
/// prekey. Vrací počet OTP na relayi po nahrání.
|
||||
Future<int> uploadKeys({
|
||||
SepotSignedPreKey? signedPreKey,
|
||||
SepotKyberPreKey? kyberPreKey,
|
||||
List<SepotOneTimePreKey> oneTimePreKeys = const [],
|
||||
}) async {
|
||||
final deviceUuid = _requireDevice();
|
||||
final ts = DateTime.now().millisecondsSinceEpoch;
|
||||
final signature = _crypto.sign(utf8.encode('POST /v1/keys\n$ts'));
|
||||
final resp = await _transport.httpClient().post(
|
||||
Uri.parse('$baseUrl/v1/keys'),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-sepot-auth': '$deviceUuid.$ts.${base64Encode(signature)}',
|
||||
},
|
||||
body: jsonEncode({
|
||||
if (signedPreKey != null)
|
||||
'signed_prekey': {
|
||||
'key_id': signedPreKey.id,
|
||||
'public_key': base64Encode(signedPreKey.publicKey),
|
||||
'signature': base64Encode(signedPreKey.signature),
|
||||
},
|
||||
if (kyberPreKey != null)
|
||||
'kyber_prekey': {
|
||||
'key_id': kyberPreKey.id,
|
||||
'public_key': base64Encode(kyberPreKey.publicKey),
|
||||
'signature': base64Encode(kyberPreKey.signature),
|
||||
'is_last_resort': true,
|
||||
},
|
||||
'one_time_prekeys': [
|
||||
for (final otp in oneTimePreKeys)
|
||||
{'key_id': otp.id, 'public_key': base64Encode(otp.publicKey)},
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (resp.statusCode != 200) {
|
||||
throw RelayException('prekey upload failed', resp.statusCode, resp.body);
|
||||
}
|
||||
final body = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
return (body['one_time_prekeys'] as num?)?.toInt() ?? 0;
|
||||
}
|
||||
|
||||
/// GET /v1/keys/{addr} — bundle protistrany (spotřebuje 1 OTP).
|
||||
Future<SepotPreKeyBundle> fetchBundle(Uint8List deliveryAddress) async {
|
||||
final hexAddr = deliveryAddress
|
||||
@@ -276,6 +340,20 @@ class RelayClient {
|
||||
_setState(RelayConnectionState.disconnected);
|
||||
}
|
||||
|
||||
/// Budíček od relaye znamená, že nás nevidí jako živé. Spojení, které
|
||||
/// hlásí „připojeno", ale [_staleAfter] nic nepřijalo, je po změně sítě
|
||||
/// nejspíš zombie — zavřít a navázat hned, ne až po ping timeoutu
|
||||
/// (pojistka proti zombie spojení na straně appky). Jinak jen [start].
|
||||
void kick() {
|
||||
if (_state == RelayConnectionState.connected &&
|
||||
DateTime.now().difference(_lastFrameAt) > _staleAfter) {
|
||||
_reconnectDelayS = 1;
|
||||
_onDisconnected();
|
||||
return;
|
||||
}
|
||||
start();
|
||||
}
|
||||
|
||||
Future<void> _teardownChannel() async {
|
||||
await _wsSub?.cancel();
|
||||
_wsSub = null;
|
||||
@@ -292,6 +370,14 @@ class RelayClient {
|
||||
try {
|
||||
final channel = _transport.connectWs(Uri.parse('$_wsUrl/v1/ws'));
|
||||
await channel.ready;
|
||||
// Během `ready` (přes Tor může viset dlouho) mohl přijít stop()/dispose()
|
||||
// — Tor toggle nebo auto-lock. Bez téhle kontroly by vzniklo zombie
|
||||
// autentizované spojení (relay má 2× jedno zařízení, DELIVERy padají do
|
||||
// zavřených controllerů). Nezakládat ho, jen zavřít.
|
||||
if (!_shouldRun) {
|
||||
await channel.sink.close();
|
||||
return;
|
||||
}
|
||||
_channel = channel;
|
||||
|
||||
// AUTH: výzva-odpověď podepsaná identity klíčem (XEdDSA)
|
||||
@@ -301,6 +387,7 @@ class RelayClient {
|
||||
_wsSub = channel.stream.listen(
|
||||
(data) {
|
||||
final frame = pb.ServerFrame.fromBuffer(data as List<int>);
|
||||
_lastFrameAt = DateTime.now();
|
||||
if (!authenticated) {
|
||||
if (frame.hasChallenge()) {
|
||||
final signature = _crypto.sign([
|
||||
@@ -322,10 +409,22 @@ class RelayClient {
|
||||
return;
|
||||
}
|
||||
if (frame.hasDeliver()) {
|
||||
if (!_deliverController.isClosed) {
|
||||
_deliverController.add(frame.deliver);
|
||||
}
|
||||
} else if (frame.hasReceipt()) {
|
||||
if (!_receiptController.isClosed) {
|
||||
_receiptController.add(frame.receipt);
|
||||
}
|
||||
} else if (frame.hasTurnCredentials()) {
|
||||
if (!_turnController.isClosed) {
|
||||
_turnController.add(frame.turnCredentials);
|
||||
}
|
||||
} else if (frame.hasPrekeysLow()) {
|
||||
if (!_prekeysLowController.isClosed) {
|
||||
_prekeysLowController.add(frame.prekeysLow.remaining);
|
||||
}
|
||||
}
|
||||
},
|
||||
onDone: _onDisconnected,
|
||||
onError: (Object _) => _onDisconnected(),
|
||||
@@ -358,27 +457,46 @@ class RelayClient {
|
||||
}
|
||||
|
||||
/// Odešle sealed-sender obálku. Vrací Receipt (STORED_OFFLINE /
|
||||
/// DELIVERED_ONLINE) spárovaný přes msg_uuid.
|
||||
/// DELIVERED_ONLINE, u EPHEMERAL i DROPPED) spárovaný přes msg_uuid.
|
||||
Future<pb.Receipt> sendEnvelope({
|
||||
required Uint8List dstAddress,
|
||||
required Uint8List payload,
|
||||
pb.ContentHint hint = pb.ContentHint.CONTENT_HINT_DEFAULT,
|
||||
Duration timeout = const Duration(seconds: 10),
|
||||
}) async {
|
||||
final msgUuid = randomBytes(16);
|
||||
final receiptFuture = _receiptController.stream
|
||||
.firstWhere((r) => _bytesEqual(r.msgUuid, msgUuid))
|
||||
.timeout(timeout);
|
||||
try {
|
||||
_send(pb.ClientFrame(
|
||||
envelope: pb.Envelope(
|
||||
dstAddress: dstAddress,
|
||||
msgUuid: msgUuid,
|
||||
contentHint: pb.ContentHint.CONTENT_HINT_DEFAULT,
|
||||
contentHint: hint,
|
||||
payload: payload,
|
||||
),
|
||||
));
|
||||
} catch (_) {
|
||||
// Odeslání selhalo (kanál zavřený) — čekající future by za 10 s
|
||||
// hodil neodchycený TimeoutException do zvonku chyb.
|
||||
receiptFuture.ignore();
|
||||
rethrow;
|
||||
}
|
||||
return receiptFuture;
|
||||
}
|
||||
|
||||
/// Dočasné údaje k TURN serveru pro hovor. Relay je vydá jen přihlášenému
|
||||
/// zařízení; bez TURN na relayi (503) doběhne timeout a hovor zkusí jen
|
||||
/// přímé spojení přes STUN.
|
||||
Future<pb.TurnCredentials> requestTurn({
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) {
|
||||
final future = _turnController.stream.first.timeout(timeout);
|
||||
_send(pb.ClientFrame(turnRequest: pb.TurnRequest()));
|
||||
return future;
|
||||
}
|
||||
|
||||
/// Potvrdí zpracování obálky — relay ji smaže z fronty. Volat až PO
|
||||
/// uložení zprávy na disk.
|
||||
void ack(List<int> msgUuid) {
|
||||
@@ -422,6 +540,8 @@ class RelayClient {
|
||||
await stop();
|
||||
_transport.close();
|
||||
await _stateController.close();
|
||||
await _turnController.close();
|
||||
await _prekeysLowController.close();
|
||||
await _deliverController.close();
|
||||
await _receiptController.close();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,15 @@ import 'package:socks5_proxy/socks_client.dart';
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
/// Jak často posílat WS ping a jak dlouho čekat na pong.
|
||||
///
|
||||
/// Bez pingu se mrtvé spojení tváří jako živé: mobilní NAT zahodí nečinné TCP
|
||||
/// tiše, appka dál hlásí „připojeno" a píše do prázdna — zprávy pak odejdou až
|
||||
/// po restartu appky, všechny naráz. `dart:io` při nastaveném intervalu ping
|
||||
/// posílá a když pong nedorazí včas, spojení zavře → [RelayClient] se
|
||||
/// reconnectne sám. Relay pinguje protisměrně po 30 s (ws.rs PING_INTERVAL).
|
||||
const kWsPingInterval = Duration(seconds: 30);
|
||||
|
||||
/// Abstrakce nad tím, jak klient otevírá spojení k relayi.
|
||||
abstract class RelayTransport {
|
||||
/// Sdílený HTTP klient pro REST (registrace, prekey bundle, přílohy…).
|
||||
@@ -41,7 +50,8 @@ class DirectTransport implements RelayTransport {
|
||||
http.Client httpClient() => _client ??= http.Client();
|
||||
|
||||
@override
|
||||
WebSocketChannel connectWs(Uri url) => WebSocketChannel.connect(url);
|
||||
WebSocketChannel connectWs(Uri url) =>
|
||||
IOWebSocketChannel.connect(url, pingInterval: kWsPingInterval);
|
||||
|
||||
@override
|
||||
void close() {
|
||||
@@ -84,7 +94,7 @@ class TorTransport implements RelayTransport {
|
||||
@override
|
||||
WebSocketChannel connectWs(Uri url) =>
|
||||
IOWebSocketChannel.connect(_withExplicitPort(url),
|
||||
customClient: _sharedHttpClient());
|
||||
customClient: _sharedHttpClient(), pingInterval: kWsPingInterval);
|
||||
|
||||
/// `Uri` pro `ws`/`wss` nezná výchozí port → `url.port` vrací 0 a SOCKS
|
||||
/// CONNECT by cílil na port 0 (serverError). Doplníme ho podle schématu
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'dart:io' show Platform;
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:sepot/core/calls/call_ringer.dart';
|
||||
import 'package:sepot/core/push/apns_ios.dart';
|
||||
import 'package:sepot/core/push/push_notifier.dart';
|
||||
import 'package:sepot/core/push/push_service.dart';
|
||||
@@ -28,8 +29,14 @@ import 'package:unifiedpush/unifiedpush.dart';
|
||||
/// FCM budíček se zabitou appkou — plugin spouští handler ve vlastním
|
||||
/// background isolate. Stejný model jako UnifiedPush killed-state:
|
||||
/// DB je zamčená, nic nejde dešifrovat → jen generická notifikace.
|
||||
/// Budíček hovoru (`k=call`) místo ní rovnou zazvoní; kdo
|
||||
/// volá, appka zjistí až po odemčení z nabídky ve frontě.
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> sepotFcmBackgroundWake(RemoteMessage message) async {
|
||||
if (message.data['k'] == 'call') {
|
||||
await CallRinger.ringFromPush();
|
||||
return;
|
||||
}
|
||||
await Firebase.initializeApp();
|
||||
await PushNotifier.showWakeNotification();
|
||||
}
|
||||
@@ -45,18 +52,32 @@ class PushBootstrap {
|
||||
|
||||
static const _pendingEndpointKey = 'pending_push_endpoint';
|
||||
static const _modeKey = 'push_mode'; // 'off' | 'up' | 'fcm'
|
||||
static const _registrationFailedKey = 'push_registration_failed';
|
||||
static const _store = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
/// Režim pushů žije v OS keystore (NE v SQLCipher DB) — musí být
|
||||
/// čitelný před unlockem, aby main() věděl, zda registrovat FCM.
|
||||
static Future<String> readMode() async =>
|
||||
await _store.read(key: _modeKey) ?? 'off';
|
||||
static Future<String> readMode() async => await readStoredMode() ?? 'off';
|
||||
|
||||
/// Uložený režim, nebo null, když ho uživatel ani appka ještě nevybrali
|
||||
/// — to odlišuje čerstvou instalaci od vědomého „vypnuto".
|
||||
static Future<String?> readStoredMode() => _store.read(key: _modeKey);
|
||||
|
||||
static Future<void> writeMode(String mode) =>
|
||||
_store.write(key: _modeKey, value: mode);
|
||||
|
||||
/// Odmítl distributor registraci? Selhání se dřív jen spolklo, takže
|
||||
/// uživatel měl v Nastavení „push zapnutý", ale budíčky nechodily a
|
||||
/// zprávy čekaly, než appku otevře. Nastavení si to přečte a řekne to.
|
||||
static Future<bool> registrationFailed() async =>
|
||||
await _store.read(key: _registrationFailedKey) == '1';
|
||||
|
||||
static Future<void> _setRegistrationFailed(bool failed) => failed
|
||||
? _store.write(key: _registrationFailedKey, value: '1')
|
||||
: _store.delete(key: _registrationFailedKey);
|
||||
|
||||
static Future<void> init(List<String> args) async {
|
||||
backgroundStart = args.contains('--unifiedpush-bg');
|
||||
|
||||
@@ -66,10 +87,13 @@ class PushBootstrap {
|
||||
if (Platform.isIOS) {
|
||||
ApnsBridge.onWake = _wake;
|
||||
ApnsBridge.ensureHandler();
|
||||
// Zvonění z VoIP pushe bez nabídky nesmí zvonit celých 45 s.
|
||||
unawaited(CallRinger.expireStalePushRings());
|
||||
} else {
|
||||
await UnifiedPush.initialize(
|
||||
onNewEndpoint: _onNewEndpoint,
|
||||
onRegistrationFailed: (_, _) {},
|
||||
onRegistrationFailed: (_, _) =>
|
||||
unawaited(_setRegistrationFailed(true)),
|
||||
onUnregistered: _onUnregistered,
|
||||
onMessage: _onMessage,
|
||||
);
|
||||
@@ -114,6 +138,7 @@ class PushBootstrap {
|
||||
}
|
||||
|
||||
static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
|
||||
unawaited(_setRegistrationFailed(false)); // registrace se povedla
|
||||
final s = _service;
|
||||
if (s != null) {
|
||||
s.handleNewEndpoint(endpoint.url);
|
||||
|
||||
@@ -13,8 +13,11 @@
|
||||
/// killed-state startu `--unifiedpush-bg` bez UI); tahle třída je
|
||||
/// most bootstrap ↔ messaging + API pro nastavení.
|
||||
///
|
||||
/// FCM „fast mode" (opt-in) je druhá cesta (viz [setModeFcm]); default je
|
||||
/// degooglovaný UnifiedPush.
|
||||
/// Výchozí režim na Androidu vybírá [restoreMode] při prvním startu: s
|
||||
/// UnifiedPush distributorem UnifiedPush, jinak FCM. Bez budíčku Android
|
||||
/// appku na pozadí uspí a zprávy čekají, než ji člověk otevře — to je pro
|
||||
/// messenger horší než token u Googlu (obsah ani odesílatel budíčkem nejde).
|
||||
/// Uživatel může kdykoli přepnout v Nastavení.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
@@ -24,6 +27,7 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:sepot/core/calls/call_ringer.dart';
|
||||
import 'package:sepot/core/push/apns_ios.dart';
|
||||
import 'package:sepot/core/push/push_bootstrap.dart';
|
||||
import 'package:sepot/core/push/push_notifier.dart';
|
||||
@@ -48,7 +52,15 @@ class PushService {
|
||||
/// Může budíček rovnou probudit WS? (Jinak jde generická notifikace.)
|
||||
bool get canWake => _messaging.isStarted;
|
||||
|
||||
void wake() => _messaging.onPushWake();
|
||||
/// Budíček: běžící messaging připojí WS; po auto-locku (zamčená DB) aspoň
|
||||
/// generická notifikace bez obsahu, ať se uživatel o zprávě dozví.
|
||||
void wake() {
|
||||
if (canWake) {
|
||||
_messaging.onPushWake();
|
||||
} else {
|
||||
unawaited(PushNotifier.showWakeNotification());
|
||||
}
|
||||
}
|
||||
|
||||
void handleNewEndpoint(String url) {
|
||||
_messaging.registerPush(_registration(
|
||||
@@ -80,11 +92,25 @@ class PushService {
|
||||
}
|
||||
}
|
||||
|
||||
void _registerApnsToken(String token) {
|
||||
_messaging.registerPush(_registration(
|
||||
String? _apnsToken;
|
||||
|
||||
/// APNs token pro zprávy + VoIP token pro hovory v jedné registraci
|
||||
/// (relay drží obojí u zařízení).
|
||||
Future<void> _registerApnsToken(String token) async {
|
||||
_apnsToken = token;
|
||||
final voip = await CallRinger.voipToken();
|
||||
final reg = _registration(
|
||||
pb.RegisterPush_Platform.PLATFORM_APNS,
|
||||
Uint8List.fromList(utf8.encode(token)),
|
||||
));
|
||||
);
|
||||
if (voip != null) reg.voipToken = utf8.encode(voip);
|
||||
_messaging.registerPush(reg);
|
||||
}
|
||||
|
||||
/// PushKit vydal (nový) VoIP token — přeregistrovat, pokud už APNs běží.
|
||||
Future<void> refreshVoipToken() async {
|
||||
final token = _apnsToken;
|
||||
if (token != null) await _registerApnsToken(token);
|
||||
}
|
||||
|
||||
/// iOS nativní push (APNs) — zapne registraci u Apple; token přijde
|
||||
@@ -144,6 +170,10 @@ class PushService {
|
||||
return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off';
|
||||
}
|
||||
|
||||
/// Odmítl distributor poslední registraci? Nastavení to ukáže — jinak by
|
||||
/// push vypadal zapnutý, ale budíčky by nechodily.
|
||||
Future<bool> registrationFailed() => PushBootstrap.registrationFailed();
|
||||
|
||||
Future<void> setModeOff() async {
|
||||
await _teardownFcm(deleteToken: true);
|
||||
try {
|
||||
@@ -162,8 +192,8 @@ class PushService {
|
||||
return ok;
|
||||
}
|
||||
|
||||
/// FCM fast mode — OPT-IN: registrační token zařízení jde Googlu
|
||||
/// (obsah budíčků nikdy, ťuk je konstantní `{"v":1}`).
|
||||
/// FCM: registrační token zařízení jde Googlu (obsah budíčků nikdy,
|
||||
/// ťuk je konstantní `{"v":1}`).
|
||||
Future<void> setModeFcm() async {
|
||||
await init();
|
||||
try {
|
||||
@@ -218,6 +248,10 @@ class PushService {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (await PushBootstrap.readStoredMode() == null) {
|
||||
await _chooseDefaultMode();
|
||||
return;
|
||||
}
|
||||
switch (await currentMode()) {
|
||||
case 'up':
|
||||
await enable();
|
||||
@@ -231,4 +265,23 @@ class PushService {
|
||||
await init(); // aspoň attach (killed-state notifikace)
|
||||
}
|
||||
}
|
||||
|
||||
/// První start na Androidu (nebo instalace z doby, kdy byl push
|
||||
/// opt-in a nikdo ho nezapnul). Kdo má nainstalovaný UnifiedPush
|
||||
/// distributor, vybral si degooglovanou cestu sám — dostane ji.
|
||||
/// Ostatním zapneme FCM. Telefon bez Google služeb token nedostane;
|
||||
/// pak zapíšeme „off", ať se to nezkouší při každém startu — seznam
|
||||
/// chatů na chybějící budíčky upozorní sám.
|
||||
Future<void> _chooseDefaultMode() async {
|
||||
try {
|
||||
if ((await currentMode()) == 'up' ||
|
||||
(await distributors()).isNotEmpty) {
|
||||
if (await setModeUnifiedPush()) return;
|
||||
}
|
||||
await setModeFcm();
|
||||
} catch (_) {
|
||||
await init();
|
||||
await PushBootstrap.writeMode('off');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:sepot/core/app_info.dart';
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/errors.dart';
|
||||
|
||||
@@ -67,12 +68,16 @@ class ReportService {
|
||||
throw const ReportEmptyDescriptionError();
|
||||
}
|
||||
|
||||
// S build číslem, ne jen „1.1.2" — jinak nejde poznat, ze kterého buildu
|
||||
// hlášení přišlo. Neznámou verzi radši vynechat než poslat vymyšlenou.
|
||||
final verze = AppInfo.versionWithBuild;
|
||||
|
||||
final body = <String, dynamic>{
|
||||
'kategorie': category.wire,
|
||||
'popis': popis,
|
||||
'zdroj': 'app',
|
||||
'platforma': Platform.operatingSystem,
|
||||
'verze': AppConstants.appVersion,
|
||||
'verze': ?verze,
|
||||
if (reportedFingerprint != null && reportedFingerprint.isNotEmpty)
|
||||
'nahlasenyOtisk': reportedFingerprint,
|
||||
if (replyTo != null && replyTo.trim().isNotEmpty)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'dart:isolate';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:sepot/core/services/image_processor.dart';
|
||||
|
||||
/// Strana profilové fotky v pixelech — víc se v appce nikde nezobrazí.
|
||||
const avatarSide = 256;
|
||||
|
||||
/// Vybranou fotku otočí podle EXIF, ořízne na středový čtverec, zmenší
|
||||
/// a znovu zakóduje do JPEG bez metadat (EXIF, GPS, model telefonu).
|
||||
/// Null = obrázek nejde přečíst.
|
||||
Future<Uint8List?> prepareAvatarJpeg(Uint8List input) => Isolate.run(() {
|
||||
final img.Image? decoded;
|
||||
try {
|
||||
decoded = img.decodeImage(input);
|
||||
} on Object {
|
||||
// Dekodéry na poškozených datech háží místo null.
|
||||
return null;
|
||||
}
|
||||
if (decoded == null) return null;
|
||||
final upright = img.bakeOrientation(decoded);
|
||||
final side = math.min(upright.width, upright.height);
|
||||
final square = img.copyCrop(
|
||||
upright,
|
||||
x: (upright.width - side) ~/ 2,
|
||||
y: (upright.height - side) ~/ 2,
|
||||
width: side,
|
||||
height: side,
|
||||
);
|
||||
final small = img.copyResize(square,
|
||||
width: avatarSide,
|
||||
height: avatarSide,
|
||||
interpolation: img.Interpolation.average);
|
||||
ImageProcessor.stripMetadata(small);
|
||||
return Uint8List.fromList(img.encodeJpg(small, quality: 85));
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/// Pravidla správců skupin oddělená od sítě a
|
||||
/// úložiště: drátová podoba `gop`, ověření příchozí operace v pořadí 1–6
|
||||
/// ze specifikace, tvorba odchozí operace s invarianty a podržení operací
|
||||
/// z budoucích epoch.
|
||||
///
|
||||
/// Ověření podpisu se předává jako funkce — produkce volá libsignal
|
||||
/// (`GroupAdminOp.verify`), testy pravidel tak běží i bez nativní knihovny.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:sepot/core/crypto/group_admin.dart';
|
||||
import 'package:sepot/core/errors.dart';
|
||||
import 'package:sepot/models/group.dart';
|
||||
|
||||
/// Ověří podpis [sig] operace [op] identity klíčem [identityKey].
|
||||
typedef GroupOpSignatureCheck = bool Function(
|
||||
GroupAdminOp op, Uint8List sig, Uint8List identityKey);
|
||||
|
||||
bool _libsignalVerify(GroupAdminOp op, Uint8List sig, Uint8List ik) {
|
||||
try {
|
||||
return op.verify(sig, ik);
|
||||
} catch (_) {
|
||||
// Nedeserializovatelný klíč/podpis z drátu = neplatný podpis, ne pád
|
||||
// příjmové cesty.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Výsledek ověření příchozí operace.
|
||||
enum GopVerdict {
|
||||
/// Platná a navazuje — aplikovat.
|
||||
apply,
|
||||
|
||||
/// `by` nesedí se sealed-sender autorem (krok 1).
|
||||
spoof,
|
||||
|
||||
/// Porušené invarianty nebo cizí skupina — nikdy nepůjde aplikovat.
|
||||
invalid,
|
||||
|
||||
/// Podepsal někdo, kdo u mě není správce (krok 2).
|
||||
notAdmin,
|
||||
|
||||
/// Podpis nesedí nebo neznám identity klíč podepisujícího (krok 3).
|
||||
badSignature,
|
||||
|
||||
/// Epocha přeskakuje — chybí mi mezistav (krok 4, `>`).
|
||||
gap,
|
||||
|
||||
/// Epochu už mám nebo jsem dál (krok 4, `<=`).
|
||||
stale,
|
||||
|
||||
/// Navazuje na jiný předchozí stav než můj (krok 5).
|
||||
fork,
|
||||
}
|
||||
|
||||
/// Drátová podoba `gop`: pole tabulky ze specifikace. Samostatně jako
|
||||
/// `t: 'gop'`, v kompatibilním režimu vnořená pod klíčem `gop` v legacy
|
||||
/// `ginv`/`gmeta` (v1 klient neznámé pole ignoruje a použije stín).
|
||||
class GroupOpMessage {
|
||||
GroupOpMessage({required this.op, required this.sig, this.roster});
|
||||
|
||||
final GroupAdminOp op;
|
||||
final Uint8List sig;
|
||||
|
||||
/// Roster entry `[{f,n,ik,aid,da,v}]` — u přidání nových členů a u
|
||||
/// pozvánky, ať nový člen zná klíče a routing ostatních.
|
||||
final List<Map<String, dynamic>>? roster;
|
||||
|
||||
Map<String, dynamic> toWire() => {
|
||||
'gid': op.gid,
|
||||
'epoch': op.epoch,
|
||||
'op': op.action.name,
|
||||
'by': op.actor,
|
||||
'members': op.members,
|
||||
'admins': op.admins,
|
||||
'gname': op.name,
|
||||
'prev': base64Encode(op.prev),
|
||||
'sig': base64Encode(sig),
|
||||
'roster': ?roster,
|
||||
};
|
||||
|
||||
/// Null = poškozená nebo neznámá podoba (neznámý typ operace z novější
|
||||
/// verze se nedá ověřit, protože jeho index je součást podpisu).
|
||||
static GroupOpMessage? fromWire(Object? raw) {
|
||||
if (raw is! Map) return null;
|
||||
try {
|
||||
final m = raw.cast<String, dynamic>();
|
||||
final action = GroupAdminAction.values
|
||||
.where((a) => a.name == m['op'])
|
||||
.firstOrNull;
|
||||
final epoch = m['epoch'];
|
||||
if (action == null || epoch is! int || epoch < 0) return null;
|
||||
final rosterRaw = m['roster'];
|
||||
return GroupOpMessage(
|
||||
op: GroupAdminOp(
|
||||
gid: m['gid'] as String,
|
||||
epoch: epoch,
|
||||
action: action,
|
||||
actor: m['by'] as String,
|
||||
members: (m['members'] as List).cast<String>().toList(),
|
||||
admins: (m['admins'] as List).cast<String>().toList(),
|
||||
name: m['gname'] as String,
|
||||
prev: base64Decode(m['prev'] as String),
|
||||
),
|
||||
sig: base64Decode(m['sig'] as String),
|
||||
roster: rosterRaw is List
|
||||
? [
|
||||
for (final e in rosterRaw)
|
||||
if (e is Map) e.cast<String, dynamic>(),
|
||||
]
|
||||
: null,
|
||||
);
|
||||
} on Object {
|
||||
// Špatné typy polí (TypeError), vadné base64 (FormatException).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Invarianty stavu, které musí držet každá operace bez ohledu na autora.
|
||||
bool _stateValid(GroupAdminOp op) =>
|
||||
op.gid.isNotEmpty &&
|
||||
op.gid.length <= 64 &&
|
||||
op.name.trim().isNotEmpty &&
|
||||
op.members.length <= Group.maxMembers &&
|
||||
op.admins.isNotEmpty &&
|
||||
op.admins.every(op.members.contains);
|
||||
|
||||
/// Ověří příchozí operaci proti lokálnímu stavu [local] v pořadí kroků 1–6
|
||||
/// (očíslované v těle funkce).
|
||||
///
|
||||
/// [actorIdentityKey] = identity klíč `by` z mých kontaktů (TOFU — nikdy
|
||||
/// z téže zprávy, jinak by si útočník klíč přinesl sám).
|
||||
GopVerdict checkGroupOp({
|
||||
required Group local,
|
||||
required GroupOpMessage msg,
|
||||
required String senderFp,
|
||||
required String me,
|
||||
required Uint8List? actorIdentityKey,
|
||||
GroupOpSignatureCheck verify = _libsignalVerify,
|
||||
}) {
|
||||
final op = msg.op;
|
||||
// 1. Autor = sealed sender.
|
||||
if (op.actor != senderFp) return GopVerdict.spoof;
|
||||
if (op.gid != local.id || !_stateValid(op)) return GopVerdict.invalid;
|
||||
|
||||
// 2. Oprávnění. U odhadnutých správců (viz Group.provisional) stačí, že
|
||||
// podepisující je člen a v podepsaném stavu správcem.
|
||||
final provisional = local.isProvisionalFor(me);
|
||||
final authorized = provisional
|
||||
? local.isMember(op.actor) && op.admins.contains(op.actor)
|
||||
: local.isAdmin(op.actor);
|
||||
if (!authorized) return GopVerdict.notAdmin;
|
||||
|
||||
// 3. Podpis.
|
||||
if (actorIdentityKey == null || !verify(op, msg.sig, actorIdentityKey)) {
|
||||
return GopVerdict.badSignature;
|
||||
}
|
||||
|
||||
// 4. Epocha. Stav od správce (sync) i kotva odhadnuté skupiny nemají na
|
||||
// co navázat — rozhoduje jen to, že posouvají dopředu. Ostrá nerovnost
|
||||
// u sync brání přeskakování mezi dvěma stejně starými větvemi.
|
||||
if (op.action == GroupAdminAction.sync) {
|
||||
return op.epoch > local.epoch ? GopVerdict.apply : GopVerdict.stale;
|
||||
}
|
||||
if (provisional) {
|
||||
return op.epoch >= local.epoch ? GopVerdict.apply : GopVerdict.stale;
|
||||
}
|
||||
if (op.epoch <= local.epoch) return GopVerdict.stale;
|
||||
if (op.epoch > local.epoch + 1) return GopVerdict.gap;
|
||||
|
||||
// 5. Řetěz: `prev` musí být hash mého aktuálního stavu.
|
||||
final mine = hashState(
|
||||
gid: local.id,
|
||||
epoch: local.epoch,
|
||||
members: local.memberIds,
|
||||
admins: local.admins,
|
||||
name: local.name,
|
||||
);
|
||||
if (!_bytesEqual(op.prev, mine)) return GopVerdict.fork;
|
||||
|
||||
// 6. Aplikovat (volající přes applyGroupOp).
|
||||
return GopVerdict.apply;
|
||||
}
|
||||
|
||||
/// Ověří operaci, kterou mě někdo přidává do skupiny, kterou ještě neznám.
|
||||
/// Není s čím řetězit — důvěra je stejná jako u v1 pozvánky (kdo mě zve,
|
||||
/// je člen), navíc musí být podepsaná správcem uvedeným v rosteru.
|
||||
GopVerdict checkJoinOp({
|
||||
required GroupOpMessage msg,
|
||||
required String senderFp,
|
||||
required String me,
|
||||
required Uint8List? actorIdentityKey,
|
||||
GroupOpSignatureCheck verify = _libsignalVerify,
|
||||
}) {
|
||||
final op = msg.op;
|
||||
if (op.actor != senderFp) return GopVerdict.spoof;
|
||||
if (!_stateValid(op) || !op.members.contains(me)) return GopVerdict.invalid;
|
||||
if (!op.admins.contains(op.actor)) return GopVerdict.notAdmin;
|
||||
if (actorIdentityKey == null || !verify(op, msg.sig, actorIdentityKey)) {
|
||||
return GopVerdict.badSignature;
|
||||
}
|
||||
return GopVerdict.apply;
|
||||
}
|
||||
|
||||
/// Přepíše lokální stav výsledkem ověřené operace. Když ve výsledku
|
||||
/// nejsem, správce mě odebral — skupina se chová jako opuštěná.
|
||||
void applyGroupOp(Group group, GroupAdminOp op, String me) {
|
||||
group
|
||||
..memberIds = [...op.members]
|
||||
..admins = [...op.admins]
|
||||
..name = op.name
|
||||
..epoch = op.epoch
|
||||
..provisional = false;
|
||||
if (!op.members.contains(me)) group.left = true;
|
||||
}
|
||||
|
||||
/// Hash aktuálního stavu skupiny (vstupuje jako `prev` další operace).
|
||||
Uint8List currentStateHash(Group g) => hashState(
|
||||
gid: g.id,
|
||||
epoch: g.epoch,
|
||||
members: g.memberIds,
|
||||
admins: g.admins,
|
||||
name: g.name,
|
||||
);
|
||||
|
||||
/// Postaví odchozí operaci s kontrolou oprávnění a invariantů. Hází
|
||||
/// [NotGroupAdminError] / [LastGroupAdminError] dřív, než cokoli odejde.
|
||||
GroupAdminOp buildGroupOp({
|
||||
required Group group,
|
||||
required String me,
|
||||
required GroupAdminAction action,
|
||||
List<String>? members,
|
||||
List<String>? admins,
|
||||
String? name,
|
||||
}) {
|
||||
if (!group.isAdmin(me)) throw const NotGroupAdminError();
|
||||
final newMembers = members ?? group.memberIds;
|
||||
final newAdmins = admins ?? group.admins;
|
||||
if (newMembers.toSet().length > Group.maxMembers) {
|
||||
throw const GroupTooLargeError(Group.maxMembers);
|
||||
}
|
||||
// Poslední správce se nesmí odebrat ani zbavit role — skupina by zůstala
|
||||
// bez nikoho, kdo smí podepsat další změnu.
|
||||
if (newAdmins.isEmpty) throw const LastGroupAdminError();
|
||||
if (!newAdmins.every(newMembers.contains)) {
|
||||
throw StateError('group admins must be members');
|
||||
}
|
||||
final isSync = action == GroupAdminAction.sync;
|
||||
return GroupAdminOp(
|
||||
gid: group.id,
|
||||
epoch: isSync ? group.epoch : group.epoch + 1,
|
||||
action: action,
|
||||
actor: me,
|
||||
members: newMembers,
|
||||
admins: newAdmins,
|
||||
name: name ?? group.name,
|
||||
// Sync nic neřetězí, jen potvrzuje stav — viz checkGroupOp krok 4.
|
||||
prev: isSync ? Uint8List(0) : currentStateHash(group),
|
||||
);
|
||||
}
|
||||
|
||||
/// Podrží operaci na později. Při přetečení se zahodí ty z nejvzdálenější
|
||||
/// budoucnosti — podvržené operace s absurdní epochou tak nevytlačí
|
||||
/// skutečně chybějící mezistav.
|
||||
void holdGroupOp(Group group, PendingGroupOp pending) {
|
||||
final sig = pending.wire['sig'];
|
||||
if (group.pendingOps.any((p) => p.wire['sig'] == sig)) return;
|
||||
group.pendingOps = [...group.pendingOps, pending]
|
||||
..sort((a, b) => a.epoch.compareTo(b.epoch));
|
||||
if (group.pendingOps.length > Group.maxPendingOps) {
|
||||
group.pendingOps = group.pendingOps.sublist(0, Group.maxPendingOps);
|
||||
}
|
||||
}
|
||||
|
||||
bool _bytesEqual(List<int> a, List<int> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -43,6 +43,19 @@ class ImageProcessor {
|
||||
/// sealed-sender obálku) — držet výrazně pod tím.
|
||||
static const int thumbMaxBytes = 48 * 1024;
|
||||
|
||||
/// Jestli z obrázku umíme odstranit metadata. Formát, který knihovna
|
||||
/// nepřečte (HEIC z galerie odeslaný jako dokument, RAW…), by odešel
|
||||
/// v originále včetně GPS — UI se proto nejdřív zeptá uživatele.
|
||||
static Future<bool> canStripMetadata(Uint8List bytes) {
|
||||
return Isolate.run(() {
|
||||
try {
|
||||
return img.decodeImage(bytes) != null;
|
||||
} on Object {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Zpracuje obrázek v izolátu (dekódování megapixelů by blokovalo UI).
|
||||
/// Vrací null, když bajty nejsou dekódovatelný obrázek — volající je
|
||||
/// pak pošle jako dokument.
|
||||
@@ -51,7 +64,13 @@ class ImageProcessor {
|
||||
String? mimeHint,
|
||||
}) {
|
||||
return Isolate.run(() {
|
||||
final decoded = img.decodeImage(original);
|
||||
final img.Image? decoded;
|
||||
try {
|
||||
decoded = img.decodeImage(original);
|
||||
} on Object {
|
||||
// Dekodéry na poškozených datech háží místo null.
|
||||
return null;
|
||||
}
|
||||
if (decoded == null) return null;
|
||||
|
||||
// GIF beze změny — re-encode by zabil animaci
|
||||
@@ -77,6 +96,7 @@ class ImageProcessor {
|
||||
// Samotný alfa kanál nestačí — plně krytý RGBA PNG (screenshot,
|
||||
// canvas render z editoru fotek) je jako JPEG několikanásobně menší.
|
||||
final keepPng = mimeHint == 'image/png' && _hasTransparency(decoded);
|
||||
stripMetadata(image);
|
||||
final bytes = keepPng
|
||||
? Uint8List.fromList(img.encodePng(image))
|
||||
: Uint8List.fromList(img.encodeJpg(image, quality: jpegQuality));
|
||||
@@ -101,6 +121,15 @@ class ImageProcessor {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Zahodí EXIF (GPS, model telefonu, čas focení) a textová metadata.
|
||||
/// Knihovna je při ořezu i zmenšení kopíruje a encodeJpg/encodePng je
|
||||
/// znovu zapíše — re-encode sám o sobě metadata NEodstraní.
|
||||
static void stripMetadata(img.Image image) {
|
||||
image
|
||||
..exif = img.ExifData()
|
||||
..textData = null;
|
||||
}
|
||||
|
||||
static Uint8List _thumbnail(img.Image source) {
|
||||
var thumb = max(source.width, source.height) > thumbDimension
|
||||
? (source.width >= source.height
|
||||
@@ -114,6 +143,7 @@ class ImageProcessor {
|
||||
img.compositeImage(flat, thumb);
|
||||
thumb = flat;
|
||||
}
|
||||
stripMetadata(thumb);
|
||||
var quality = 70;
|
||||
var encoded = Uint8List.fromList(img.encodeJpg(thumb, quality: quality));
|
||||
// Náhled je jen placeholder, než dorazí ostrá fotka — kvalitu držíme
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Kdo právě píše: konverzace → otisky pisatelů.
|
||||
///
|
||||
/// Indikátor nemá „konec psaní“ — odesílatel posílá „píšu“ nejvýš jednou
|
||||
/// za pár sekund a tady každý záznam sám vyprší. Zpráva, která pak dorazí,
|
||||
/// ho smaže hned ([clear]).
|
||||
class TypingTracker {
|
||||
TypingTracker({DateTime Function()? clock}) : _now = clock ?? DateTime.now;
|
||||
|
||||
/// Jak dlouho po posledním „píšu“ indikátor svítí. Musí být delší než
|
||||
/// interval, ve kterém odesílatel „píšu“ opakuje (4 s), jinak by blikal.
|
||||
static const showFor = Duration(seconds: 6);
|
||||
|
||||
final DateTime Function() _now;
|
||||
final _expires = <String, Map<String, DateTime>>{};
|
||||
final _changes = StreamController<Map<String, Set<String>>>.broadcast();
|
||||
Timer? _sweep;
|
||||
|
||||
Stream<Map<String, Set<String>>> get changes => _changes.stream;
|
||||
|
||||
/// Aktuální pisatelé po konverzacích (bez vypršelých).
|
||||
Map<String, Set<String>> get current {
|
||||
final now = _now();
|
||||
return {
|
||||
for (final e in _expires.entries)
|
||||
if (e.value.values.any((t) => t.isAfter(now)))
|
||||
e.key: {
|
||||
for (final w in e.value.entries)
|
||||
if (w.value.isAfter(now)) w.key,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
void mark(String convId, String writer) {
|
||||
(_expires[convId] ??= {})[writer] = _now().add(showFor);
|
||||
_emit();
|
||||
_sweep ??= Timer.periodic(const Duration(seconds: 1), (_) => prune());
|
||||
}
|
||||
|
||||
void clear(String convId, String writer) {
|
||||
final writers = _expires[convId];
|
||||
if (writers?.remove(writer) == null) return;
|
||||
if (writers!.isEmpty) _expires.remove(convId);
|
||||
_emit();
|
||||
}
|
||||
|
||||
/// Zahodí vypršelé záznamy; když už nikdo nepíše, zastaví časovač.
|
||||
void prune() {
|
||||
final now = _now();
|
||||
var changed = false;
|
||||
_expires.removeWhere((_, writers) {
|
||||
final before = writers.length;
|
||||
writers.removeWhere((_, t) => !t.isAfter(now));
|
||||
changed |= writers.length != before;
|
||||
return writers.isEmpty;
|
||||
});
|
||||
if (changed) _emit();
|
||||
if (_expires.isEmpty) {
|
||||
_sweep?.cancel();
|
||||
_sweep = null;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_sweep?.cancel();
|
||||
_sweep = null;
|
||||
_expires.clear();
|
||||
}
|
||||
|
||||
void _emit() {
|
||||
if (!_changes.isClosed) _changes.add(current);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,16 @@ import 'package:sepot/models/message.dart';
|
||||
/// při onboardingu, který DB rovnou otevře).
|
||||
|
||||
/// Nátlakový PIN je shodný se skutečným — nelze nastavit.
|
||||
/// Příliš mnoho špatných PINů za sebou — další pokus až za [remaining].
|
||||
class PinLockedException implements Exception {
|
||||
const PinLockedException(this.remaining);
|
||||
|
||||
final Duration remaining;
|
||||
|
||||
@override
|
||||
String toString() => 'PIN locked for ${remaining.inSeconds}s';
|
||||
}
|
||||
|
||||
class DecoyPinConflict implements Exception {
|
||||
const DecoyPinConflict();
|
||||
@override
|
||||
@@ -51,6 +61,13 @@ class SecureStorageService {
|
||||
static const _legacyPinHashKey = 'pin_hash';
|
||||
|
||||
static const _decoyWrappedDekKey = 'wrapped_dek_decoy';
|
||||
// Brzda hádání PINu: počet chyb za sebou a čas, do kdy je zámek uzamčený.
|
||||
// V keystore (ne v DB) — DB je právě to, co se hádáním otevírá.
|
||||
static const _pinFailCountKey = 'pin_fail_count';
|
||||
static const _pinLockUntilKey = 'pin_lock_until';
|
||||
static const _pinFreeAttempts = 5;
|
||||
static const _pinLockBase = Duration(seconds: 30);
|
||||
static const _pinLockMax = Duration(minutes: 15);
|
||||
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
final String? _dbPathOverride;
|
||||
@@ -60,12 +77,24 @@ class SecureStorageService {
|
||||
bool _decoyMode = false;
|
||||
|
||||
/// Odemčeno nátlakovým (decoy) PINem → běžíme nad prázdnou decoy DB.
|
||||
/// V tomhle režimu úložiště nevydá skutečnou identitu ani frázi,
|
||||
/// nestartuje síť a nic z keystore nemění (viz jednotlivé metody).
|
||||
bool get isDecoyMode => _decoyMode;
|
||||
|
||||
/// Identita ukazovaná v nátlakové relaci: náhodná, jen v paměti.
|
||||
LocalIdentity? _decoyIdentity;
|
||||
|
||||
void _requireRealSession() {
|
||||
if (_decoyMode) throw StateError('Not available in a duress session');
|
||||
}
|
||||
|
||||
SecureStorageService({String? dbPath})
|
||||
: _secureStorage = const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
// this_device: identita a klíče nesmí putovat v iCloud Keychain
|
||||
// ani v záloze na jiný telefon — obnova jde přes 24 slov.
|
||||
iOptions: IOSOptions(
|
||||
accessibility: KeychainAccessibility.first_unlock_this_device),
|
||||
),
|
||||
_dbPathOverride = dbPath;
|
||||
|
||||
@@ -98,21 +127,59 @@ class SecureStorageService {
|
||||
if (wrapped == null) {
|
||||
throw StateError('No PIN set — use unlockWithoutPin');
|
||||
}
|
||||
try {
|
||||
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: pin);
|
||||
await _openDb(dek);
|
||||
} on WrongPinException {
|
||||
final locked = await pinLockRemaining();
|
||||
if (locked != null) throw PinLockedException(locked);
|
||||
|
||||
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
|
||||
if (decoy == null) rethrow;
|
||||
// Když ani decoy nesedí, unwrap vyhodí WrongPinException dál.
|
||||
final dek = await DekWrap.unwrap(wrapped: decoy, pin: pin);
|
||||
await _openDb(dek, decoy: true);
|
||||
try {
|
||||
// Oba bloby jednou derivací (sdílená sůl) — čas odemčení neprozradí,
|
||||
// který PIN padl.
|
||||
final (index, dek) = await DekWrap.unwrapFirstMatching(
|
||||
wrapped: [wrapped, ?decoy],
|
||||
pin: pin,
|
||||
);
|
||||
await _openDb(dek, decoy: index == 1);
|
||||
await _resetPinFailures();
|
||||
} on WrongPinException {
|
||||
await _notePinFailure();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Zbývající zámek po sérii špatných PINů; null = volno.
|
||||
Future<Duration?> pinLockRemaining() async {
|
||||
final raw = await _secureStorage.read(key: _pinLockUntilKey);
|
||||
final until = raw == null ? null : int.tryParse(raw);
|
||||
if (until == null) return null;
|
||||
final remaining = DateTime.fromMillisecondsSinceEpoch(until)
|
||||
.difference(DateTime.now());
|
||||
return remaining > Duration.zero ? remaining : null;
|
||||
}
|
||||
|
||||
Future<void> _notePinFailure() async {
|
||||
final raw = await _secureStorage.read(key: _pinFailCountKey);
|
||||
final failures = (raw == null ? 0 : int.tryParse(raw) ?? 0) + 1;
|
||||
await _secureStorage.write(key: _pinFailCountKey, value: '$failures');
|
||||
if (failures < _pinFreeAttempts) return;
|
||||
// 30 s po páté chybě, pak dvojnásobně až do 15 minut: šest číslic
|
||||
// (10⁶) tak trvá roky, Argon2id sám by brzdil jen ~2 s na pokus.
|
||||
var lock = _pinLockBase * (1 << (failures - _pinFreeAttempts));
|
||||
if (lock > _pinLockMax) lock = _pinLockMax;
|
||||
await _secureStorage.write(
|
||||
key: _pinLockUntilKey,
|
||||
value: '${DateTime.now().add(lock).millisecondsSinceEpoch}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _resetPinFailures() async {
|
||||
await _secureStorage.delete(key: _pinFailCountKey);
|
||||
await _secureStorage.delete(key: _pinLockUntilKey);
|
||||
}
|
||||
|
||||
/// Zapne PIN: zabalí DEK za Argon2id(PIN) a raw kopii z keystore smaže.
|
||||
/// Pokud DB ještě není otevřená (onboarding), rovnou ji otevře.
|
||||
Future<void> enablePin(String pin) async {
|
||||
_requireRealSession();
|
||||
final dek = _dek ?? await _getOrCreateRawDek();
|
||||
final wrapped = await DekWrap.wrap(dek: dek, pin: pin);
|
||||
await _secureStorage.write(key: _wrappedDekKey, value: wrapped);
|
||||
@@ -124,6 +191,7 @@ class SecureStorageService {
|
||||
|
||||
/// Vypne PIN: DEK se vrátí raw do OS keystore.
|
||||
Future<void> disablePin() async {
|
||||
_requireRealSession();
|
||||
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
|
||||
await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek));
|
||||
await _secureStorage.delete(key: _wrappedDekKey);
|
||||
@@ -151,6 +219,7 @@ class SecureStorageService {
|
||||
required String oldPin,
|
||||
required String newPin,
|
||||
}) async {
|
||||
_requireRealSession();
|
||||
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||
if (wrapped == null) throw StateError('No PIN set');
|
||||
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: oldPin);
|
||||
@@ -165,7 +234,11 @@ class SecureStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
final rewrapped = await DekWrap.wrap(dek: dek, pin: newPin);
|
||||
// Sůl zůstává: nátlakový blob ji sdílí (jedna derivace pro oba PINy)
|
||||
// a bez jeho PINu by se přebalit nedal. Jedna sůl pro dva PINy téhož
|
||||
// uživatele nic neoslabuje — sůl brání předpočítání napříč uživateli.
|
||||
final rewrapped = await DekWrap.wrap(
|
||||
dek: dek, pin: newPin, salt: DekWrap.saltOf(wrapped));
|
||||
await _secureStorage.write(key: _wrappedDekKey, value: rewrapped);
|
||||
}
|
||||
|
||||
@@ -176,6 +249,7 @@ class SecureStorageService {
|
||||
/// Nastaví nátlakový (decoy) PIN — jeho zadáním se otevře PRÁZDNÁ decoy
|
||||
/// DB. Vyžaduje nastavený běžný PIN a MUSÍ se od něj lišit.
|
||||
Future<void> enableDecoyPin(String decoyPin) async {
|
||||
_requireRealSession();
|
||||
final realWrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||
if (realWrapped == null) {
|
||||
throw StateError('Set a regular PIN first');
|
||||
@@ -193,7 +267,9 @@ class SecureStorageService {
|
||||
// Inicializuj prázdnou decoy DB (schéma) bez zásahu do aktuální DB
|
||||
final decoyPath = await _dbPath(decoy: true);
|
||||
CryptoDb.open(path: decoyPath, dek: decoyDek).close();
|
||||
final wrapped = await DekWrap.wrap(dek: decoyDek, pin: decoyPin);
|
||||
// Stejná sůl jako skutečný blob → jedna Argon2id derivace pro oba PINy.
|
||||
final wrapped = await DekWrap.wrap(
|
||||
dek: decoyDek, pin: decoyPin, salt: DekWrap.saltOf(realWrapped));
|
||||
await _secureStorage.write(key: _decoyWrappedDekKey, value: wrapped);
|
||||
}
|
||||
|
||||
@@ -203,11 +279,20 @@ class SecureStorageService {
|
||||
|
||||
/// Zruší nátlakový PIN a smaže decoy DB.
|
||||
Future<void> disableDecoyPin() async {
|
||||
_requireRealSession();
|
||||
await _secureStorage.delete(key: _decoyWrappedDekKey);
|
||||
await _deleteDbFiles(await _dbPath(decoy: true));
|
||||
}
|
||||
|
||||
Future<void> _deleteDbFiles(String path) async {
|
||||
for (final suffix in ['', '-wal', '-shm']) {
|
||||
try {
|
||||
final f = File(await _dbPath(decoy: true));
|
||||
final f = File('$path$suffix');
|
||||
if (f.existsSync()) f.deleteSync();
|
||||
} catch (_) {}
|
||||
} catch (_) {
|
||||
// best-effort úklid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starší instalace měla PIN jako PBKDF2 hash — starý formát neumíme
|
||||
@@ -222,6 +307,8 @@ class SecureStorageService {
|
||||
_db?.close();
|
||||
_db = null;
|
||||
_dek = null;
|
||||
_decoyMode = false;
|
||||
_decoyIdentity = null;
|
||||
}
|
||||
|
||||
Future<void> _openDb(Uint8List dek, {bool decoy = false}) async {
|
||||
@@ -293,6 +380,11 @@ class SecureStorageService {
|
||||
|
||||
/// Načti lokální identitu
|
||||
Future<LocalIdentity?> loadIdentity() async {
|
||||
if (_decoyMode) {
|
||||
// Nátlaková relace ukazuje náhodnou identitu jen z paměti — skutečný
|
||||
// klíč z keystore se do ní nikdy nedostane (nátlakový PIN musí být izolovaný).
|
||||
return _decoyIdentity ??= await LocalIdentity.create('');
|
||||
}
|
||||
final json = await _secureStorage.read(key: _identityKey);
|
||||
if (json == null) return null;
|
||||
try {
|
||||
@@ -320,6 +412,7 @@ class SecureStorageService {
|
||||
}
|
||||
|
||||
Future<List<String>?> loadSeedPhrase() async {
|
||||
if (_decoyMode) return null;
|
||||
final json = await _secureStorage.read(key: _seedPhraseKey);
|
||||
if (json == null) return null;
|
||||
return (jsonDecode(json) as List).cast<String>();
|
||||
@@ -331,6 +424,7 @@ class SecureStorageService {
|
||||
/// Vyžaduje odemčené úložiště (typicky hned po zadání PINu v settings).
|
||||
/// Samotné ověření otisku/obličeje řeší UI přes local_auth PŘED voláním.
|
||||
Future<void> enableBiometrics() async {
|
||||
_requireRealSession();
|
||||
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
|
||||
await _secureStorage.write(
|
||||
key: _biometricDekKey,
|
||||
@@ -342,6 +436,7 @@ class SecureStorageService {
|
||||
/// Vypne biometrii a kopii DEK z keystore smaže (zpět na čistý
|
||||
/// PIN-only model: DEK existuje jen za Argon2id).
|
||||
Future<void> disableBiometrics() async {
|
||||
_requireRealSession();
|
||||
await _secureStorage.delete(key: _biometricDekKey);
|
||||
await _secureStorage.write(key: _biometricsEnabledKey, value: 'false');
|
||||
}
|
||||
@@ -442,11 +537,15 @@ class SecureStorageService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addMessage(String contactId, Message message) async {
|
||||
/// Přidá zprávu do konverzace. Vrací false, když už tam zpráva s tímhle id
|
||||
/// je (opakované doručení) — volající pak přeskočí navazující kroky jako
|
||||
/// doručenku nebo stahování přílohy.
|
||||
Future<bool> addMessage(String contactId, Message message) async {
|
||||
var conv = await loadConversation(contactId);
|
||||
conv ??= Conversation(contactId: contactId);
|
||||
conv.addMessage(message);
|
||||
if (!conv.addMessage(message)) return false;
|
||||
await saveConversation(conv);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Založí prázdnou konverzaci, pokud ještě není (nová skupina musí být
|
||||
@@ -471,24 +570,29 @@ class SecureStorageService {
|
||||
if (!isUnlocked) return false;
|
||||
var changed = false;
|
||||
final now = DateTime.now();
|
||||
for (final conv in await loadConversations()) {
|
||||
final expired = conv.messages
|
||||
.where((m) =>
|
||||
bool isExpired(Message m) =>
|
||||
m.disappearAfterSeconds != null &&
|
||||
m.timestamp
|
||||
.add(Duration(seconds: m.disappearAfterSeconds!))
|
||||
.isBefore(now))
|
||||
.toList();
|
||||
.isBefore(now);
|
||||
for (final snapshot in await loadConversations()) {
|
||||
if (!snapshot.messages.any(isExpired)) continue;
|
||||
// Konverzace se před zápisem načte znovu: mezi snímkem a zápisem
|
||||
// běží event loop (mazání blobů, příjem zpráv) a starý snímek by
|
||||
// přepsal zprávu přijatou mezitím — už ACKnutou, tedy navždy pryč.
|
||||
final conv = await loadConversation(snapshot.contactId);
|
||||
if (conv == null) continue;
|
||||
final expired = conv.messages.where(isExpired).toList();
|
||||
if (expired.isEmpty) continue;
|
||||
changed = true;
|
||||
conv.messages.removeWhere(isExpired);
|
||||
await saveConversation(conv);
|
||||
for (final m in expired) {
|
||||
final blobId = m.attachment?.blobId;
|
||||
if (blobId != null) {
|
||||
await AttachmentStore().delete(blobId);
|
||||
}
|
||||
conv.messages.remove(m);
|
||||
}
|
||||
await saveConversation(conv);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
@@ -508,12 +612,19 @@ class SecureStorageService {
|
||||
/// Smaž všechna data (factory reset) — DB soubor, klíče, keystore
|
||||
/// i lokální bloby příloh.
|
||||
Future<void> deleteAll() async {
|
||||
final path = await _dbPath();
|
||||
if (_decoyMode) {
|
||||
// Nátlaková relace: „smazat" smí jen prázdnou decoy DB — skutečná
|
||||
// data a keystore zůstávají (dřív to smazalo reálný účet).
|
||||
final decoyPath = await _dbPath(decoy: true);
|
||||
lock();
|
||||
for (final suffix in ['', '-wal', '-shm']) {
|
||||
final f = File('$path$suffix');
|
||||
if (f.existsSync()) f.deleteSync();
|
||||
await _deleteDbFiles(decoyPath);
|
||||
return;
|
||||
}
|
||||
final path = await _dbPath();
|
||||
final decoyPath = await _dbPath(decoy: true);
|
||||
lock();
|
||||
await _deleteDbFiles(path);
|
||||
await _deleteDbFiles(decoyPath);
|
||||
await AttachmentStore().deleteAll();
|
||||
await _secureStorage.deleteAll();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ class SepotColors {
|
||||
// Text
|
||||
static const Color textPrimary = Color(0xFFF0EEE8); // mléko
|
||||
static const Color textSecondary = Color(0xFF9AA0B5); // mlha
|
||||
static const Color textEmphasis = Color(0xFFC4C8D6); // mlha, zvýrazněná
|
||||
static const Color textMuted = Color(0xFF626880);
|
||||
|
||||
// Akcentní barvy
|
||||
@@ -39,12 +40,57 @@ class SepotColors {
|
||||
// Bubliny zpráv — odeslaná teplá (ember), přijatá chladná (povrch)
|
||||
static const Color bubbleSent = Color(0xFFF0795A);
|
||||
static const Color bubbleReceived = Color(0xFF191C28);
|
||||
|
||||
/// Plovoucí lišty nad obsahem (hlavička chatu, pole pro psaní, navigace).
|
||||
/// Průhlednost nese rozmazání pod sebou — bez něj by působila jako chyba.
|
||||
static const Color glass = Color(0xC712141D);
|
||||
static const Color glassBorder = Color(0x0FFFFFFF);
|
||||
|
||||
/// Podklad hlavičky profilu — o tón teplejší než noc, ať avatar vystoupí.
|
||||
static const Color hero = Color(0xFF14131A);
|
||||
}
|
||||
|
||||
/// Tóny avatarů: tlumený podklad + světlejší inkoust téže barvy. Člověk
|
||||
/// dostane tón podle otisku klíče, takže má všude a u všech stejnou barvu.
|
||||
class SepotTint {
|
||||
const SepotTint(this.background, this.ink);
|
||||
|
||||
final Color background;
|
||||
final Color ink;
|
||||
|
||||
static const all = [
|
||||
SepotTint(Color(0xFF3A2A33), Color(0xFFF2A58F)), // ember
|
||||
SepotTint(Color(0xFF22352B), Color(0xFFA6CFB2)), // sage
|
||||
SepotTint(Color(0xFF1E2A3A), Color(0xFF9FB6D6)), // mist
|
||||
SepotTint(Color(0xFF3A3222), Color(0xFFE6C27A)), // jantar
|
||||
SepotTint(Color(0xFF2C2638), Color(0xFFC3B2E0)), // levandule
|
||||
];
|
||||
|
||||
/// Stabilní napříč spuštěními — String.hashCode to Dart nezaručuje.
|
||||
static SepotTint of(String seed) {
|
||||
var h = 0x811c9dc5;
|
||||
for (final c in seed.codeUnits) {
|
||||
h = ((h ^ c) * 0x01000193) & 0xFFFFFFFF;
|
||||
}
|
||||
return all[h % all.length];
|
||||
}
|
||||
}
|
||||
|
||||
/// Témata aplikace
|
||||
class SepotTheme {
|
||||
SepotTheme._();
|
||||
|
||||
/// Velký nadpis hlavních karet (Chaty, Kontakty, Nastavení).
|
||||
static const largeTitle = TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.6,
|
||||
color: SepotColors.textPrimary,
|
||||
);
|
||||
|
||||
/// Výška lišty s velkým nadpisem.
|
||||
static const largeTitleBarHeight = 76.0;
|
||||
|
||||
static ThemeData get dark {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/// Porovnávání textu pro hledání: bez ohledu na velikost písmen a diakritiku.
|
||||
/// Lidé na mobilu často píšou „prectu“ místo „přečtu“ — hledání to má najít.
|
||||
library;
|
||||
|
||||
const _map = {
|
||||
'á': 'a', 'ä': 'a', 'č': 'c', 'ď': 'd', 'é': 'e', 'ě': 'e', 'ë': 'e',
|
||||
'í': 'i', 'ľ': 'l', 'ĺ': 'l', 'ň': 'n', 'ó': 'o', 'ô': 'o', 'ö': 'o',
|
||||
'ř': 'r', 'ŕ': 'r', 'š': 's', 'ť': 't', 'ú': 'u', 'ů': 'u', 'ü': 'u',
|
||||
'ý': 'y', 'ž': 'z', 'ß': 'ss',
|
||||
};
|
||||
|
||||
/// Malá písmena bez diakritiky. Délka se může změnit jen u „ß“, proto
|
||||
/// pozice shod počítej přes [foldedIndexes], ne přes indexy výsledku.
|
||||
String fold(String input) {
|
||||
final out = StringBuffer();
|
||||
for (final ch in input.toLowerCase().split('')) {
|
||||
out.write(_map[ch] ?? ch);
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/// Začátky všech výskytů [query] v [text] (indexy do PŮVODNÍHO textu).
|
||||
/// Znaky se skládají po jednom, takže indexy sedí i pro zvýraznění.
|
||||
List<int> foldedIndexes(String text, String query) {
|
||||
final q = fold(query);
|
||||
if (q.isEmpty) return const [];
|
||||
final chars = text.split('');
|
||||
final folded = chars.map(fold).toList();
|
||||
final hits = <int>[];
|
||||
for (var i = 0; i < chars.length; i++) {
|
||||
var j = i;
|
||||
var matched = '';
|
||||
while (j < chars.length && matched.length < q.length) {
|
||||
matched += folded[j];
|
||||
j++;
|
||||
}
|
||||
if (matched.startsWith(q)) hits.add(i);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
@@ -4,8 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
import 'package:sepot/core/crypto/dek_wrap.dart';
|
||||
import 'package:sepot/core/storage/secure_storage.dart' show PinLockedException;
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/push_provider.dart';
|
||||
import 'package:sepot/providers/settings_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
import 'package:sepot/widgets/sepot_logo.dart';
|
||||
|
||||
@@ -39,10 +43,14 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
|
||||
try {
|
||||
final ok = await LocalAuthentication().authenticate(
|
||||
localizedReason: unlockReason,
|
||||
options: const AuthenticationOptions(stickyAuth: true),
|
||||
// biometricOnly: zámek telefonu (PIN/vzor zařízení) nesmí Šepot
|
||||
// odemknout — obešel by Argon2id ochranu DEK slabším faktorem.
|
||||
options: const AuthenticationOptions(
|
||||
stickyAuth: true, biometricOnly: true),
|
||||
);
|
||||
if (!ok) return; // uživatel zrušil → zůstává PIN
|
||||
await storage.unlockWithBiometrics();
|
||||
_restartMessagingAfterUnlock();
|
||||
if (mounted) context.go('/chats');
|
||||
} catch (_) {
|
||||
// biometrie nedostupná/selhala — PIN je vždy fallback
|
||||
@@ -211,6 +219,19 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Auto-lock službu zastavil a storage zamkl; po odemčení je nutné
|
||||
/// messaging znovu nastartovat. messagingStartProvider drží starou
|
||||
/// „hotovou" hodnotu (start se sám neprovede) → invalidace ho přepočítá
|
||||
/// nad odemčeným storage a spustí start() znovu (jinak žádný příjem ani
|
||||
/// odesílání až do restartu appky).
|
||||
void _restartMessagingAfterUnlock() {
|
||||
ref.invalidate(messagingStartProvider);
|
||||
ref.invalidate(pushInitProvider);
|
||||
// Zabezpečení obrazovky se čte z DB — před odemčením provider nic
|
||||
// neaplikoval, teď má skutečnou hodnotu.
|
||||
ref.invalidate(screenSecurityProvider);
|
||||
}
|
||||
|
||||
Future<void> _verifyPin() async {
|
||||
setState(() => _isVerifying = true);
|
||||
|
||||
@@ -218,14 +239,25 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
|
||||
// „Ověření" PINu = Argon2id unwrap DEK + otevření SQLCipher DB.
|
||||
// Špatný PIN = GCM tag nesedí = DB zůstává nečitelná.
|
||||
await ref.read(storageProvider).unlockWithPin(_pin);
|
||||
_restartMessagingAfterUnlock();
|
||||
if (mounted) context.go('/chats');
|
||||
} on WrongPinException {
|
||||
final locked = await ref.read(storageProvider).pinLockRemaining();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = context.l10n.pinWrong2;
|
||||
_error = locked == null
|
||||
? context.l10n.pinWrong2
|
||||
: context.l10n.pinLockedFor(locked.inSeconds);
|
||||
_pin = '';
|
||||
_isVerifying = false;
|
||||
});
|
||||
HapticFeedback.heavyImpact();
|
||||
} on PinLockedException catch (e) {
|
||||
setState(() {
|
||||
_error = context.l10n.pinLockedFor(e.remaining.inSeconds);
|
||||
_pin = '';
|
||||
_isVerifying = false;
|
||||
});
|
||||
} on StateError {
|
||||
// Wrapped DEK neexistuje — nekonzistentní stav, zpět na onboarding
|
||||
if (mounted) context.go('/onboarding');
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sepot/features/calls/call_screen.dart';
|
||||
import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||
import 'package:sepot/models/call_log.dart';
|
||||
|
||||
/// Ikona a text záznamu hovoru — sdílí chat i seznam chatů, ať se
|
||||
/// formulace nerozejdou.
|
||||
({IconData icon, String text, bool alert}) callLogLabel(
|
||||
AppLocalizations l10n, CallLog log) {
|
||||
final icon = log.video ? Icons.videocam_rounded : Icons.call_rounded;
|
||||
return switch (log.outcome) {
|
||||
CallOutcome.completed => (
|
||||
icon: icon,
|
||||
text: log.video
|
||||
? l10n.callLogVideo(formatCallDuration(log.durationSeconds))
|
||||
: l10n.callLogVoice(formatCallDuration(log.durationSeconds)),
|
||||
alert: false,
|
||||
),
|
||||
CallOutcome.missed => (
|
||||
icon: Icons.phone_missed_rounded,
|
||||
text: l10n.callLogMissed,
|
||||
alert: true,
|
||||
),
|
||||
CallOutcome.unanswered => (
|
||||
icon: icon,
|
||||
text: l10n.callLogUnanswered,
|
||||
alert: false,
|
||||
),
|
||||
CallOutcome.rejected => (
|
||||
icon: Icons.call_end_rounded,
|
||||
text: l10n.callLogRejected,
|
||||
alert: false,
|
||||
),
|
||||
CallOutcome.busy => (icon: icon, text: l10n.callLogBusy, alert: false),
|
||||
CallOutcome.failed => (
|
||||
icon: Icons.error_outline_rounded,
|
||||
text: l10n.callLogFailed,
|
||||
alert: true,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/calls/call_controller.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/models/call_log.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
import 'package:sepot/widgets/glass.dart';
|
||||
|
||||
/// Obrazovka hovoru: vytáčení, zvonění, spojování i hovor samotný.
|
||||
/// Zavře se sama, když hovor skončí.
|
||||
class CallScreen extends ConsumerStatefulWidget {
|
||||
const CallScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CallScreen> createState() => _CallScreenState();
|
||||
}
|
||||
|
||||
class _CallScreenState extends ConsumerState<CallScreen> {
|
||||
// Počítadlo délky hovoru — překreslení jednou za sekundu stačí.
|
||||
Timer? _tick;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tick = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tick?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _status(CallState call) {
|
||||
final l10n = context.l10n;
|
||||
switch (call.phase) {
|
||||
case CallPhase.outgoing:
|
||||
return l10n.callStatusRinging;
|
||||
case CallPhase.incoming:
|
||||
return call.video ? l10n.callIncomingVideo : l10n.callIncomingVoice;
|
||||
case CallPhase.connecting:
|
||||
return l10n.callStatusConnecting;
|
||||
case CallPhase.active:
|
||||
final d = DateTime.now().difference(call.connectedAt!);
|
||||
return formatCallDuration(d.inSeconds);
|
||||
case CallPhase.ended:
|
||||
return switch (call.outcome) {
|
||||
CallOutcome.busy => l10n.callEndedBusy,
|
||||
CallOutcome.rejected => l10n.callEndedRejected,
|
||||
CallOutcome.unanswered => l10n.callEndedUnanswered,
|
||||
CallOutcome.failed => l10n.callEndedFailed,
|
||||
_ => l10n.callEnded,
|
||||
};
|
||||
case CallPhase.idle:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final call = ref.watch(callProvider);
|
||||
final controller = ref.read(callProvider.notifier);
|
||||
ref.listen(callProvider.select((s) => s.phase), (_, phase) {
|
||||
if (phase == CallPhase.idle && context.canPop()) context.pop();
|
||||
});
|
||||
|
||||
final showVideo = call.video &&
|
||||
(call.phase == CallPhase.active || call.phase == CallPhase.connecting);
|
||||
|
||||
return PopScope(
|
||||
// Zpět hovor neukončí ani neschová — jinak by běžel bez ovládání.
|
||||
canPop: !call.busy,
|
||||
child: Scaffold(
|
||||
backgroundColor: SepotColors.hero,
|
||||
body: Stack(
|
||||
children: [
|
||||
if (showVideo)
|
||||
Positioned.fill(
|
||||
child: RTCVideoView(
|
||||
controller.remoteRenderer,
|
||||
objectFit:
|
||||
RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
||||
),
|
||||
),
|
||||
// Positioned.fill: bez něj si Stack dá sloupci volné rozměry a ve
|
||||
// fázi „ukončeno" (žádný ovládací řádek přes celou šířku) se sloupec
|
||||
// smrskne na šířku jména a zarovná vlevo nahoru — avatar uskočí.
|
||||
Positioned.fill(
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 48),
|
||||
if (!showVideo) ...[
|
||||
SepotAvatar(
|
||||
name: call.contactName,
|
||||
seed: call.contactId,
|
||||
size: 112),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
Text(
|
||||
call.contactName,
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_status(call),
|
||||
style: const TextStyle(
|
||||
fontSize: 16, color: SepotColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.lock_rounded,
|
||||
size: 13, color: SepotColors.mist),
|
||||
const SizedBox(width: 4),
|
||||
Text(context.l10n.callEncrypted,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: SepotColors.mist)),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
if (call.phase == CallPhase.incoming)
|
||||
_IncomingActions(
|
||||
onAccept: controller.accept,
|
||||
onReject: controller.reject,
|
||||
)
|
||||
else if (call.busy)
|
||||
_InCallControls(call: call, controller: controller),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showVideo && !call.cameraOff)
|
||||
Positioned(
|
||||
right: 16,
|
||||
top: MediaQuery.paddingOf(context).top + 16,
|
||||
width: 108,
|
||||
height: 152,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: RTCVideoView(
|
||||
controller.localRenderer,
|
||||
mirror: true,
|
||||
objectFit:
|
||||
RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IncomingActions extends StatelessWidget {
|
||||
const _IncomingActions({required this.onAccept, required this.onReject});
|
||||
|
||||
final VoidCallback onAccept;
|
||||
final VoidCallback onReject;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_RoundAction(
|
||||
icon: Icons.call_end_rounded,
|
||||
label: l10n.callReject,
|
||||
color: SepotColors.error,
|
||||
onTap: onReject,
|
||||
),
|
||||
_RoundAction(
|
||||
icon: Icons.call_rounded,
|
||||
label: l10n.callAccept,
|
||||
color: SepotColors.sage,
|
||||
onTap: onAccept,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InCallControls extends StatelessWidget {
|
||||
const _InCallControls({required this.call, required this.controller});
|
||||
|
||||
final CallState call;
|
||||
final CallController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: GlassSurface(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_ToggleAction(
|
||||
icon: call.muted ? Icons.mic_off_rounded : Icons.mic_rounded,
|
||||
label: l10n.callMute,
|
||||
active: call.muted,
|
||||
onTap: controller.toggleMute,
|
||||
),
|
||||
_ToggleAction(
|
||||
icon: Icons.volume_up_rounded,
|
||||
label: l10n.callSpeaker,
|
||||
active: call.speaker,
|
||||
onTap: controller.toggleSpeaker,
|
||||
),
|
||||
if (call.video) ...[
|
||||
_ToggleAction(
|
||||
icon: call.cameraOff
|
||||
? Icons.videocam_off_rounded
|
||||
: Icons.videocam_rounded,
|
||||
label: l10n.callCamera,
|
||||
active: call.cameraOff,
|
||||
onTap: controller.toggleCamera,
|
||||
),
|
||||
_ToggleAction(
|
||||
icon: Icons.cameraswitch_rounded,
|
||||
label: l10n.callSwitchCamera,
|
||||
active: false,
|
||||
onTap: controller.switchCamera,
|
||||
),
|
||||
],
|
||||
_RoundAction(
|
||||
icon: Icons.call_end_rounded,
|
||||
label: l10n.callHangUp,
|
||||
color: SepotColors.error,
|
||||
onTap: controller.hangUp,
|
||||
size: 56,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToggleAction extends StatelessWidget {
|
||||
const _ToggleAction({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.active,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool active;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
toggled: active,
|
||||
label: label,
|
||||
child: InkResponse(
|
||||
onTap: onTap,
|
||||
radius: 32,
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: active
|
||||
? SepotColors.textPrimary
|
||||
: SepotColors.textPrimary.withValues(alpha: 0.1),
|
||||
),
|
||||
child: Icon(icon,
|
||||
color: active ? SepotColors.background : SepotColors.textPrimary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoundAction extends StatelessWidget {
|
||||
const _RoundAction({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
this.size = 72,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: label,
|
||||
child: InkResponse(
|
||||
onTap: onTap,
|
||||
radius: size / 2 + 8,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
||||
child: Icon(icon, color: SepotColors.background, size: size * 0.42),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// „3:12“, u dlouhého hovoru „1:03:12“.
|
||||
String formatCallDuration(int seconds) {
|
||||
final h = seconds ~/ 3600;
|
||||
final m = (seconds % 3600) ~/ 60;
|
||||
final s = (seconds % 60).toString().padLeft(2, '0');
|
||||
return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:$s' : '$m:$s';
|
||||
}
|
||||
@@ -1,163 +1,467 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/core/utils/text_fold.dart';
|
||||
import 'package:sepot/features/chat_list/chat_list_tile.dart';
|
||||
import 'package:sepot/features/chat_list/push_hint_banner.dart';
|
||||
import 'package:sepot/features/contacts/contacts_screen.dart';
|
||||
import 'package:sepot/features/settings/settings_screen.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/models/group.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/drafts_provider.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/groups_provider.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/push_provider.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
import 'package:sepot/widgets/e2e_badge.dart';
|
||||
import 'package:sepot/widgets/glass.dart';
|
||||
|
||||
/// Hlavní obrazovka — seznam konverzací
|
||||
class ChatListScreen extends ConsumerWidget {
|
||||
/// Výška plovoucí navigace včetně okraje — obsah karet pod ní končí.
|
||||
const _navHeight = 66.0 + 18.0;
|
||||
|
||||
/// Domovská obrazovka: karty Chaty / Kontakty / Nastavení pod průsvitnou
|
||||
/// plovoucí navigací.
|
||||
class ChatListScreen extends ConsumerStatefulWidget {
|
||||
const ChatListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<ChatListScreen> createState() => _ChatListScreenState();
|
||||
}
|
||||
|
||||
class _ChatListScreenState extends ConsumerState<ChatListScreen> {
|
||||
var _tab = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Nastartuje krypto + spojení s relayem (idempotentní)
|
||||
ref.watch(messagingStartProvider);
|
||||
// Zaregistruje push callbacky (po startu messagingu) — obnoví registraci,
|
||||
// pokud už distributor byl dřív vybraný. Push zapíná uživatel v nastavení.
|
||||
// Zaregistruje push callbacky (po startu messagingu) a obnoví režim.
|
||||
ref.watch(pushInitProvider);
|
||||
final conversationsAsync = ref.watch(conversationsProvider);
|
||||
final contactsAsync = ref.watch(contactsProvider);
|
||||
final groupsAsync = ref.watch(groupsProvider);
|
||||
final identityAsync = ref.watch(identityProvider);
|
||||
|
||||
final l10n = context.l10n;
|
||||
// S klávesnicí (hledání) by navigace i „Nový chat“ plavaly nad ní.
|
||||
final keyboard = MediaQuery.viewInsetsOf(context).bottom > 0;
|
||||
// Pod plovoucí navigací je ještě systémová lišta telefonu.
|
||||
final tabBottom = _navHeight + MediaQuery.paddingOf(context).bottom;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
context.l10n.commonAppName,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 22,
|
||||
extendBody: true,
|
||||
body: IndexedStack(
|
||||
index: _tab,
|
||||
children: [
|
||||
_ChatsTab(onOpenSettings: () => setState(() => _tab = 2)),
|
||||
// Kontakty a Nastavení mají vlastní Scaffold; spodní okraj, ať
|
||||
// poslední položka neskončí pod navigací.
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: tabBottom),
|
||||
child: const ContactsScreen(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
onPressed: () {
|
||||
// TODO: Implementovat vyhledávání
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
onPressed: () => context.push('/settings'),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: tabBottom),
|
||||
child: const SettingsScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: conversationsAsync.when(
|
||||
data: (conversations) {
|
||||
final active = conversations.where((c) => !c.archived).toList();
|
||||
final archived = conversations.where((c) => c.archived).toList();
|
||||
if (active.isEmpty && archived.isEmpty) {
|
||||
return _buildEmptyState(context);
|
||||
}
|
||||
final hasArchive = archived.isNotEmpty;
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: active.length + (hasArchive ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (hasArchive && index == 0) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.archive_outlined,
|
||||
color: SepotColors.textMuted),
|
||||
title: Text(context.l10n.chatListArchivedCount(archived.length),
|
||||
style: const TextStyle(color: SepotColors.textSecondary)),
|
||||
onTap: () => context.push('/archived'),
|
||||
);
|
||||
}
|
||||
final conv = active[index - (hasArchive ? 1 : 0)];
|
||||
final group = groupsAsync.valueOrNull
|
||||
?.where((g) => g.id == conv.contactId)
|
||||
.firstOrNull;
|
||||
final contact = contactsAsync.valueOrNull
|
||||
?.where((c) => c.publicIdentity.id == conv.contactId)
|
||||
.firstOrNull;
|
||||
final myId = identityAsync.valueOrNull?.id ?? '';
|
||||
final name = group?.name ??
|
||||
contact?.displayName ??
|
||||
context.l10n.commonUnknownContact;
|
||||
|
||||
return ChatListTile(
|
||||
conversation: conv,
|
||||
contactName: name,
|
||||
myId: myId,
|
||||
isGroup: group != null,
|
||||
onTap: () => context.push('/conversation/${conv.contactId}'),
|
||||
onLongPress: () => _showChatActions(context, ref, conv, name),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(color: SepotColors.ember),
|
||||
),
|
||||
error: (e, _) =>
|
||||
Center(child: Text(describeError(context.l10n, e))),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
floatingActionButton: _tab == 0 && !keyboard
|
||||
? FloatingActionButton.extended(
|
||||
onPressed: () => context.push('/contacts'),
|
||||
child: const Icon(Icons.chat_bubble_outline_rounded),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
label: Text(l10n.chatListNewChat,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||
)
|
||||
: null,
|
||||
bottomNavigationBar: keyboard
|
||||
? null
|
||||
: SafeArea(
|
||||
minimum: const EdgeInsets.fromLTRB(14, 0, 14, 12),
|
||||
child: GlassSurface(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: SizedBox(
|
||||
height: 66,
|
||||
child: Row(
|
||||
children: [
|
||||
_NavItem(
|
||||
icon: Icons.chat_bubble_outline_rounded,
|
||||
activeIcon: Icons.chat_bubble_rounded,
|
||||
label: l10n.navChats,
|
||||
selected: _tab == 0,
|
||||
onTap: () => setState(() => _tab = 0),
|
||||
),
|
||||
_NavItem(
|
||||
icon: Icons.people_outline_rounded,
|
||||
activeIcon: Icons.people_rounded,
|
||||
label: l10n.contactsTitle,
|
||||
selected: _tab == 1,
|
||||
onTap: () => setState(() => _tab = 1),
|
||||
),
|
||||
_NavItem(
|
||||
icon: Icons.settings_outlined,
|
||||
activeIcon: Icons.settings_rounded,
|
||||
label: l10n.settingsTitle,
|
||||
selected: _tab == 2,
|
||||
onTap: () => setState(() => _tab = 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(48),
|
||||
class _NavItem extends StatelessWidget {
|
||||
const _NavItem({
|
||||
required this.icon,
|
||||
required this.activeIcon,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final IconData activeIcon;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = selected ? SepotColors.ember : SepotColors.textSecondary;
|
||||
return Expanded(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
onTap();
|
||||
},
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: SepotColors.ember.withValues(alpha: 0.1),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.chat_bubble_outline_rounded,
|
||||
size: 40,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Icon(selected ? activeIcon : icon, size: 23, color: color),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
context.l10n.chatListEmptyTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.textPrimary,
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.chatListEmptySubtitle,
|
||||
style: const TextStyle(color: SepotColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const E2EBadge(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Akce nad konverzací (long-press): ztlumit / archivovat.
|
||||
void _showChatActions(
|
||||
BuildContext context, WidgetRef ref, Conversation conv, String name) {
|
||||
enum _Filter { all, unread, groups }
|
||||
|
||||
/// Karta Chaty: nadpis, hledání, filtry, připnuté a seznam.
|
||||
class _ChatsTab extends ConsumerStatefulWidget {
|
||||
const _ChatsTab({required this.onOpenSettings});
|
||||
|
||||
final VoidCallback onOpenSettings;
|
||||
|
||||
@override
|
||||
ConsumerState<_ChatsTab> createState() => _ChatsTabState();
|
||||
}
|
||||
|
||||
class _ChatsTabState extends ConsumerState<_ChatsTab> {
|
||||
final _search = TextEditingController();
|
||||
var _filter = _Filter.all;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_search.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final conversationsAsync = ref.watch(conversationsProvider);
|
||||
final contacts = ref.watch(contactsProvider).valueOrNull ?? const [];
|
||||
final groups = ref.watch(groupsProvider).valueOrNull ?? const [];
|
||||
final identity = ref.watch(identityProvider).valueOrNull;
|
||||
final myId = identity?.id ?? '';
|
||||
final typing = ref.watch(typingProvider).valueOrNull ?? const {};
|
||||
final drafts = ref.watch(draftsProvider).valueOrNull ?? const {};
|
||||
|
||||
Contact? contactOf(String id) =>
|
||||
contacts.where((c) => c.publicIdentity.id == id).firstOrNull;
|
||||
Group? groupOf(String id) => groups.where((g) => g.id == id).firstOrNull;
|
||||
String nameOf(Conversation c) => ConversationsNotifier.isNotes(c.contactId)
|
||||
? l10n.notesTitle
|
||||
: groupOf(c.contactId)?.name ??
|
||||
contactOf(c.contactId)?.displayName ??
|
||||
l10n.commonUnknownContact;
|
||||
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: conversationsAsync.when(
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(color: SepotColors.ember)),
|
||||
error: (e, _) => Center(child: Text(describeError(l10n, e))),
|
||||
data: (conversations) {
|
||||
final active = conversations.where((c) => !c.archived).toList();
|
||||
final archived = conversations.where((c) => c.archived).length;
|
||||
final query = _search.text.trim();
|
||||
final searching = query.isNotEmpty;
|
||||
|
||||
// Hledání: jméno i obsah zpráv, bez diakritiky. U shody ve zprávě
|
||||
// se místo poslední zprávy ukáže ta nalezená.
|
||||
final hits = <Conversation, Message?>{};
|
||||
if (searching) {
|
||||
final q = fold(query);
|
||||
for (final c in conversations) {
|
||||
if (fold(nameOf(c)).contains(q)) {
|
||||
hits[c] = null;
|
||||
continue;
|
||||
}
|
||||
final m = c.messages.reversed
|
||||
.where((m) =>
|
||||
!m.isDeleted &&
|
||||
m.content != null &&
|
||||
fold(m.content!).contains(q))
|
||||
.firstOrNull;
|
||||
if (m != null) hits[c] = m;
|
||||
}
|
||||
}
|
||||
|
||||
final unreadCount =
|
||||
active.where((c) => c.unreadCount(myId) > 0).length;
|
||||
final pinned = active.where((c) => c.pinned).toList();
|
||||
final list = searching
|
||||
? hits.keys.toList()
|
||||
: active.where((c) {
|
||||
if (_filter == _Filter.all && c.pinned) return false;
|
||||
return switch (_filter) {
|
||||
_Filter.all => true,
|
||||
_Filter.unread => c.unreadCount(myId) > 0,
|
||||
_Filter.groups => groupOf(c.contactId) != null,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
Widget tile(Conversation c) {
|
||||
final group = groupOf(c.contactId);
|
||||
final last = c.lastMessage;
|
||||
final writers = typing[c.contactId] ?? const <String>{};
|
||||
final typingLabel = writers.isEmpty
|
||||
? null
|
||||
: group == null
|
||||
? l10n.chatTyping
|
||||
: writers.length == 1
|
||||
? l10n.chatTypingNamed(
|
||||
(contactOf(writers.first)?.displayName ?? '?')
|
||||
.split(' ')
|
||||
.first)
|
||||
: l10n.chatTypingSeveral;
|
||||
return ChatListTile(
|
||||
conversation: c,
|
||||
contactName: nameOf(c),
|
||||
myId: myId,
|
||||
isGroup: group != null,
|
||||
verified: contactOf(c.contactId)?.verified ?? false,
|
||||
lastAuthor: group != null && last != null
|
||||
? contactOf(last.senderId)?.displayName
|
||||
: null,
|
||||
highlight: hits[c],
|
||||
typingLabel: typingLabel,
|
||||
draft: hits[c] == null ? drafts[c.contactId] : null,
|
||||
onTap: () => context.push('/conversation/${c.contactId}'),
|
||||
onLongPress: () => _showChatActions(c, nameOf(c)),
|
||||
);
|
||||
}
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 12, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(l10n.navChats,
|
||||
style: SepotTheme.largeTitle),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: l10n.qrScanTitle,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: SepotColors.surface,
|
||||
foregroundColor: SepotColors.textPrimary),
|
||||
onPressed: () => context.push('/qr-scan'),
|
||||
icon: const Icon(Icons.qr_code_scanner_rounded,
|
||||
size: 21),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: l10n.settingsTitle,
|
||||
child: GestureDetector(
|
||||
onTap: widget.onOpenSettings,
|
||||
child: SepotAvatar(
|
||||
name: identity?.displayName ?? '?',
|
||||
seed: myId,
|
||||
size: 42,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
|
||||
child: TextField(
|
||||
controller: _search,
|
||||
onChanged: (_) => setState(() {}),
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.chatListSearchHint,
|
||||
prefixIcon: const Icon(Icons.search_rounded,
|
||||
color: SepotColors.textSecondary),
|
||||
suffixIcon: searching
|
||||
? IconButton(
|
||||
tooltip: l10n.commonCancel,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () =>
|
||||
setState(() => _search.clear()),
|
||||
)
|
||||
: null,
|
||||
filled: true,
|
||||
fillColor: SepotColors.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 10),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: const BorderSide(
|
||||
color: SepotColors.ember, width: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bez budíčků appka na pozadí spí — ať uživatel ví, proč
|
||||
// mu zprávy chodí, až když Šepot otevře.
|
||||
const SliverToBoxAdapter(child: PushHintBanner()),
|
||||
if (!searching)
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 52,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
children: [
|
||||
_FilterChip(
|
||||
label: l10n.chatFilterAll,
|
||||
selected: _filter == _Filter.all,
|
||||
onTap: () => setState(() => _filter = _Filter.all),
|
||||
),
|
||||
_FilterChip(
|
||||
label: l10n.chatFilterUnread,
|
||||
count: unreadCount,
|
||||
selected: _filter == _Filter.unread,
|
||||
onTap: () =>
|
||||
setState(() => _filter = _Filter.unread),
|
||||
),
|
||||
_FilterChip(
|
||||
label: l10n.chatFilterGroups,
|
||||
selected: _filter == _Filter.groups,
|
||||
onTap: () =>
|
||||
setState(() => _filter = _Filter.groups),
|
||||
),
|
||||
if (archived > 0)
|
||||
_FilterChip(
|
||||
label: l10n.chatFilterArchive,
|
||||
count: archived,
|
||||
icon: Icons.archive_outlined,
|
||||
selected: false,
|
||||
onTap: () => context.push('/archived'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!searching && _filter == _Filter.all && pinned.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 100,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 4),
|
||||
itemCount: pinned.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 14),
|
||||
itemBuilder: (context, i) {
|
||||
final c = pinned[i];
|
||||
return _PinnedChat(
|
||||
name: nameOf(c),
|
||||
seed: c.contactId,
|
||||
isGroup: groupOf(c.contactId) != null,
|
||||
unread: c.unreadCount(myId) > 0 && !c.isMuted,
|
||||
onTap: () =>
|
||||
context.push('/conversation/${c.contactId}'),
|
||||
onLongPress: () => _showChatActions(c, nameOf(c)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (conversations.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: _EmptyState(),
|
||||
)
|
||||
else if (list.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(48),
|
||||
child: Text(
|
||||
searching ? l10n.chatListNoResults : l10n.chatListFilterEmpty,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: SepotColors.textSecondary),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList.builder(
|
||||
itemCount: list.length,
|
||||
itemBuilder: (context, i) => tile(list[i]),
|
||||
),
|
||||
// Místo pro „Nový chat“ a plovoucí navigaci.
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 170)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Akce nad konverzací (long-press): připnout / ztlumit / archivovat.
|
||||
void _showChatActions(Conversation conv, String name) {
|
||||
final notifier = ref.read(conversationsProvider.notifier);
|
||||
final l10n = context.l10n;
|
||||
HapticFeedback.mediumImpact();
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: SepotColors.surface,
|
||||
@@ -168,13 +472,30 @@ class ChatListScreen extends ConsumerWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 8),
|
||||
child: Text(name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(conv.pinned
|
||||
? Icons.push_pin_outlined
|
||||
: Icons.push_pin_rounded),
|
||||
title: Text(conv.pinned ? l10n.chatListUnpin : l10n.chatListPin),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
notifier.setPinned(conv.contactId, !conv.pinned);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(conv.isMuted
|
||||
? Icons.notifications_active_outlined
|
||||
: Icons.notifications_off_outlined),
|
||||
title: Text(conv.isMuted
|
||||
? context.l10n.chatListUnmute
|
||||
: context.l10n.chatListMute),
|
||||
title: Text(
|
||||
conv.isMuted ? l10n.chatListUnmute : l10n.chatListMute),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
notifier.setMuted(conv.contactId, !conv.isMuted);
|
||||
@@ -182,7 +503,7 @@ class ChatListScreen extends ConsumerWidget {
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.archive_outlined),
|
||||
title: Text(context.l10n.chatListArchive),
|
||||
title: Text(l10n.chatListArchive),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
notifier.setArchived(conv.contactId, true);
|
||||
@@ -194,3 +515,173 @@ class ChatListScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FilterChip extends StatelessWidget {
|
||||
const _FilterChip({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.count,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
final int? count;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? SepotColors.ember : SepotColors.surface,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: selected ? SepotColors.ember : SepotColors.border),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 15, color: SepotColors.textEmphasis),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: selected
|
||||
? SepotColors.emberInk
|
||||
: SepotColors.textEmphasis,
|
||||
),
|
||||
),
|
||||
if (count != null && count! > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: selected
|
||||
? SepotColors.emberInk
|
||||
: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PinnedChat extends StatelessWidget {
|
||||
const _PinnedChat({
|
||||
required this.name,
|
||||
required this.seed,
|
||||
required this.isGroup,
|
||||
required this.unread,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String seed;
|
||||
final bool isGroup;
|
||||
final bool unread;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLongPress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: SizedBox(
|
||||
width: 66,
|
||||
child: Column(
|
||||
children: [
|
||||
isGroup
|
||||
? Container(
|
||||
padding: const EdgeInsets.all(2.5),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(
|
||||
color: unread ? SepotColors.ember : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: SepotGroupAvatar(seed: seed, size: 54),
|
||||
)
|
||||
: SepotAvatar(
|
||||
name: name,
|
||||
seed: seed,
|
||||
size: 54,
|
||||
ring: unread ? SepotColors.ember : SepotColors.border,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
name.split(' ').first,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(48),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: SepotColors.ember.withValues(alpha: 0.1),
|
||||
),
|
||||
child: const Icon(Icons.chat_bubble_outline_rounded,
|
||||
size: 40, color: SepotColors.ember),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
context.l10n.chatListEmptyTitle,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.chatListEmptySubtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: SepotColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const E2EBadge(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/features/calls/call_log_text.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
import 'package:sepot/widgets/message_status_icon.dart';
|
||||
|
||||
/// Dlaždice konverzace v seznamu chatů. Sdílená hlavním i archivovaným
|
||||
/// seznamem, aby se logika (fajfky, náhled, ztlumení) nerozešla.
|
||||
@@ -13,6 +16,20 @@ class ChatListTile extends StatelessWidget {
|
||||
final String contactName;
|
||||
final String myId;
|
||||
final bool isGroup;
|
||||
final bool verified;
|
||||
|
||||
/// Jméno autora poslední zprávy ve skupině (null = 1:1 nebo neznámý).
|
||||
final String? lastAuthor;
|
||||
|
||||
/// Nalezená zpráva při hledání — ukáže se místo poslední.
|
||||
final Message? highlight;
|
||||
|
||||
/// Kdo právě píše (už složený text „píše…“ / „Alice píše…“), null = nikdo.
|
||||
final String? typingLabel;
|
||||
|
||||
/// Rozepsaná zpráva v tomhle chatu — v náhledu má přednost před poslední
|
||||
/// zprávou, jinak by se na ni snadno zapomnělo.
|
||||
final String? draft;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onLongPress;
|
||||
|
||||
@@ -22,37 +39,41 @@ class ChatListTile extends StatelessWidget {
|
||||
required this.contactName,
|
||||
required this.myId,
|
||||
this.isGroup = false,
|
||||
this.verified = false,
|
||||
this.lastAuthor,
|
||||
this.highlight,
|
||||
this.typingLabel,
|
||||
this.draft,
|
||||
required this.onTap,
|
||||
this.onLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lastMsg = conversation.lastMessage;
|
||||
final lastMsg = highlight ?? conversation.lastMessage;
|
||||
final unread = conversation.unreadCount(myId);
|
||||
final muted = conversation.isMuted;
|
||||
final mine = lastMsg != null && lastMsg.isMine(myId);
|
||||
final preview = previewOf(context.l10n, lastMsg);
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
isGroup
|
||||
? Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: SepotColors.surfaceVariant,
|
||||
border: Border.all(color: SepotColors.border),
|
||||
conversation.contactId == AppConstants.notesConversationId
|
||||
? const NotesAvatar(size: 54)
|
||||
: isGroup
|
||||
? SepotGroupAvatar(seed: conversation.contactId, size: 54)
|
||||
: SepotAvatar(
|
||||
name: contactName,
|
||||
seed: conversation.contactId,
|
||||
size: 54,
|
||||
verified: verified,
|
||||
),
|
||||
child: const Icon(Icons.group_rounded,
|
||||
color: SepotColors.ember, size: 24),
|
||||
)
|
||||
: SepotAvatar(name: contactName),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -65,26 +86,28 @@ class ChatListTile extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight:
|
||||
unread > 0 ? FontWeight.w700 : FontWeight.w500,
|
||||
unread > 0 ? FontWeight.w700 : FontWeight.w600,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (muted)
|
||||
if (conversation.pinned)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 6),
|
||||
child: Icon(Icons.notifications_off,
|
||||
size: 15, color: SepotColors.textMuted),
|
||||
child: Icon(Icons.push_pin_rounded,
|
||||
size: 14, color: SepotColors.textMuted),
|
||||
),
|
||||
if (lastMsg != null)
|
||||
Text(
|
||||
_formatTime(context, lastMsg.timestamp),
|
||||
formatListTime(context, lastMsg.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight:
|
||||
unread > 0 ? FontWeight.w600 : FontWeight.w400,
|
||||
color: unread > 0 && !muted
|
||||
? SepotColors.ember
|
||||
: SepotColors.textMuted,
|
||||
: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -92,28 +115,63 @@ class ChatListTile extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
if (lastMsg != null && lastMsg.isMine(myId))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Icon(
|
||||
switch (lastMsg.status) {
|
||||
MessageStatus.sending => Icons.schedule,
|
||||
MessageStatus.sent => Icons.done,
|
||||
MessageStatus.delivered => Icons.done_all,
|
||||
MessageStatus.read => Icons.done_all,
|
||||
MessageStatus.failed => Icons.error_outline,
|
||||
},
|
||||
size: 16,
|
||||
color: lastMsg.status == MessageStatus.failed
|
||||
? SepotColors.error
|
||||
: lastMsg.status == MessageStatus.read
|
||||
? SepotColors.mist
|
||||
: SepotColors.textMuted,
|
||||
),
|
||||
),
|
||||
if (typingLabel != null)
|
||||
Expanded(
|
||||
child: Text(
|
||||
_getLastMessageText(context.l10n, lastMsg),
|
||||
typingLabel!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (draft != null)
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(children: [
|
||||
TextSpan(
|
||||
text: '${context.l10n.chatListDraft}: ',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
TextSpan(text: draft!.replaceAll('\n', ' ')),
|
||||
]),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
if (mine && !lastMsg.isDeleted) ...[
|
||||
MessageStatusIcon(status: lastMsg.status),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
if (preview.icon != null) ...[
|
||||
Icon(preview.icon,
|
||||
size: 15, color: SepotColors.textSecondary),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(children: [
|
||||
if (isGroup && !mine && lastAuthor != null)
|
||||
TextSpan(
|
||||
text: '$lastAuthor: ',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.textEmphasis,
|
||||
),
|
||||
),
|
||||
TextSpan(text: preview.text),
|
||||
]),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: unread > 0
|
||||
@@ -124,27 +182,40 @@ class ChatListTile extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
if (unread > 0)
|
||||
],
|
||||
if (muted)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 6),
|
||||
child: Icon(Icons.notifications_off_outlined,
|
||||
size: 16, color: SepotColors.textMuted),
|
||||
),
|
||||
if (unread > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
constraints: const BoxConstraints(minWidth: 22),
|
||||
height: 22,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
// Ztlumené = šedý badge (počet ano, důraz ne)
|
||||
color: muted
|
||||
? SepotColors.textMuted
|
||||
? SepotColors.surfaceVariant
|
||||
: SepotColors.ember,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
child: Text(
|
||||
unread.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
unread > 99 ? '99+' : unread.toString(),
|
||||
style: TextStyle(
|
||||
color: muted
|
||||
? SepotColors.textSecondary
|
||||
: SepotColors.emberInk,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -155,33 +226,60 @@ class ChatListTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
static String _getLastMessageText(AppLocalizations l10n, Message? msg) {
|
||||
if (msg == null) return l10n.chatListNewConversation;
|
||||
if (msg.isDeleted) return l10n.chatMessageDeleted;
|
||||
if (msg.attachment?.isAudio == true) return l10n.chatVoiceMessagePreview;
|
||||
switch (msg.type) {
|
||||
case MessageType.text:
|
||||
return msg.content ?? '';
|
||||
case MessageType.image:
|
||||
return msg.content?.isNotEmpty == true
|
||||
? l10n.chatListPhotoWithCaptionPreview(msg.content!)
|
||||
: l10n.chatPhotoPreview;
|
||||
case MessageType.file:
|
||||
return l10n.chatListFilePreview(msg.fileName ?? l10n.commonFile);
|
||||
case MessageType.readReceipt:
|
||||
return l10n.chatListReadReceiptPreview;
|
||||
default:
|
||||
return '';
|
||||
/// Náhled zprávy: ikona typu (fotka, soubor, hlas) + text. Ikona místo
|
||||
/// emoji — emoji se na každém systému kreslí jinak a v náhledu ruší.
|
||||
static ({IconData? icon, String text}) previewOf(
|
||||
AppLocalizations l10n, Message? msg) {
|
||||
if (msg == null) return (icon: null, text: l10n.chatListNewConversation);
|
||||
if (msg.isDeleted) {
|
||||
return (icon: Icons.block_rounded, text: l10n.chatMessageDeleted);
|
||||
}
|
||||
if (msg.attachment?.isAudio == true) {
|
||||
return (icon: Icons.mic_none_rounded, text: l10n.chatVoiceMessagePreview);
|
||||
}
|
||||
if (msg.poll != null) {
|
||||
return (icon: Icons.bar_chart_rounded, text: msg.poll!.question);
|
||||
}
|
||||
final call = msg.callLog;
|
||||
if (call != null) {
|
||||
final label = callLogLabel(l10n, call);
|
||||
return (icon: label.icon, text: label.text);
|
||||
}
|
||||
return switch (msg.type) {
|
||||
MessageType.text => (icon: null, text: msg.content ?? ''),
|
||||
MessageType.image => (
|
||||
icon: Icons.photo_outlined,
|
||||
text: msg.content?.isNotEmpty == true
|
||||
? msg.content!
|
||||
: l10n.chatPhotoPreview,
|
||||
),
|
||||
MessageType.file => (
|
||||
icon: Icons.attach_file_rounded,
|
||||
text: msg.fileName ?? l10n.commonFile,
|
||||
),
|
||||
MessageType.readReceipt => (
|
||||
icon: null,
|
||||
text: l10n.chatListReadReceiptPreview,
|
||||
),
|
||||
_ => (icon: null, text: ''),
|
||||
};
|
||||
}
|
||||
|
||||
static String _formatTime(BuildContext context, DateTime time) {
|
||||
static String formatListTime(BuildContext context, DateTime time) {
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(time);
|
||||
if (diff.inMinutes < 1) return context.l10n.chatListTimeNow;
|
||||
if (diff.inHours < 1) return context.l10n.chatListTimeMinutes(diff.inMinutes);
|
||||
if (diff.inDays < 1) return DateFormat.Hm(locale).format(time);
|
||||
// Záporný rozdíl = hodiny odesílatele jdou napřed; „teď“ by lhalo.
|
||||
if (!diff.isNegative && diff.inMinutes < 1) {
|
||||
return context.l10n.chatListTimeNow;
|
||||
}
|
||||
if (!diff.isNegative && diff.inHours < 1) {
|
||||
return context.l10n.chatListTimeMinutes(diff.inMinutes);
|
||||
}
|
||||
final sameDay = now.year == time.year &&
|
||||
now.month == time.month &&
|
||||
now.day == time.day;
|
||||
if (sameDay) return DateFormat.Hm(locale).format(time);
|
||||
if (diff.inDays < 7) return DateFormat.E(locale).format(time);
|
||||
return DateFormat.Md(locale).format(time);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/push_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
/// Upozornění nad seznamem chatů, když budíčky nefungují.
|
||||
///
|
||||
/// Bez pushe Android appku na pozadí uspí a zprávy dorazí, až ji uživatel
|
||||
/// sám otevře — vypadá to jako výpadek doručování, i když relay má všechno
|
||||
/// připravené ve frontě. Proužek je proto informace o stavu, ne reklama:
|
||||
/// zmizí, jakmile si push nastaví, a jde odmítnout natrvalo.
|
||||
class PushHintBanner extends ConsumerWidget {
|
||||
const PushHintBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final health = ref.watch(pushHealthProvider).valueOrNull;
|
||||
final dismissed = ref.watch(pushHintDismissedProvider).valueOrNull ?? true;
|
||||
if (dismissed || health == null || health == PushHealth.ok) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final text = health == PushHealth.failed
|
||||
? context.l10n.pushHintFailed
|
||||
: context.l10n.pushHintOff;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 2),
|
||||
child: Material(
|
||||
color: SepotColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/settings'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 8, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.notifications_off_outlined,
|
||||
size: 18,
|
||||
color: health == PushHealth.failed
|
||||
? SepotColors.ember
|
||||
: SepotColors.textMuted,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 13,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
color: SepotColors.textMuted,
|
||||
tooltip: context.l10n.commonClose,
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(storageProvider)
|
||||
.setSetting('push_hint_dismissed', true);
|
||||
ref.invalidate(pushHintDismissedProvider);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
|
||||
@@ -32,7 +34,8 @@ class _ContactsScreenState extends ConsumerState<ContactsScreen> {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.contactsTitle),
|
||||
toolbarHeight: SepotTheme.largeTitleBarHeight,
|
||||
title: Text(context.l10n.contactsTitle, style: SepotTheme.largeTitle),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.group_add_outlined),
|
||||
@@ -72,6 +75,22 @@ class _ContactsScreenState extends ConsumerState<ContactsScreen> {
|
||||
// Sdílet vlastní QR
|
||||
_buildShareCard(context),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const NotesAvatar(size: 40),
|
||||
title: Text(context.l10n.notesTitle,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(context.l10n.notesSubtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: SepotColors.textSecondary)),
|
||||
onTap: () async {
|
||||
await ref.read(conversationsProvider.notifier).ensureNotes();
|
||||
if (context.mounted) {
|
||||
context.push(
|
||||
'/conversation/${AppConstants.notesConversationId}');
|
||||
}
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// Seznam kontaktů
|
||||
Expanded(
|
||||
child: contactsAsync.when(
|
||||
@@ -115,8 +134,16 @@ class _ContactsScreenState extends ConsumerState<ContactsScreen> {
|
||||
final displayName = contact.displayName.isEmpty
|
||||
? context.l10n.commonUnknownContact
|
||||
: contact.displayName;
|
||||
final id = contact.publicIdentity.id;
|
||||
return ListTile(
|
||||
leading: SepotAvatar(name: displayName),
|
||||
leading: GestureDetector(
|
||||
onTap: () => context.push('/contact/$id'),
|
||||
child: SepotAvatar(
|
||||
name: displayName,
|
||||
seed: id,
|
||||
verified: contact.verified,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
displayName,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
@@ -127,23 +154,15 @@ class _ContactsScreenState extends ConsumerState<ContactsScreen> {
|
||||
contact.sepotId,
|
||||
style: SepotTheme.cryptoText.copyWith(fontSize: 11),
|
||||
),
|
||||
if (contact.verified) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.verified,
|
||||
size: 14,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
color: SepotColors.textMuted,
|
||||
trailing: IconButton(
|
||||
tooltip: context.l10n.chatOpenProfile,
|
||||
icon: const Icon(Icons.person_outline_rounded,
|
||||
color: SepotColors.textSecondary),
|
||||
onPressed: () => context.push('/contact/$id'),
|
||||
),
|
||||
onTap: () {
|
||||
context.push('/conversation/${contact.publicIdentity.id}');
|
||||
},
|
||||
onTap: () => context.push('/conversation/$id'),
|
||||
onLongPress: () =>
|
||||
_showContactOptions(context, contact.id),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/// Akce nad konverzací, kontaktem a skupinou sdílené obrazovkou chatu
|
||||
/// a profily — ať se chování (potvrzení, hlášky, pořadí kroků) nerozejde.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/models/group.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/groups_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
String contactLabel(BuildContext context, Contact contact) =>
|
||||
contact.displayName.isEmpty
|
||||
? context.l10n.commonUnknownContact
|
||||
: contact.displayName;
|
||||
|
||||
void _snack(BuildContext context, String text) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
|
||||
}
|
||||
|
||||
/// Popisky voleb mizících zpráv (sekundy → text); -1 = vypnuto.
|
||||
List<(int, String)> disappearingOptions(BuildContext context) => [
|
||||
(-1, context.l10n.commonOff),
|
||||
(30, context.l10n.chatDisappearing30s),
|
||||
(300, context.l10n.chatDisappearing5m),
|
||||
(3600, context.l10n.chatDisappearing1h),
|
||||
(86400, context.l10n.chatDisappearing1d),
|
||||
(604800, context.l10n.chatDisappearing1w),
|
||||
];
|
||||
|
||||
/// Text aktuálního nastavení mizících zpráv („1 týden“ / „Vypnuto“).
|
||||
String disappearingLabel(BuildContext context, int? seconds) {
|
||||
final options = disappearingOptions(context);
|
||||
return options.where((o) => o.$1 == (seconds ?? -1)).firstOrNull?.$2 ??
|
||||
context.l10n.commonOff;
|
||||
}
|
||||
|
||||
/// Per-konverzace TTL mizících zpráv. Platí pro NOVĚ odeslané zprávy
|
||||
/// (TTL cestuje E2E kanálem, protistrana ho respektuje). Poctivá
|
||||
/// hranice: je to dohoda klientů, ne DRM — screenshot nezastavíš.
|
||||
Future<void> configureDisappearing(
|
||||
BuildContext context, WidgetRef ref, String convId) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
final conv = await storage.loadConversation(convId) ??
|
||||
Conversation(contactId: convId);
|
||||
if (!context.mounted) return;
|
||||
|
||||
final options = disappearingOptions(context);
|
||||
final current = conv.disappearAfterSeconds ?? -1;
|
||||
|
||||
final picked = await showDialog<int>(
|
||||
context: context,
|
||||
builder: (ctx) => SimpleDialog(
|
||||
title: Text(context.l10n.chatDisappearingMessages),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
|
||||
child: Text(
|
||||
context.l10n.chatDisappearingDescription,
|
||||
style:
|
||||
const TextStyle(fontSize: 13, color: SepotColors.textSecondary),
|
||||
),
|
||||
),
|
||||
for (final (value, label) in options)
|
||||
SimpleDialogOption(
|
||||
onPressed: () => Navigator.of(ctx).pop(value),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
value == current
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_off,
|
||||
size: 18,
|
||||
color: value == current
|
||||
? SepotColors.ember
|
||||
: SepotColors.textMuted,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(label),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (picked == null) return;
|
||||
|
||||
conv.disappearAfterSeconds = picked == -1 ? null : picked;
|
||||
await storage.saveConversation(conv);
|
||||
ref.invalidate(conversationProvider(convId));
|
||||
ref.invalidate(conversationsProvider);
|
||||
if (context.mounted) {
|
||||
_snack(
|
||||
context,
|
||||
picked == -1
|
||||
? context.l10n.chatDisappearingDisabled
|
||||
: context.l10n.chatDisappearingEnabled(
|
||||
options.firstWhere((o) => o.$1 == picked).$2));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setContactBlocked(
|
||||
BuildContext context, WidgetRef ref, Contact contact, bool blocked) async {
|
||||
await ref.read(contactsProvider.notifier).setBlocked(contact.id, blocked);
|
||||
if (!context.mounted) return;
|
||||
final jmeno = contactLabel(context, contact);
|
||||
_snack(
|
||||
context,
|
||||
blocked
|
||||
? context.l10n.chatContactBlocked(jmeno)
|
||||
: context.l10n.chatContactUnblocked(jmeno));
|
||||
}
|
||||
|
||||
/// Nahlásit a zablokovat: nejdřív blok (okamžitá ochrana), pak formulář.
|
||||
Future<void> reportAndBlock(
|
||||
BuildContext context, WidgetRef ref, Contact contact) async {
|
||||
final jmeno = contactLabel(context, contact);
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.reportAndBlock),
|
||||
content: Text(context.l10n.reportAndBlockBody(jmeno)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(context.l10n.commonCancel)),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(context.l10n.reportAndBlock,
|
||||
style: const TextStyle(color: SepotColors.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true || !context.mounted) return;
|
||||
|
||||
await setContactBlocked(context, ref, contact, true);
|
||||
if (!context.mounted) return;
|
||||
final q = <String, String>{'contact': contact.id, 'name': contact.displayName};
|
||||
context.push(Uri(path: '/report', queryParameters: q).toString());
|
||||
}
|
||||
|
||||
Future<void> renameGroup(
|
||||
BuildContext context, WidgetRef ref, Group group) async {
|
||||
final controller = TextEditingController(text: group.name);
|
||||
final novy = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.groupsRename),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
maxLength: 64,
|
||||
decoration: InputDecoration(hintText: context.l10n.groupsNameHint),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(context.l10n.commonCancel)),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(controller.text.trim()),
|
||||
child: Text(context.l10n.commonSave),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
controller.dispose();
|
||||
if (novy == null || novy.isEmpty || novy == group.name) return;
|
||||
try {
|
||||
await ref.read(groupsProvider.notifier).rename(group, novy);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
_snack(context,
|
||||
context.l10n.groupsRenameFailed(describeError(context.l10n, e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Odebere člena skupiny (jen správce) po potvrzení.
|
||||
Future<void> removeGroupMember(BuildContext context, WidgetRef ref,
|
||||
Group group, String fingerprint, String name) async {
|
||||
final potvrzeno = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.groupsRemoveMemberConfirmTitle(name)),
|
||||
content: Text(
|
||||
context.l10n.groupsRemoveMemberConfirmBody,
|
||||
style: const TextStyle(color: SepotColors.textSecondary),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: Text(context.l10n.commonCancel)),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: SepotColors.error),
|
||||
child: Text(context.l10n.groupsRemoveAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (potvrzeno != true || !context.mounted) return;
|
||||
await _adminAction(context,
|
||||
() => ref.read(groupsProvider.notifier).removeMember(group, fingerprint));
|
||||
}
|
||||
|
||||
/// Udělí / odebere roli správce (jen správce).
|
||||
Future<void> setGroupAdmin(BuildContext context, WidgetRef ref, Group group,
|
||||
String fingerprint, bool admin) =>
|
||||
_adminAction(
|
||||
context,
|
||||
() => ref
|
||||
.read(groupsProvider.notifier)
|
||||
.setAdmin(group, fingerprint, admin));
|
||||
|
||||
Future<void> _adminAction(
|
||||
BuildContext context, Future<void> Function() action) async {
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
_snack(context,
|
||||
context.l10n.groupsAdminActionFailed(describeError(context.l10n, e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vrací true, když uživatel skupinu opravdu opustil.
|
||||
Future<bool> leaveGroup(
|
||||
BuildContext context, WidgetRef ref, Group group) async {
|
||||
final potvrzeno = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.groupsLeaveConfirmTitle),
|
||||
content: Text(
|
||||
context.l10n.groupsLeaveConfirmBody,
|
||||
style: const TextStyle(color: SepotColors.textSecondary),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: Text(context.l10n.commonCancel)),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: SepotColors.error),
|
||||
child: Text(context.l10n.groupsLeaveAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (potvrzeno != true) return false;
|
||||
try {
|
||||
await ref.read(groupsProvider.notifier).leave(group);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
_snack(context,
|
||||
context.l10n.groupsLeaveFailed(describeError(context.l10n, e)));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/core/utils/text_fold.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/groups_provider.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
|
||||
/// Cíl přeposlání — kontakt nebo skupina, kam jde psát.
|
||||
class _Target {
|
||||
const _Target(this.id, this.name,
|
||||
{this.isGroup = false, this.verified = false, this.isNotes = false});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final bool isGroup;
|
||||
final bool isNotes;
|
||||
final bool verified;
|
||||
}
|
||||
|
||||
/// Výběr chatu a přeposlání zprávy. Zdrojový chat v nabídce není —
|
||||
/// přeposlat zprávu tam, kde už je, nikdo nechce.
|
||||
Future<void> forwardMessage(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Message message, {
|
||||
required String fromId,
|
||||
}) async {
|
||||
final contacts = ref.read(contactsProvider).valueOrNull ?? const [];
|
||||
final groups = ref.read(groupsProvider).valueOrNull ?? const [];
|
||||
final targets = [
|
||||
for (final g in groups)
|
||||
if (!g.left && g.id != fromId) _Target(g.id, g.name, isGroup: true),
|
||||
for (final c in contacts)
|
||||
if (!c.blocked && c.id != fromId)
|
||||
_Target(c.id, c.displayName, verified: c.verified),
|
||||
]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
// Poznámky nahoře: přeposlat si něco „na později“ je nejčastější případ.
|
||||
if (!ConversationsNotifier.isNotes(fromId)) {
|
||||
targets.insert(
|
||||
0,
|
||||
_Target(AppConstants.notesConversationId,
|
||||
context.l10n.notesTitle,
|
||||
isNotes: true));
|
||||
}
|
||||
|
||||
final target = await showModalBottomSheet<_Target>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: SepotColors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (_) => _ForwardPicker(targets: targets),
|
||||
);
|
||||
if (target == null || !context.mounted) return;
|
||||
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final l10n = context.l10n;
|
||||
try {
|
||||
await ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.forwardMessage(message, target.id);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.forwardDone(target.name))));
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(SnackBar(
|
||||
content: Text(l10n.forwardFailed(describeError(l10n, e)))));
|
||||
}
|
||||
}
|
||||
|
||||
class _ForwardPicker extends StatefulWidget {
|
||||
const _ForwardPicker({required this.targets});
|
||||
|
||||
final List<_Target> targets;
|
||||
|
||||
@override
|
||||
State<_ForwardPicker> createState() => _ForwardPickerState();
|
||||
}
|
||||
|
||||
class _ForwardPickerState extends State<_ForwardPicker> {
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final q = fold(_query);
|
||||
final shown = q.isEmpty
|
||||
? widget.targets
|
||||
: widget.targets
|
||||
.where((t) => fold(t.name).contains(q))
|
||||
.toList();
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.7,
|
||||
maxChildSize: 0.92,
|
||||
builder: (context, scroll) => Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: Text(
|
||||
l10n.forwardTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: TextField(
|
||||
autofocus: false,
|
||||
onChanged: (v) => setState(() => _query = v),
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.forwardSearchHint,
|
||||
prefixIcon: const Icon(Icons.search_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: shown.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
l10n.forwardEmpty,
|
||||
style: const TextStyle(color: SepotColors.textSecondary),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: scroll,
|
||||
itemCount: shown.length,
|
||||
itemBuilder: (context, i) {
|
||||
final t = shown[i];
|
||||
return ListTile(
|
||||
leading: t.isNotes
|
||||
? const NotesAvatar(size: 40)
|
||||
: t.isGroup
|
||||
? SepotGroupAvatar(seed: t.id, size: 40)
|
||||
: SepotAvatar(
|
||||
name: t.name,
|
||||
seed: t.id,
|
||||
size: 40,
|
||||
verified: t.verified,
|
||||
),
|
||||
title: Text(t.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: SepotColors.textPrimary)),
|
||||
onTap: () => Navigator.pop(context, t),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/// Fullscreen prohlížeč obrázkové přílohy.
|
||||
/// Fotky konverzace přes celou obrazovku — listování, přiblížení, sdílení.
|
||||
///
|
||||
/// Plné rozlišení se dešifruje až tady, do PAMĚTI (na disk nikdy).
|
||||
/// FLAG_SECURE platí globálně, takže screenshot/škrcení obrazovky
|
||||
@@ -6,67 +6,241 @@
|
||||
/// (XFile.fromData — plaintext jen v cache sdílení).
|
||||
library;
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/models/attachment.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
import 'package:sepot/widgets/glass.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class ImageViewerScreen extends ConsumerStatefulWidget {
|
||||
const ImageViewerScreen({super.key, required this.message});
|
||||
/// Fotky zprávy, které jde otevřít: stažené obrázky, ne smazané.
|
||||
bool isViewableImage(Message m) =>
|
||||
!m.isDeleted &&
|
||||
m.type == MessageType.image &&
|
||||
m.attachment?.state == AttachmentTransferState.ready;
|
||||
|
||||
final Message message;
|
||||
class ImageViewerScreen extends ConsumerStatefulWidget {
|
||||
const ImageViewerScreen({
|
||||
super.key,
|
||||
required this.images,
|
||||
this.initialIndex = 0,
|
||||
});
|
||||
|
||||
/// Fotky od nejstarší po nejnovější (pořadí jako v chatu).
|
||||
final List<Message> images;
|
||||
final int initialIndex;
|
||||
|
||||
/// Otevře fotku [message] a nabídne listování ostatními z [all].
|
||||
static Future<void> open(
|
||||
BuildContext context, Message message, List<Message> all) {
|
||||
final images = all.where(isViewableImage).toList();
|
||||
var index = images.indexWhere((m) => m.id == message.id);
|
||||
if (index < 0) {
|
||||
images
|
||||
..clear()
|
||||
..add(message);
|
||||
index = 0;
|
||||
}
|
||||
return Navigator.of(context).push(
|
||||
PageRouteBuilder<void>(
|
||||
opaque: false,
|
||||
pageBuilder: (_, _, _) =>
|
||||
ImageViewerScreen(images: images, initialIndex: index),
|
||||
transitionsBuilder: (_, anim, _, child) =>
|
||||
FadeTransition(opacity: anim, child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ConsumerState<ImageViewerScreen> createState() => _ImageViewerScreenState();
|
||||
}
|
||||
|
||||
class _ImageViewerScreenState extends ConsumerState<ImageViewerScreen> {
|
||||
late final Future<Uint8List?> _decrypted;
|
||||
late final _pages = PageController(initialPage: widget.initialIndex);
|
||||
late int _index = widget.initialIndex;
|
||||
final _decrypted = <String, Future<Uint8List?>>{};
|
||||
bool _chrome = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_decrypted = ref
|
||||
.read(messagingServiceProvider)
|
||||
.loadDecryptedAttachment(widget.message);
|
||||
void dispose() {
|
||||
_pages.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<Uint8List?> _load(Message m) => _decrypted.putIfAbsent(
|
||||
m.id,
|
||||
() => ref.read(messagingServiceProvider).loadDecryptedAttachment(m));
|
||||
|
||||
Message get _current => widget.images[_index];
|
||||
|
||||
Future<void> _share() async {
|
||||
final meta = widget.message.attachment!;
|
||||
final meta = _current.attachment!;
|
||||
final ext = meta.mime == 'image/png'
|
||||
? 'png'
|
||||
: meta.mime == 'image/gif'
|
||||
? 'gif'
|
||||
: 'jpg';
|
||||
final name = context.l10n.attachmentSharedImageFilename(ext);
|
||||
final bytes = await _decrypted;
|
||||
final bytes = await _load(_current);
|
||||
if (bytes == null) return;
|
||||
await Share.shareXFiles([
|
||||
XFile.fromData(bytes, name: name, mimeType: meta.mime),
|
||||
]);
|
||||
}
|
||||
|
||||
String _senderName(Message m) {
|
||||
final me = ref.read(identityProvider).valueOrNull;
|
||||
if (m.senderId == me?.id) return context.l10n.groupsYou;
|
||||
final contact = (ref.read(contactsProvider).valueOrNull ?? const [])
|
||||
.where((c) => c.publicIdentity.id == m.senderId)
|
||||
.firstOrNull;
|
||||
return contact?.displayName.isNotEmpty == true
|
||||
? contact!.displayName
|
||||
: context.l10n.commonUnknownContact;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
final m = _current;
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final when =
|
||||
'${DateFormat.MMMd(locale).format(m.timestamp)} ${DateFormat.Hm(locale).format(m.timestamp)}';
|
||||
final total = widget.images.length;
|
||||
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.light,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
actions: [
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _chrome = !_chrome),
|
||||
child: PageView.builder(
|
||||
controller: _pages,
|
||||
itemCount: total,
|
||||
onPageChanged: (i) => setState(() => _index = i),
|
||||
itemBuilder: (context, i) => _Page(future: _load(widget.images[i])),
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
top: _chrome ? 0 : -140,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: GlassSurface(
|
||||
border: false,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
4, MediaQuery.paddingOf(context).top + 6, 8, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share_outlined),
|
||||
tooltip: MaterialLocalizations.of(context)
|
||||
.closeButtonTooltip,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
SepotAvatar(name: _senderName(m), seed: m.senderId, size: 34),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(_senderName(m),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
Text(
|
||||
total > 1
|
||||
? '$when · ${context.l10n.chatSearchCounter(_index + 1, total)}'
|
||||
: when,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: SepotColors.textEmphasis),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: context.l10n.commonShare,
|
||||
icon: const Icon(Icons.ios_share_rounded),
|
||||
onPressed: _share,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: FutureBuilder<Uint8List?>(
|
||||
future: _decrypted,
|
||||
),
|
||||
),
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
bottom: _chrome ? 0 : -220,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: (m.content?.isNotEmpty == true || total > 1)
|
||||
? GlassSurface(
|
||||
border: false,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16, 12, 16, MediaQuery.paddingOf(context).bottom + 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (m.content?.isNotEmpty == true)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(m.content!,
|
||||
style: const TextStyle(
|
||||
fontSize: 15, height: 1.4)),
|
||||
),
|
||||
if (total > 1)
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: total,
|
||||
separatorBuilder: (_, _) =>
|
||||
const SizedBox(width: 6),
|
||||
itemBuilder: (context, i) => _Thumb(
|
||||
message: widget.images[i],
|
||||
selected: i == _index,
|
||||
onTap: () => _pages.animateToPage(i,
|
||||
duration:
|
||||
const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Page extends StatelessWidget {
|
||||
const _Page({required this.future});
|
||||
|
||||
final Future<Uint8List?> future;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<Uint8List?>(
|
||||
future: future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(
|
||||
@@ -84,11 +258,50 @@ class _ImageViewerScreenState extends ConsumerState<ImageViewerScreen> {
|
||||
}
|
||||
return InteractiveViewer(
|
||||
maxScale: 6,
|
||||
child: Center(
|
||||
child: Image.memory(bytes, fit: BoxFit.contain),
|
||||
),
|
||||
child: Center(child: Image.memory(bytes, fit: BoxFit.contain)),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Náhled v pásu dole — stačí malý náhled, který přišel uvnitř E2E zprávy.
|
||||
class _Thumb extends StatelessWidget {
|
||||
const _Thumb({
|
||||
required this.message,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final Message message;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final thumb = message.attachment?.thumbnail;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: selected ? SepotColors.ember : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Opacity(
|
||||
opacity: selected ? 1 : 0.6,
|
||||
child: thumb != null
|
||||
? Image.memory(thumb, fit: BoxFit.cover, gaplessPlayback: true)
|
||||
: const ColoredBox(color: SepotColors.surfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'dart:typed_data';
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
@@ -17,6 +18,7 @@ import 'package:sepot/models/attachment.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
String formatBytes(AppLocalizations l10n, int bytes) {
|
||||
@@ -65,13 +67,13 @@ class ImageAttachmentContent extends ConsumerWidget {
|
||||
|
||||
return GestureDetector(
|
||||
onTap: switch (meta.state) {
|
||||
AttachmentTransferState.ready => () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ImageViewerScreen(message: message),
|
||||
// Listovat jde všemi staženými fotkami konverzace.
|
||||
AttachmentTransferState.ready => () => ImageViewerScreen.open(
|
||||
context,
|
||||
message,
|
||||
ref.read(conversationProvider(contactId)).valueOrNull?.messages ??
|
||||
[message],
|
||||
),
|
||||
);
|
||||
},
|
||||
AttachmentTransferState.failed => () => ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.retryAttachmentDownload(contactId, message),
|
||||
@@ -220,7 +222,13 @@ class AudioAttachmentContent extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _AudioState extends ConsumerState<AudioAttachmentContent> {
|
||||
/// Kolečko rychlostí jako u ostatních messengerů; zvolená rychlost platí
|
||||
/// pro všechny hlasovky a pamatuje si ji šifrované nastavení.
|
||||
static const _rates = [1.0, 1.5, 2.0];
|
||||
static const rateSettingKey = 'voice_playback_rate';
|
||||
|
||||
final _player = AudioPlayer();
|
||||
double _rate = 1.0;
|
||||
final _subs = <StreamSubscription<dynamic>>[];
|
||||
bool _loading = false;
|
||||
bool _playing = false;
|
||||
@@ -232,6 +240,7 @@ class _AudioState extends ConsumerState<AudioAttachmentContent> {
|
||||
super.initState();
|
||||
final dur = widget.message.attachment!.durationMs;
|
||||
_total = dur != null ? Duration(milliseconds: dur) : null;
|
||||
_loadRate();
|
||||
_subs.add(_player.onPositionChanged.listen((p) {
|
||||
if (mounted) setState(() => _pos = p);
|
||||
}));
|
||||
@@ -255,6 +264,21 @@ class _AudioState extends ConsumerState<AudioAttachmentContent> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadRate() async {
|
||||
final saved =
|
||||
await ref.read(storageProvider).getSetting<num>(rateSettingKey);
|
||||
if (mounted && saved != null && _rates.contains(saved.toDouble())) {
|
||||
setState(() => _rate = saved.toDouble());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cycleRate() async {
|
||||
final next = _rates[(_rates.indexOf(_rate) + 1) % _rates.length];
|
||||
setState(() => _rate = next);
|
||||
await _player.setPlaybackRate(next);
|
||||
await ref.read(storageProvider).setSetting(rateSettingKey, next);
|
||||
}
|
||||
|
||||
String _fmt(Duration d) {
|
||||
final m = d.inMinutes.remainder(60).toString();
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
@@ -279,6 +303,8 @@ class _AudioState extends ConsumerState<AudioAttachmentContent> {
|
||||
setState(() => _loading = false);
|
||||
if (bytes == null) return;
|
||||
await _player.play(BytesSource(bytes, mimeType: meta.mime));
|
||||
// Rychlost až po startu — část platforem ji před přehráním zahodí.
|
||||
if (_rate != 1.0) await _player.setPlaybackRate(_rate);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -356,7 +382,30 @@ class _AudioState extends ConsumerState<AudioAttachmentContent> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Icon(Icons.mic, size: 13, color: ink.withValues(alpha: .6)),
|
||||
if (_playing || _pos > Duration.zero)
|
||||
GestureDetector(
|
||||
onTap: _cycleRate,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${NumberFormat.decimalPattern(
|
||||
Localizations.localeOf(context).toLanguageTag(),
|
||||
).format(_rate)}×',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: accent),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(Icons.mic,
|
||||
size: 13, color: ink.withValues(alpha: .6)),
|
||||
Text(
|
||||
_playing || _pos > Duration.zero
|
||||
? _fmt(_pos)
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/models/poll.dart';
|
||||
|
||||
/// Vytvoření ankety: otázka, 2–12 možností, jedna nebo víc voleb.
|
||||
/// Vrací hotovou [Poll], nebo null při zrušení.
|
||||
Future<Poll?> showCreatePollSheet(BuildContext context) {
|
||||
return showModalBottomSheet<Poll>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: SepotColors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (_) => const _CreatePollSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
class _CreatePollSheet extends StatefulWidget {
|
||||
const _CreatePollSheet();
|
||||
|
||||
@override
|
||||
State<_CreatePollSheet> createState() => _CreatePollSheetState();
|
||||
}
|
||||
|
||||
class _CreatePollSheetState extends State<_CreatePollSheet> {
|
||||
final _question = TextEditingController();
|
||||
final _options = [TextEditingController(), TextEditingController()];
|
||||
var _multi = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_question.dispose();
|
||||
for (final c in _options) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<String> get _filled => [
|
||||
for (final c in _options)
|
||||
if (c.text.trim().isNotEmpty) c.text.trim(),
|
||||
];
|
||||
|
||||
bool get _valid =>
|
||||
_question.text.trim().isNotEmpty && _filled.length >= Poll.minOptions;
|
||||
|
||||
void _addOption() {
|
||||
if (_options.length >= Poll.maxOptions) return;
|
||||
setState(() => _options.add(TextEditingController()));
|
||||
}
|
||||
|
||||
void _removeOption(int i) {
|
||||
if (_options.length <= Poll.minOptions) return;
|
||||
setState(() => _options.removeAt(i).dispose());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
|
||||
child: SafeArea(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.85),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(l10n.pollCreateTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 18, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _valid
|
||||
? () => Navigator.of(context).pop(Poll(
|
||||
question: _question.text.trim(),
|
||||
options: _filled,
|
||||
multi: _multi,
|
||||
))
|
||||
: null,
|
||||
child: Text(l10n.chatSend),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _question,
|
||||
autofocus: true,
|
||||
maxLength: Poll.maxQuestionLength,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.pollQuestion,
|
||||
counterText: '',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
for (var i = 0; i < _options.length; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: TextField(
|
||||
controller: _options[i],
|
||||
maxLength: Poll.maxOptionLength,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.pollOptionHint(i + 1),
|
||||
counterText: '',
|
||||
suffixIcon: _options.length > Poll.minOptions
|
||||
? IconButton(
|
||||
tooltip: l10n.pollRemoveOption,
|
||||
icon: const Icon(Icons.close_rounded, size: 18),
|
||||
onPressed: () => _removeOption(i),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_options.length < Poll.maxOptions)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: _addOption,
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: Text(l10n.pollAddOption),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _multi,
|
||||
onChanged: (v) => setState(() => _multi = v),
|
||||
activeTrackColor: SepotColors.ember,
|
||||
title: Text(l10n.pollAllowMultiple),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/models/poll.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
|
||||
/// Anketa v bublině: otázka, možnosti s pruhem výsledků a kolečky
|
||||
/// hlasujících. Klepnutí na možnost hlasuje (znovu = zrušit).
|
||||
class PollContent extends StatelessWidget {
|
||||
const PollContent({
|
||||
super.key,
|
||||
required this.poll,
|
||||
required this.onEmber,
|
||||
required this.myId,
|
||||
required this.nameOf,
|
||||
required this.onVote,
|
||||
});
|
||||
|
||||
final Poll poll;
|
||||
|
||||
/// Kreslí se na korálové bublině (moje anketa).
|
||||
final bool onEmber;
|
||||
final String myId;
|
||||
final String Function(String fingerprint) nameOf;
|
||||
final void Function(List<int> choice)? onVote;
|
||||
|
||||
static const _maxFaces = 3;
|
||||
|
||||
void _toggle(int index) {
|
||||
final mine = poll.votes[myId] ?? const <int>[];
|
||||
final List<int> next;
|
||||
if (poll.multi) {
|
||||
next = mine.contains(index)
|
||||
? (mine.toList()..remove(index))
|
||||
: [...mine, index];
|
||||
} else {
|
||||
next = mine.contains(index) ? const [] : [index];
|
||||
}
|
||||
HapticFeedback.selectionClick();
|
||||
onVote?.call(next);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ink = onEmber ? SepotColors.emberInk : SepotColors.textPrimary;
|
||||
final soft = onEmber
|
||||
? SepotColors.emberInk.withValues(alpha: 0.65)
|
||||
: SepotColors.textSecondary;
|
||||
final track = onEmber
|
||||
? SepotColors.emberInk.withValues(alpha: 0.12)
|
||||
: SepotColors.background.withValues(alpha: 0.55);
|
||||
final fill = onEmber
|
||||
? SepotColors.emberInk.withValues(alpha: 0.22)
|
||||
: SepotColors.ember.withValues(alpha: 0.24);
|
||||
final mine = poll.votes[myId] ?? const <int>[];
|
||||
final total = poll.voterCount;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.bar_chart_rounded, size: 15, color: soft),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
'${poll.multi ? context.l10n.pollMulti : context.l10n.pollSingle}'
|
||||
' · ${context.l10n.pollVoters(total)}',
|
||||
style: TextStyle(fontSize: 12, color: soft),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(poll.question,
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w700, color: ink)),
|
||||
const SizedBox(height: 10),
|
||||
for (var i = 0; i < poll.options.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 6),
|
||||
_OptionRow(
|
||||
label: poll.options[i],
|
||||
count: poll.countFor(i),
|
||||
share: total == 0 ? 0 : poll.countFor(i) / total,
|
||||
chosen: mine.contains(i),
|
||||
voters: poll.votersFor(i),
|
||||
nameOf: nameOf,
|
||||
ink: ink,
|
||||
track: track,
|
||||
fill: fill,
|
||||
onEmber: onEmber,
|
||||
onTap: onVote == null ? null : () => _toggle(i),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OptionRow extends StatelessWidget {
|
||||
const _OptionRow({
|
||||
required this.label,
|
||||
required this.count,
|
||||
required this.share,
|
||||
required this.chosen,
|
||||
required this.voters,
|
||||
required this.nameOf,
|
||||
required this.ink,
|
||||
required this.track,
|
||||
required this.fill,
|
||||
required this.onEmber,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int count;
|
||||
final double share;
|
||||
final bool chosen;
|
||||
final List<String> voters;
|
||||
final String Function(String) nameOf;
|
||||
final Color ink;
|
||||
final Color track;
|
||||
final Color fill;
|
||||
final bool onEmber;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = onEmber ? SepotColors.emberInk : SepotColors.ember;
|
||||
return Semantics(
|
||||
button: onTap != null,
|
||||
selected: chosen,
|
||||
label: '$label, $count',
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 44),
|
||||
decoration: BoxDecoration(
|
||||
color: track,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: chosen ? accent : Colors.transparent,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: AnimatedFractionallySizedBox(
|
||||
duration: const Duration(milliseconds: 280),
|
||||
curve: Curves.easeOutCubic,
|
||||
widthFactor: share.clamp(0.0, 1.0),
|
||||
heightFactor: 1,
|
||||
child: ColoredBox(color: fill),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
chosen
|
||||
? Icons.check_circle_rounded
|
||||
: Icons.radio_button_unchecked_rounded,
|
||||
size: 18,
|
||||
color: chosen ? accent : ink.withValues(alpha: 0.45),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(label,
|
||||
style: TextStyle(fontSize: 14, color: ink)),
|
||||
),
|
||||
if (voters.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
_Faces(voters: voters, nameOf: nameOf),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.w700, color: ink),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Překrývající se kolečka prvních hlasujících.
|
||||
class _Faces extends StatelessWidget {
|
||||
const _Faces({required this.voters, required this.nameOf});
|
||||
|
||||
final List<String> voters;
|
||||
final String Function(String) nameOf;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shown = voters.take(PollContent._maxFaces).toList();
|
||||
return SizedBox(
|
||||
width: 20.0 + (shown.length - 1) * 13,
|
||||
height: 20,
|
||||
child: Stack(
|
||||
children: [
|
||||
for (var i = 0; i < shown.length; i++)
|
||||
Positioned(
|
||||
left: i * 13.0,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: SepotColors.surface, width: 1.5),
|
||||
),
|
||||
child: SepotAvatar(
|
||||
name: nameOf(shown[i]), seed: shown[i], size: 17),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,17 @@ import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/groups_provider.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
|
||||
/// Založení skupiny: jméno + výběr členů.
|
||||
/// Založení skupiny: jméno + výběr členů. S `addToGroupId`
|
||||
/// jde o přidání lidí do existující skupiny — bez jména, stávající členové
|
||||
/// v nabídce nejsou a strop počítá s nimi.
|
||||
///
|
||||
/// Vybrat jde jen kontakty s doručovací adresou (routing) — bez ní jim
|
||||
/// fanout nemá kam doručit. Kontakty bez routingu jsou v seznamu vidět,
|
||||
/// ale zašedlé s vysvětlením.
|
||||
class CreateGroupScreen extends ConsumerStatefulWidget {
|
||||
const CreateGroupScreen({super.key});
|
||||
const CreateGroupScreen({super.key, this.addToGroupId});
|
||||
|
||||
final String? addToGroupId;
|
||||
|
||||
@override
|
||||
ConsumerState<CreateGroupScreen> createState() => _CreateGroupScreenState();
|
||||
@@ -37,24 +41,32 @@ class _CreateGroupScreenState extends ConsumerState<CreateGroupScreen> {
|
||||
c.publicIdentity.accountId != null &&
|
||||
c.publicIdentity.deliveryAddress != null;
|
||||
|
||||
Future<void> _create(List<Contact> contacts) async {
|
||||
Future<void> _submit(List<Contact> contacts, Group? existing) async {
|
||||
final name = _nameController.text.trim();
|
||||
if (name.isEmpty || _selected.isEmpty || _creating) return;
|
||||
if ((existing == null && name.isEmpty) || _selected.isEmpty || _creating) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _creating = true);
|
||||
final members = contacts.where((c) => _selected.contains(c.id)).toList();
|
||||
final groups = ref.read(groupsProvider.notifier);
|
||||
try {
|
||||
final members =
|
||||
contacts.where((c) => _selected.contains(c.id)).toList();
|
||||
final group =
|
||||
await ref.read(groupsProvider.notifier).createGroup(name, members);
|
||||
if (existing != null) {
|
||||
await groups.addMembers(existing, members);
|
||||
if (mounted) context.pop();
|
||||
} else {
|
||||
final group = await groups.createGroup(name, members);
|
||||
if (mounted) context.pushReplacement('/conversation/${group.id}');
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _creating = false);
|
||||
if (mounted) {
|
||||
final detail = describeError(context.l10n, e);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n
|
||||
.groupsCreateFailed(describeError(context.l10n, e))),
|
||||
content: Text(existing != null
|
||||
? context.l10n.groupsAddMembersFailed(detail)
|
||||
: context.l10n.groupsCreateFailed(detail)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -63,20 +75,30 @@ class _CreateGroupScreenState extends ConsumerState<CreateGroupScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final adding = widget.addToGroupId != null;
|
||||
final existing = adding
|
||||
? ref.watch(groupProvider(widget.addToGroupId!)).valueOrNull
|
||||
: null;
|
||||
final contactsAsync = ref.watch(contactsProvider);
|
||||
final contacts = (contactsAsync.valueOrNull ?? [])
|
||||
.where((c) => !c.blocked)
|
||||
.where((c) => existing == null || !existing.memberIds.contains(c.id))
|
||||
.toList();
|
||||
final canCreate = _nameController.text.trim().isNotEmpty &&
|
||||
// Já jsem v memberIds existující skupiny; nová skupina mě přidá sama.
|
||||
final capacity = Group.maxMembers - (existing?.memberIds.length ?? 1);
|
||||
final canSubmit =
|
||||
(adding ? existing != null : _nameController.text.trim().isNotEmpty) &&
|
||||
_selected.isNotEmpty &&
|
||||
!_creating;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.groupsNewGroupTitle),
|
||||
title: Text(adding
|
||||
? context.l10n.groupsAddMembers
|
||||
: context.l10n.groupsNewGroupTitle),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: canCreate ? () => _create(contacts) : null,
|
||||
onPressed: canSubmit ? () => _submit(contacts, existing) : null,
|
||||
child: _creating
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
@@ -84,12 +106,17 @@ class _CreateGroupScreenState extends ConsumerState<CreateGroupScreen> {
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: SepotColors.ember),
|
||||
)
|
||||
: Text(context.l10n.groupsCreateAction),
|
||||
: Text(adding
|
||||
? context.l10n.groupsAddMembersAction
|
||||
: context.l10n.groupsCreateAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (adding)
|
||||
const SizedBox(height: 16)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
@@ -109,8 +136,7 @@ class _CreateGroupScreenState extends ConsumerState<CreateGroupScreen> {
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.groupsMembersCounter(
|
||||
_selected.length, Group.maxMembers - 1),
|
||||
context.l10n.groupsMembersCounter(_selected.length, capacity),
|
||||
style: const TextStyle(
|
||||
color: SepotColors.textSecondary, fontSize: 13),
|
||||
),
|
||||
@@ -124,7 +150,9 @@ class _CreateGroupScreenState extends ConsumerState<CreateGroupScreen> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(
|
||||
context.l10n.groupsEmptyContactsHint,
|
||||
adding
|
||||
? context.l10n.groupsNoContactsToAdd
|
||||
: context.l10n.groupsEmptyContactsHint,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: SepotColors.textSecondary),
|
||||
@@ -142,8 +170,7 @@ class _CreateGroupScreenState extends ConsumerState<CreateGroupScreen> {
|
||||
onChanged: routable
|
||||
? (v) => setState(() {
|
||||
if (v == true) {
|
||||
if (_selected.length <
|
||||
Group.maxMembers - 1) {
|
||||
if (_selected.length < capacity) {
|
||||
_selected.add(c.id);
|
||||
}
|
||||
} else {
|
||||
@@ -151,7 +178,8 @@ class _CreateGroupScreenState extends ConsumerState<CreateGroupScreen> {
|
||||
}
|
||||
})
|
||||
: null,
|
||||
secondary: SepotAvatar(name: c.displayName, size: 40),
|
||||
secondary: SepotAvatar(
|
||||
name: c.displayName, seed: c.id, size: 40),
|
||||
title: Text(
|
||||
c.displayName,
|
||||
style: TextStyle(
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/features/conversation/chat_actions.dart';
|
||||
import 'package:sepot/features/profile/profile_widgets.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/groups_provider.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
|
||||
/// Profil kontaktu: kdo to je, jestli je ověřený, co jsme si poslali,
|
||||
/// ve kterých skupinách jsme spolu a nastavení konverzace.
|
||||
class ContactProfileScreen extends ConsumerWidget {
|
||||
const ContactProfileScreen({
|
||||
super.key,
|
||||
required this.contactId,
|
||||
this.fromChat = false,
|
||||
});
|
||||
|
||||
final String contactId;
|
||||
|
||||
/// Otevřeno z hlavičky chatu — „Zpráva“ se jen vrátí, nic nepřidá
|
||||
/// do zásobníku obrazovek.
|
||||
final bool fromChat;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = context.l10n;
|
||||
final contact = (ref.watch(contactsProvider).valueOrNull ?? const [])
|
||||
.where((c) => c.publicIdentity.id == contactId)
|
||||
.firstOrNull;
|
||||
final conv = ref.watch(conversationProvider(contactId)).valueOrNull;
|
||||
final groups = (ref.watch(groupsProvider).valueOrNull ?? const [])
|
||||
.where((g) => !g.left && g.memberIds.contains(contactId))
|
||||
.toList();
|
||||
|
||||
if (contact == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: Center(child: Text(l10n.commonUnknownContact)),
|
||||
);
|
||||
}
|
||||
|
||||
final name = contactLabel(context, contact);
|
||||
final muted = conv?.isMuted ?? false;
|
||||
|
||||
void openChat({bool search = false}) {
|
||||
if (fromChat) {
|
||||
Navigator.of(context).pop(search ? ProfileResult.openSearch : null);
|
||||
} else {
|
||||
context.push('/conversation/$contactId${search ? '?search=1' : ''}');
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
ProfileHero(
|
||||
avatar: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: contact.verified
|
||||
? SepotColors.sage
|
||||
: SepotColors.border,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: SepotAvatar(
|
||||
name: name,
|
||||
seed: contactId,
|
||||
size: 108,
|
||||
verified: contact.verified,
|
||||
),
|
||||
),
|
||||
title: name,
|
||||
lines: [
|
||||
SelectableText(
|
||||
contact.publicIdentity.fingerprint.short,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontMono,
|
||||
fontSize: 13,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
_VerificationLine(
|
||||
verified: contact.verified,
|
||||
onVerify: () => context.push('/verify/$contactId'),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ProfileAction(
|
||||
icon: Icons.chat_bubble_outline_rounded,
|
||||
label: l10n.profileMessage,
|
||||
accent: true,
|
||||
onTap: contact.blocked ? null : openChat,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ProfileAction(
|
||||
icon: Icons.search_rounded,
|
||||
label: l10n.profileSearch,
|
||||
onTap: () => openChat(search: true),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ProfileAction(
|
||||
icon: muted
|
||||
? Icons.notifications_active_outlined
|
||||
: Icons.notifications_off_outlined,
|
||||
label: muted ? l10n.profileUnmute : l10n.chatListMute,
|
||||
onTap: conv == null
|
||||
? null
|
||||
: () => ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.setMuted(contactId, !muted)
|
||||
.then((_) => ref
|
||||
.invalidate(conversationProvider(contactId))),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ProfileAction(
|
||||
icon: Icons.timer_outlined,
|
||||
label: l10n.profileDisappearing,
|
||||
onTap: () =>
|
||||
configureDisappearing(context, ref, contactId),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
if (conv != null) ...[
|
||||
SharedMediaCard(messages: conv.messages),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
if (groups.isNotEmpty) ...[
|
||||
ProfileCard(
|
||||
title: l10n.profileCommonGroups,
|
||||
trailing: '${groups.length}',
|
||||
children: [
|
||||
for (final g in groups)
|
||||
ProfileRow(
|
||||
leading: SepotGroupAvatar(seed: g.id, size: 38),
|
||||
label: g.name,
|
||||
subtitle: l10n.groupsMemberCount(g.memberIds.length),
|
||||
onTap: () => context.push('/conversation/${g.id}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
ProfileCard(
|
||||
children: [
|
||||
ProfileRow(
|
||||
label: l10n.chatDisappearingMessages,
|
||||
value:
|
||||
disappearingLabel(context, conv?.disappearAfterSeconds),
|
||||
onTap: () =>
|
||||
configureDisappearing(context, ref, contactId),
|
||||
),
|
||||
ProfileRow(
|
||||
label: l10n.profileSafetyNumber,
|
||||
subtitle: contact.verified
|
||||
? l10n.verifyVerified
|
||||
: l10n.profileSafetyNumberHint,
|
||||
onTap: () => context.push('/verify/$contactId'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
ProfileCard(
|
||||
children: [
|
||||
ProfileRow(
|
||||
label: contact.blocked
|
||||
? l10n.chatMenuUnblockContact
|
||||
: l10n.chatMenuBlockContact,
|
||||
danger: !contact.blocked,
|
||||
onTap: () => setContactBlocked(
|
||||
context, ref, contact, !contact.blocked),
|
||||
),
|
||||
if (!contact.blocked)
|
||||
ProfileRow(
|
||||
label: l10n.reportAndBlock,
|
||||
danger: true,
|
||||
onTap: () => reportAndBlock(context, ref, contact),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VerificationLine extends StatelessWidget {
|
||||
const _VerificationLine({required this.verified, required this.onVerify});
|
||||
|
||||
final bool verified;
|
||||
final VoidCallback onVerify;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (verified) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.verified_user_outlined,
|
||||
size: 16, color: SepotColors.sage),
|
||||
const SizedBox(width: 6),
|
||||
Text(context.l10n.profileVerifiedInPerson,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.sage)),
|
||||
],
|
||||
);
|
||||
}
|
||||
return TextButton.icon(
|
||||
onPressed: onVerify,
|
||||
icon: const Icon(Icons.shield_outlined, size: 16),
|
||||
label: Text(context.l10n.profileVerifyNow),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/features/conversation/chat_actions.dart';
|
||||
import 'package:sepot/features/profile/profile_widgets.dart';
|
||||
import 'package:sepot/models/group.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/groups_provider.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
|
||||
/// Profil skupiny: členové a jejich ověření, přidání lidí, sdílená média,
|
||||
/// nastavení konverzace a odchod.
|
||||
///
|
||||
/// Členství, role a jméno mění správci. Ne-správce
|
||||
/// vidí akce neaktivní a pod členy vysvětlení; přidávat lidi smí i on, dokud
|
||||
/// je ve skupině někdo se starší verzí (v1 to umí a skupina se nesmí
|
||||
/// rozejít).
|
||||
class GroupProfileScreen extends ConsumerWidget {
|
||||
const GroupProfileScreen({
|
||||
super.key,
|
||||
required this.groupId,
|
||||
this.fromChat = false,
|
||||
});
|
||||
|
||||
final String groupId;
|
||||
final bool fromChat;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = context.l10n;
|
||||
final group = ref.watch(groupProvider(groupId)).valueOrNull;
|
||||
final conv = ref.watch(conversationProvider(groupId)).valueOrNull;
|
||||
final contacts = ref.watch(contactsProvider).valueOrNull ?? const [];
|
||||
final myId = ref.watch(identityProvider).valueOrNull?.id;
|
||||
|
||||
if (group == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: const Center(
|
||||
child: CircularProgressIndicator(color: SepotColors.ember)),
|
||||
);
|
||||
}
|
||||
|
||||
final me = myId ?? '';
|
||||
final amAdmin = group.isAdmin(me);
|
||||
final active = !group.left;
|
||||
final canAdd = active && (amAdmin || !group.isEnforced(me));
|
||||
final canRename = active && amAdmin;
|
||||
final rejectedAt = group.rejectedOpAt;
|
||||
final rejectedRecently = rejectedAt != null &&
|
||||
DateTime.now().difference(rejectedAt) < _rejectedNoticeFor;
|
||||
|
||||
final muted = conv?.isMuted ?? false;
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
|
||||
void openChat({bool search = false}) {
|
||||
if (fromChat) {
|
||||
Navigator.of(context).pop(search ? ProfileResult.openSearch : null);
|
||||
} else {
|
||||
context.push('/conversation/$groupId${search ? '?search=1' : ''}');
|
||||
}
|
||||
}
|
||||
|
||||
// Já první, pak ověření, pak ostatní — kdo je důvěryhodný, je nahoře.
|
||||
final members = [...group.memberIds]..sort((a, b) {
|
||||
if (a == myId) return -1;
|
||||
if (b == myId) return 1;
|
||||
final va = contacts.any((c) => c.id == a && c.verified) ? 0 : 1;
|
||||
final vb = contacts.any((c) => c.id == b && c.verified) ? 0 : 1;
|
||||
return va.compareTo(vb);
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
body: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
ProfileHero(
|
||||
avatar: SepotGroupAvatar(seed: groupId, size: 104),
|
||||
title: group.name,
|
||||
onTitleTap:
|
||||
canRename ? () => renameGroup(context, ref, group) : null,
|
||||
lines: [
|
||||
Text(
|
||||
group.left
|
||||
? l10n.groupsYouLeftGroup
|
||||
: l10n.profileGroupSubtitle(
|
||||
l10n.groupsMemberCount(group.memberIds.length),
|
||||
DateFormat.yMMMd(locale).format(group.createdAt)),
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: SepotColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ProfileAction(
|
||||
icon: Icons.person_add_alt_1_outlined,
|
||||
label: l10n.profileAdd,
|
||||
accent: true,
|
||||
onTap: canAdd
|
||||
? () => context.push('/group/$groupId/add')
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ProfileAction(
|
||||
icon: Icons.search_rounded,
|
||||
label: l10n.profileSearch,
|
||||
onTap: () => openChat(search: true),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ProfileAction(
|
||||
icon: muted
|
||||
? Icons.notifications_active_outlined
|
||||
: Icons.notifications_off_outlined,
|
||||
label: muted ? l10n.profileUnmute : l10n.chatListMute,
|
||||
onTap: conv == null
|
||||
? null
|
||||
: () => ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.setMuted(groupId, !muted)
|
||||
.then((_) => ref
|
||||
.invalidate(conversationProvider(groupId))),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ProfileAction(
|
||||
icon: Icons.timer_outlined,
|
||||
label: l10n.profileDisappearing,
|
||||
onTap: group.left
|
||||
? null
|
||||
: () => configureDisappearing(context, ref, groupId),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
if (active && group.needsResync) ...[
|
||||
_GroupNotice(
|
||||
icon: Icons.sync_rounded,
|
||||
text: l10n.groupsResyncNotice,
|
||||
color: SepotColors.mist,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
if (active && rejectedRecently) ...[
|
||||
_GroupNotice(
|
||||
icon: Icons.gpp_maybe_outlined,
|
||||
text: l10n.groupsRejectedChangeNotice,
|
||||
color: SepotColors.warning,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
ProfileCard(
|
||||
title: l10n.groupsMembers,
|
||||
trailing: l10n.profileMembersOfMax(
|
||||
group.memberIds.length, Group.maxMembers),
|
||||
children: [
|
||||
for (final f in members)
|
||||
_MemberRow(
|
||||
group: group,
|
||||
fingerprint: f,
|
||||
isMe: f == myId,
|
||||
canManage: active && amAdmin,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (active && !amAdmin)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.info_outline_rounded,
|
||||
size: 16, color: SepotColors.textMuted),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
group.isEnforced(me)
|
||||
? l10n.groupsAdminOnlyHint
|
||||
: l10n.groupsAdminOnlyCompatHint,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: SepotColors.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
if (conv != null) ...[
|
||||
SharedMediaCard(messages: conv.messages),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
ProfileCard(
|
||||
children: [
|
||||
ProfileRow(
|
||||
label: l10n.chatDisappearingMessages,
|
||||
value: disappearingLabel(
|
||||
context, conv?.disappearAfterSeconds),
|
||||
onTap: group.left
|
||||
? null
|
||||
: () => configureDisappearing(context, ref, groupId),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!group.left) ...[
|
||||
const SizedBox(height: 14),
|
||||
ProfileCard(
|
||||
children: [
|
||||
ProfileRow(
|
||||
label: l10n.groupsLeave,
|
||||
danger: true,
|
||||
onTap: () async {
|
||||
if (await leaveGroup(context, ref, group) &&
|
||||
context.mounted) {
|
||||
context.go('/chats');
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Jak dlouho po odmítnuté neoprávněné změně skupiny o ní profil mluví.
|
||||
const _rejectedNoticeFor = Duration(days: 7);
|
||||
|
||||
/// Upozornění nad seznamem členů (resync, odmítnutá změna).
|
||||
class _GroupNotice extends StatelessWidget {
|
||||
const _GroupNotice({
|
||||
required this.icon,
|
||||
required this.text,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String text;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: color.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: color),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(text,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: SepotColors.textEmphasis)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemberRow extends ConsumerWidget {
|
||||
const _MemberRow({
|
||||
required this.group,
|
||||
required this.fingerprint,
|
||||
required this.isMe,
|
||||
required this.canManage,
|
||||
});
|
||||
|
||||
final Group group;
|
||||
final String fingerprint;
|
||||
final bool isMe;
|
||||
|
||||
/// Dívám se jako správce — řádek otevře nabídku správcovských akcí.
|
||||
final bool canManage;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = context.l10n;
|
||||
final identity = ref.watch(identityProvider).valueOrNull;
|
||||
final contact = (ref.watch(contactsProvider).valueOrNull ?? const [])
|
||||
.where((c) => c.id == fingerprint)
|
||||
.firstOrNull;
|
||||
final name = isMe
|
||||
? l10n.groupsYou
|
||||
: contact == null
|
||||
? 'SEP-${fingerprint.substring(0, fingerprint.length.clamp(0, 10))}'
|
||||
: contactLabel(context, contact);
|
||||
final verified = contact?.verified ?? false;
|
||||
final isAdmin = group.isAdmin(fingerprint);
|
||||
final isFounder = fingerprint == group.createdBy;
|
||||
// Správce má přednost: zakladatel, který roli předal, už nic neřídí.
|
||||
final badge = isAdmin
|
||||
? l10n.groupsAdmin
|
||||
: isFounder && !group.provisional
|
||||
? l10n.groupsFounder
|
||||
: null;
|
||||
|
||||
void openContact() => context.push('/contact/$fingerprint');
|
||||
|
||||
return ProfileRow(
|
||||
leading: SepotAvatar(
|
||||
name: isMe ? (identity?.displayName ?? name) : name,
|
||||
seed: fingerprint,
|
||||
size: 40,
|
||||
verified: verified,
|
||||
),
|
||||
label: name,
|
||||
subtitle: isMe
|
||||
? null
|
||||
: verified
|
||||
? l10n.verifyVerified
|
||||
: l10n.verifyUnverified,
|
||||
trailing: badge == null
|
||||
? null
|
||||
: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: isAdmin
|
||||
? SepotColors.ember.withValues(alpha: 0.12)
|
||||
: SepotColors.border,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(badge,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isAdmin
|
||||
? SepotColors.ember
|
||||
: SepotColors.textSecondary)),
|
||||
),
|
||||
onTap: isMe
|
||||
? null
|
||||
: canManage
|
||||
? () => _showMemberActions(context, ref,
|
||||
name: name,
|
||||
isAdmin: isAdmin,
|
||||
onOpenContact: contact == null ? null : openContact)
|
||||
: contact == null
|
||||
? null
|
||||
: openContact,
|
||||
);
|
||||
}
|
||||
|
||||
void _showMemberActions(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required String name,
|
||||
required bool isAdmin,
|
||||
required VoidCallback? onOpenContact,
|
||||
}) {
|
||||
final l10n = context.l10n;
|
||||
HapticFeedback.mediumImpact();
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: SepotColors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 8),
|
||||
child: Text(name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
if (onOpenContact != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_outline_rounded),
|
||||
title: Text(l10n.groupsViewContact),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
onOpenContact();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(isAdmin
|
||||
? Icons.remove_moderator_outlined
|
||||
: Icons.add_moderator_outlined),
|
||||
title: Text(
|
||||
isAdmin ? l10n.groupsDismissAdmin : l10n.groupsMakeAdmin),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
setGroupAdmin(context, ref, group, fingerprint, !isAdmin);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_remove_outlined,
|
||||
color: SepotColors.error),
|
||||
title: Text(l10n.groupsRemoveMember,
|
||||
style: const TextStyle(color: SepotColors.error)),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
removeGroupMember(context, ref, group, fingerprint, name);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/// Stavební prvky profilu kontaktu a skupiny — ať oba vypadají a chovají
|
||||
/// se stejně.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/features/conversation/image_viewer_screen.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
|
||||
/// Výsledek profilu otevřeného z chatu: co má chat udělat po návratu.
|
||||
enum ProfileResult { openSearch }
|
||||
|
||||
/// Horní část profilu: zpět, velký avatar, jméno a řádky pod ním.
|
||||
class ProfileHero extends StatelessWidget {
|
||||
const ProfileHero({
|
||||
super.key,
|
||||
required this.avatar,
|
||||
required this.title,
|
||||
required this.lines,
|
||||
this.onTitleTap,
|
||||
});
|
||||
|
||||
final Widget avatar;
|
||||
final String title;
|
||||
final List<Widget> lines;
|
||||
final VoidCallback? onTitleTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: SepotColors.hero,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
4, MediaQuery.paddingOf(context).top + 4, 4, 22),
|
||||
child: Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
),
|
||||
avatar,
|
||||
const SizedBox(height: 14),
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: onTitleTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onTitleTap != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
const Icon(Icons.edit_outlined,
|
||||
size: 18, color: SepotColors.textSecondary),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
for (final line in lines)
|
||||
Padding(padding: const EdgeInsets.only(top: 4), child: line),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Čtveřice rychlých akcí pod hlavičkou profilu.
|
||||
class ProfileAction extends StatelessWidget {
|
||||
const ProfileAction({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.accent = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
final bool accent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = accent ? SepotColors.ember : SepotColors.textPrimary;
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: SepotColors.surface,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
onTap: onTap == null
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.selectionClick();
|
||||
onTap!();
|
||||
},
|
||||
child: SizedBox(
|
||||
height: 70,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 22, color: color),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12, fontWeight: FontWeight.w500, color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Zaoblená karta se sekcí profilu; řádky odděluje tenká linka.
|
||||
class ProfileCard extends StatelessWidget {
|
||||
const ProfileCard({super.key, required this.children, this.title, this.trailing});
|
||||
|
||||
final String? title;
|
||||
final String? trailing;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: SepotColors.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (title != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(title!,
|
||||
style: const TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
if (trailing != null)
|
||||
Text(trailing!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: SepotColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (var i = 0; i < children.length; i++) ...[
|
||||
if (i > 0 && title == null)
|
||||
const Divider(height: 1, indent: 16, color: SepotColors.divider),
|
||||
children[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Řádek karty: popisek vlevo, hodnota vpravo, volitelně šipka.
|
||||
class ProfileRow extends StatelessWidget {
|
||||
const ProfileRow({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.value,
|
||||
this.leading,
|
||||
this.subtitle,
|
||||
this.onTap,
|
||||
this.danger = false,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String? value;
|
||||
final Widget? leading;
|
||||
final String? subtitle;
|
||||
final VoidCallback? onTap;
|
||||
final bool danger;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
|
||||
child: Row(
|
||||
children: [
|
||||
if (leading != null) ...[leading!, const SizedBox(width: 12)],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: danger ? FontWeight.w600 : FontWeight.w400,
|
||||
color: danger
|
||||
? SepotColors.error
|
||||
: SepotColors.textPrimary)),
|
||||
if (subtitle != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(subtitle!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: SepotColors.textSecondary)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (value != null)
|
||||
Text(value!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14, color: SepotColors.textSecondary)),
|
||||
?trailing,
|
||||
if (onTap != null && !danger && trailing == null) ...[
|
||||
const SizedBox(width: 6),
|
||||
const Icon(Icons.chevron_right_rounded,
|
||||
size: 20, color: SepotColors.textMuted),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mřížka sdílených fotek (inline náhledy z E2E zpráv, nic se nestahuje).
|
||||
class SharedMediaCard extends StatelessWidget {
|
||||
const SharedMediaCard({super.key, required this.messages});
|
||||
|
||||
/// Všechny zprávy konverzace — fotky si vybere sama.
|
||||
final List<Message> messages;
|
||||
|
||||
static const _shown = 8;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final images = messages.where(isViewableImage).toList().reversed.toList();
|
||||
if (images.isEmpty) return const SizedBox.shrink();
|
||||
final extra = images.length - _shown;
|
||||
final visible = images.take(_shown).toList();
|
||||
|
||||
return ProfileCard(
|
||||
title: context.l10n.profileSharedMedia,
|
||||
trailing: '${images.length}',
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
|
||||
child: GridView.count(
|
||||
crossAxisCount: 4,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: 3,
|
||||
crossAxisSpacing: 3,
|
||||
children: [
|
||||
for (var i = 0; i < visible.length; i++)
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
ImageViewerScreen.open(context, visible[i], messages),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (visible[i].attachment?.thumbnail != null)
|
||||
Image.memory(visible[i].attachment!.thumbnail!,
|
||||
fit: BoxFit.cover, gaplessPlayback: true)
|
||||
else
|
||||
const ColoredBox(color: SepotColors.surfaceVariant),
|
||||
if (i == _shown - 1 && extra > 0)
|
||||
ColoredBox(
|
||||
color: Colors.black54,
|
||||
child: Center(
|
||||
child: Text('+$extra',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:share_plus/share_plus.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
|
||||
@@ -27,12 +28,24 @@ class QrShareScreen extends ConsumerWidget {
|
||||
return Center(child: Text(context.l10n.qrIdentityNotFound));
|
||||
}
|
||||
|
||||
// Sdílet s routingem (accountId + delivery address z relaye),
|
||||
// jinak protistrana nemá kam poslat první zprávu
|
||||
// Sledovat stav připojení: po dokončení vlastní registrace se
|
||||
// routing (aid+da) zapíše do DB a relay se připojí → tenhle watch
|
||||
// obrazovku překreslí a QR se z „připojuji" přepne na hotový kód.
|
||||
ref.watch(relayConnectionProvider);
|
||||
final service = ref.watch(messagingServiceProvider);
|
||||
final shareString = (service.shareableIdentity ??
|
||||
identity.publicIdentity)
|
||||
.toShareString();
|
||||
final shareable = service.shareableIdentity;
|
||||
final hasRouting = shareable != null &&
|
||||
shareable.accountId != null &&
|
||||
shareable.deliveryAddress != null;
|
||||
|
||||
// Bez routingu by QR/odkaz byl neúplný (protistrana by neměla kam
|
||||
// poslat první zprávu → „Kontakt nemá doručovací adresu"). Radši
|
||||
// než mrtvý kód ukázat stav připojování.
|
||||
if (!hasRouting) {
|
||||
return _ConnectingPlaceholder(identity: identity);
|
||||
}
|
||||
|
||||
final shareString = shareable.toShareString();
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
@@ -157,3 +170,56 @@ class QrShareScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stav obrazovky, dokud vlastní registrace na relay nedoběhne — místo
|
||||
/// neúplného QR (bez routingu) ukáže identitu a hlášku o připojování.
|
||||
class _ConnectingPlaceholder extends StatelessWidget {
|
||||
const _ConnectingPlaceholder({required this.identity});
|
||||
|
||||
final LocalIdentity identity;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(color: SepotColors.ember),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
context.l10n.qrShareConnectingTitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
context.l10n.qrShareConnectingBody,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
identity.sepotId,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontMono,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,8 @@ class _BiometricsTileState extends ConsumerState<BiometricsTile> {
|
||||
}
|
||||
final ok = await auth.authenticate(
|
||||
localizedReason: l10n.biometricsEnrollPromptReason,
|
||||
options: const AuthenticationOptions(stickyAuth: true),
|
||||
options: const AuthenticationOptions(
|
||||
stickyAuth: true, biometricOnly: true),
|
||||
);
|
||||
if (!ok) return;
|
||||
await storage.enableBiometrics();
|
||||
|
||||
@@ -20,6 +20,7 @@ class _PushSettingsTileState extends ConsumerState<PushSettingsTile> {
|
||||
String _mode = 'off';
|
||||
String? _distributor;
|
||||
bool _busy = false;
|
||||
bool _registrationFailed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -31,10 +32,12 @@ class _PushSettingsTileState extends ConsumerState<PushSettingsTile> {
|
||||
final push = ref.read(pushServiceProvider);
|
||||
final mode = await push.currentMode();
|
||||
final d = await push.currentDistributor();
|
||||
final failed = await push.registrationFailed();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_mode = mode;
|
||||
_distributor = d;
|
||||
_registrationFailed = failed;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -160,7 +163,12 @@ class _PushSettingsTileState extends ConsumerState<PushSettingsTile> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final subtitle = switch (_mode) {
|
||||
// Selhaná registrace přebíjí popis režimu — „zapnuto" bez budíčků je
|
||||
// horší než vypnuto, protože uživatel čeká zprávy, které nepřijdou.
|
||||
final failed = _registrationFailed && _mode != 'off';
|
||||
final subtitle = failed
|
||||
? context.l10n.pushRegistrationFailed
|
||||
: switch (_mode) {
|
||||
'up' => context.l10n.pushSubtitleUnifiedPush(
|
||||
_distributor ?? context.l10n.pushNoDistributorFallback),
|
||||
'fcm' => context.l10n.pushSubtitleFcm,
|
||||
@@ -184,7 +192,10 @@ class _PushSettingsTileState extends ConsumerState<PushSettingsTile> {
|
||||
style: const TextStyle(fontSize: 15)),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: const TextStyle(fontSize: 13, color: SepotColors.textMuted),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: failed ? SepotColors.ember : SepotColors.textMuted,
|
||||
),
|
||||
),
|
||||
trailing: _busy
|
||||
? const SizedBox(
|
||||
|
||||
@@ -5,15 +5,19 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:sepot/core/app_info.dart';
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/storage/secure_storage.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/core/services/avatar_image.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
@@ -34,10 +38,14 @@ class SettingsScreen extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final identityAsync = ref.watch(identityProvider);
|
||||
final l10n = context.l10n;
|
||||
// Nátlaková relace: nic, co by prozradilo nebo poškodilo skutečný účet
|
||||
// (fráze, biometrie, PIN, záloha, smazání) — appka vypadá jako čistá.
|
||||
final decoy = ref.watch(storageProvider).isDecoyMode;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.settingsTitle),
|
||||
toolbarHeight: SepotTheme.largeTitleBarHeight,
|
||||
title: Text(l10n.settingsTitle, style: SepotTheme.largeTitle),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
@@ -46,7 +54,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
identityAsync.when(
|
||||
data: (identity) {
|
||||
if (identity == null) return const SizedBox.shrink();
|
||||
return _buildProfileCard(context, identity);
|
||||
return _buildProfileCard(context, ref, identity);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
@@ -58,6 +66,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
const SizedBox(height: 8),
|
||||
// Sekce Zabezpečení
|
||||
_buildSectionHeader(l10n.settingsSectionSecurity),
|
||||
if (!decoy) ...[
|
||||
_buildTile(
|
||||
icon: Icons.pin_outlined,
|
||||
title: l10n.settingsChangePinTitle,
|
||||
@@ -65,6 +74,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
onTap: () => context.push('/change-pin'),
|
||||
),
|
||||
const BiometricsTile(),
|
||||
],
|
||||
_buildTile(
|
||||
icon: Icons.timer_outlined,
|
||||
title: l10n.settingsAutoLockTitle,
|
||||
@@ -75,6 +85,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
onTap: () => _nastavAutoLock(context, ref),
|
||||
),
|
||||
if (!decoy)
|
||||
_buildTile(
|
||||
icon: Icons.password_outlined,
|
||||
title: l10n.settingsDecoyPinTitle,
|
||||
@@ -121,6 +132,19 @@ class SettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
onTap: () {},
|
||||
),
|
||||
_buildTile(
|
||||
icon: Icons.more_horiz_rounded,
|
||||
title: l10n.settingsTypingTitle,
|
||||
subtitle: l10n.settingsTypingSubtitle,
|
||||
trailing: Switch(
|
||||
value:
|
||||
ref.watch(typingIndicatorsProvider).valueOrNull ?? false,
|
||||
onChanged: (v) =>
|
||||
ref.read(typingIndicatorsProvider.notifier).set(v),
|
||||
activeTrackColor: SepotColors.ember,
|
||||
),
|
||||
onTap: () {},
|
||||
),
|
||||
_buildTile(
|
||||
icon: Icons.screenshot_monitor_outlined,
|
||||
title: l10n.settingsScreenSecurityTitle,
|
||||
@@ -135,6 +159,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Sekce Záloha
|
||||
if (!decoy) ...[
|
||||
_buildSectionHeader(l10n.settingsSectionBackup),
|
||||
_buildTile(
|
||||
icon: Icons.key_outlined,
|
||||
@@ -154,6 +179,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
subtitle: l10n.settingsBackupImportSubtitle,
|
||||
onTap: () => _importBackup(context, ref),
|
||||
),
|
||||
],
|
||||
// „Web klient" byl slib funkce, která neexistuje (multi-device je
|
||||
// vlna 3). Apple Guideline 2.1 takové dlaždice bere jako nedodělek.
|
||||
const SizedBox(height: 8),
|
||||
@@ -181,7 +207,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
_buildTile(
|
||||
icon: Icons.info_outline,
|
||||
title: l10n.commonAppName,
|
||||
subtitle: l10n.settingsVersion(AppConstants.appVersion),
|
||||
subtitle: l10n.settingsVersion(AppInfo.version),
|
||||
onTap: () => _showAbout(context),
|
||||
),
|
||||
// Zdrojový kód je veřejný (AGPL-3.0). Detaily o licenci a odkaz
|
||||
@@ -212,6 +238,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Smazat účet
|
||||
if (!decoy)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: OutlinedButton(
|
||||
@@ -297,7 +324,69 @@ class SettingsScreen extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildProfileCard(BuildContext context, LocalIdentity identity) {
|
||||
/// Moje profilová fotka: vybrat z galerie (oříznutí a zmenšení běží
|
||||
/// v izolátu), nebo odebrat. Protistrany ji dostanou s další zprávou.
|
||||
Future<void> _editAvatar(BuildContext context, WidgetRef ref) async {
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
final hasAvatar = service.myAvatar != null;
|
||||
final action = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
backgroundColor: SepotColors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined,
|
||||
color: SepotColors.ember),
|
||||
title: Text(context.l10n.profilePhotoPick),
|
||||
onTap: () => Navigator.pop(ctx, 'pick'),
|
||||
),
|
||||
if (hasAvatar)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline,
|
||||
color: SepotColors.error),
|
||||
title: Text(context.l10n.profilePhotoRemove,
|
||||
style: const TextStyle(color: SepotColors.error)),
|
||||
onTap: () => Navigator.pop(ctx, 'remove'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 16),
|
||||
child: Text(context.l10n.profilePhotoHint,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: SepotColors.textSecondary)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (action == null || !context.mounted) return;
|
||||
try {
|
||||
if (action == 'remove') {
|
||||
await service.removeMyAvatar();
|
||||
} else {
|
||||
final picked = await ImagePicker()
|
||||
.pickImage(source: ImageSource.gallery, maxWidth: 1600);
|
||||
if (picked == null) return;
|
||||
final jpeg = await prepareAvatarJpeg(await picked.readAsBytes());
|
||||
if (jpeg == null) throw const FormatException('unreadable image');
|
||||
await service.setMyAvatar(jpeg);
|
||||
}
|
||||
ref.invalidate(avatarProvider);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(context.l10n
|
||||
.profilePhotoFailed(describeError(context.l10n, e))),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildProfileCard(
|
||||
BuildContext context, WidgetRef ref, LocalIdentity identity) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
@@ -308,18 +397,34 @@ class SettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 36,
|
||||
backgroundColor: SepotColors.ember.withValues(alpha: 0.1),
|
||||
child: Text(
|
||||
identity.displayName.isEmpty
|
||||
? '?'
|
||||
: identity.displayName.characters.first.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w600,
|
||||
// Stejný tón jako „já“ v seznamu chatů a ve skupinách.
|
||||
Semantics(
|
||||
button: true,
|
||||
label: context.l10n.profilePhotoEdit,
|
||||
child: GestureDetector(
|
||||
onTap: () => _editAvatar(context, ref),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
SepotAvatar(
|
||||
name: identity.displayName, seed: identity.id, size: 84),
|
||||
Positioned(
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
child: Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: SepotColors.ember,
|
||||
shape: BoxShape.circle,
|
||||
border:
|
||||
Border.all(color: SepotColors.surface, width: 3),
|
||||
),
|
||||
child: const Icon(Icons.photo_camera_rounded,
|
||||
size: 15, color: SepotColors.emberInk),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -418,10 +523,17 @@ class SettingsScreen extends ConsumerWidget {
|
||||
final dir = await getTemporaryDirectory();
|
||||
final path = '${dir.path}/sepot-zaloha.sepotbackup';
|
||||
await File(path).writeAsBytes(bytes);
|
||||
try {
|
||||
await Share.shareXFiles(
|
||||
[XFile(path, mimeType: 'application/octet-stream')],
|
||||
subject: l10n.backupShareSubject,
|
||||
);
|
||||
} finally {
|
||||
// Šifrovaná, ale v cache nemá co pohledávat po skončení sdílení.
|
||||
try {
|
||||
await File(path).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -614,7 +726,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
ctx.l10n.settingsVersion(AppConstants.appVersion),
|
||||
ctx.l10n.settingsVersion(AppInfo.version),
|
||||
style: const TextStyle(color: SepotColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
+636
-31
@@ -82,15 +82,6 @@
|
||||
"@groupsLeftBanner": {
|
||||
"description": "Banner místo input baru ve skupině, kterou uživatel opustil; POZOR gender"
|
||||
},
|
||||
"groupsMembersWithCount": "Členové ({count})",
|
||||
"@groupsMembersWithCount": {
|
||||
"description": "Nadpis bottom sheetu se seznamem členů skupiny",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"groupsYou": "Ty",
|
||||
"@groupsYou": {
|
||||
"description": "Fallback jména vlastního avataru v seznamu členů skupiny | Titulek ListTile vlastní osoby v seznamu členů skupiny — stejný klíč jako ř. 486"
|
||||
@@ -99,6 +90,76 @@
|
||||
"@groupsFounder": {
|
||||
"description": "Subtitle člena, který skupinu založil, v seznamu členů"
|
||||
},
|
||||
"groupsAdmin": "Správce",
|
||||
"@groupsAdmin": {
|
||||
"description": "Odznak správce skupiny u člena v seznamu členů"
|
||||
},
|
||||
"groupsMakeAdmin": "Udělat správcem",
|
||||
"@groupsMakeAdmin": {
|
||||
"description": "Akce v nabídce člena: udělit roli správce"
|
||||
},
|
||||
"groupsDismissAdmin": "Odebrat roli správce",
|
||||
"@groupsDismissAdmin": {
|
||||
"description": "Akce v nabídce člena: odebrat roli správce"
|
||||
},
|
||||
"groupsRemoveMember": "Odebrat ze skupiny",
|
||||
"@groupsRemoveMember": {
|
||||
"description": "Akce v nabídce člena: odebrat ze skupiny"
|
||||
},
|
||||
"groupsRemoveMemberConfirmTitle": "Odebrat {name} ze skupiny?",
|
||||
"@groupsRemoveMemberConfirmTitle": {
|
||||
"description": "Titulek potvrzení odebrání člena",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"groupsRemoveMemberConfirmBody": "Přestanou mu chodit nové zprávy skupiny. Dosavadní historie mu zůstane.",
|
||||
"@groupsRemoveMemberConfirmBody": {
|
||||
"description": "Text potvrzení odebrání člena"
|
||||
},
|
||||
"groupsRemoveAction": "Odebrat",
|
||||
"@groupsRemoveAction": {
|
||||
"description": "Potvrzovací tlačítko odebrání člena"
|
||||
},
|
||||
"groupsViewContact": "Zobrazit kontakt",
|
||||
"@groupsViewContact": {
|
||||
"description": "Akce v nabídce člena: otevřít profil kontaktu"
|
||||
},
|
||||
"groupsAdminOnlyHint": "Jméno, členy a správce skupiny mění jen správci.",
|
||||
"@groupsAdminOnlyHint": {
|
||||
"description": "Vysvětlení pod seznamem členů pro ne-správce ve vynucené skupině"
|
||||
},
|
||||
"groupsAdminOnlyCompatHint": "Jméno a správce mění jen správci. Přidávat lidi zatím může každý, protože někdo ve skupině má starší verzi Šepotu.",
|
||||
"@groupsAdminOnlyCompatHint": {
|
||||
"description": "Vysvětlení pro ne-správce, dokud je ve skupině člen se starší verzí aplikace"
|
||||
},
|
||||
"groupsAdminActionFailed": "Změna se nepovedla: {error}",
|
||||
"@groupsAdminActionFailed": {
|
||||
"description": "SnackBar při selhání správcovské akce",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"groupsResyncNotice": "Stav skupiny se srovnává se správcem. Změny členů se můžou chvíli opozdit.",
|
||||
"@groupsResyncNotice": {
|
||||
"description": "Upozornění v profilu skupiny, když chybí mezistav od správce"
|
||||
},
|
||||
"groupsRejectedChangeNotice": "Někdo bez oprávnění zkusil skupinu změnit. Změna se neprovedla.",
|
||||
"@groupsRejectedChangeNotice": {
|
||||
"description": "Upozornění v profilu skupiny po odmítnuté neoprávněné změně"
|
||||
},
|
||||
"errorGroupNotAdmin": "Tohle může udělat jen správce skupiny.",
|
||||
"@errorGroupNotAdmin": {
|
||||
"description": "Chyba: akci smí udělat jen správce skupiny"
|
||||
},
|
||||
"errorGroupLastAdmin": "Jsi jediný správce. Nejdřív udělej správcem někoho dalšího.",
|
||||
"@errorGroupLastAdmin": {
|
||||
"description": "Chyba: poslední správce se nemůže vzdát role"
|
||||
},
|
||||
"groupsNameHint": "Název skupiny",
|
||||
"@groupsNameHint": {
|
||||
"description": "hintText TextFieldu v dialogu přejmenování skupiny | labelText textového pole pro jméno skupiny (max 64 znaků)"
|
||||
@@ -254,6 +315,40 @@
|
||||
"@chatActionReply": {
|
||||
"description": "Položka akčního bottom sheetu zprávy (long-press)"
|
||||
},
|
||||
"chatActionForward": "Přeposlat",
|
||||
"@chatActionForward": {
|
||||
"description": "Akce v nabídce po podržení zprávy"
|
||||
},
|
||||
"forwardTitle": "Přeposlat do…",
|
||||
"@forwardTitle": {
|
||||
"description": "Nadpis panelu výběru chatu pro přeposlání"
|
||||
},
|
||||
"forwardSearchHint": "Hledat kontakt nebo skupinu",
|
||||
"@forwardSearchHint": {
|
||||
"description": "Nápověda v poli hledání panelu přeposlání"
|
||||
},
|
||||
"forwardEmpty": "Nic tu není",
|
||||
"@forwardEmpty": {
|
||||
"description": "Prázdný výsledek hledání v panelu přeposlání"
|
||||
},
|
||||
"forwardDone": "Přeposláno: {name}",
|
||||
"@forwardDone": {
|
||||
"description": "SnackBar po přeposlání zprávy",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"forwardFailed": "Přeposlání se nepovedlo: {error}",
|
||||
"@forwardFailed": {
|
||||
"description": "SnackBar při chybě přeposlání",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"commonCopy": "Kopírovat",
|
||||
"@commonCopy": {
|
||||
"description": "Položka akčního bottom sheetu zprávy — kopírování textu do schránky | Popisek OutlinedButton.icon pro zkopírování Šepot ID do schránky"
|
||||
@@ -332,6 +427,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"attachmentKeepsMetadataTitle": "Fotka odejde i s polohou",
|
||||
"@attachmentKeepsMetadataTitle": {
|
||||
"description": "Titulek dialogu před odesláním obrázku jako dokumentu, když z něj nejde odstranit EXIF (HEIC, RAW)"
|
||||
},
|
||||
"attachmentKeepsMetadataBody": "Tenhle formát fotky (třeba HEIC) neumím zbavit metadat. Odešla by v originále včetně polohy, kde vznikla, a údajů o telefonu. Bezpečnější je poslat ji přes Galerii, kde se metadata odstraní.",
|
||||
"@attachmentKeepsMetadataBody": {
|
||||
"description": "Text téhož dialogu"
|
||||
},
|
||||
"attachmentSendOriginal": "Poslat originál",
|
||||
"@attachmentSendOriginal": {
|
||||
"description": "Potvrzovací tlačítko téhož dialogu"
|
||||
},
|
||||
"attachmentPickFailed": "Výběr přílohy selhal: {error}",
|
||||
"@attachmentPickFailed": {
|
||||
"description": "SnackBar (_showError) při selhání výběru přílohy",
|
||||
@@ -375,6 +482,14 @@
|
||||
"@errorRelayUnavailable": {
|
||||
"description": "StateError z _senderCertificate, když se nepodařilo získat sender certificate (offline relay); propaguje se do odesílací cesty, zobrazuje chat UI při selhání odeslání."
|
||||
},
|
||||
"errorNotYetRegistered": "Šepot se ještě připojuje k relayi — zkus zprávu poslat za chvíli.",
|
||||
"@errorNotYetRegistered": {
|
||||
"description": "NotYetRegisteredError z _sendContent, když vlastní registrace na relay ještě nedoběhla (čerstvá instalace). 'Šepot' je značka, nepřekládat."
|
||||
},
|
||||
"errorContactLinkIncomplete": "Odkaz je neúplný — protistrana ho vytvořila dřív, než se stihla připojit. Požádej ji o nový sepot:// odkaz.",
|
||||
"@errorContactLinkIncomplete": {
|
||||
"description": "ContactLinkIncompleteError při přidání kontaktu z odkazu/QR bez routingu (aid+da). 'sepot://' je schéma odkazu, zachovat."
|
||||
},
|
||||
"errorAttachmentTooLarge": "Příloha je příliš velká (max {maxMb} MB)",
|
||||
"@errorAttachmentTooLarge": {
|
||||
"description": "StateError z _prepareAttachment při překročení limitu velikosti přílohy; zobrazuje UI výběru/odeslání přílohy. POZOR: v kódu jde o dva adjacentní literály 'Příloha je příliš velká ' (ř. 448) + '(max ${…} MB)' (ř. 449) — sloučit do jednoho klíče.",
|
||||
@@ -388,6 +503,10 @@
|
||||
"@errorGroupNeedsMember": {
|
||||
"description": "StateError z createGroup při prázdném seznamu členů; zobrazuje UI zakládání skupiny."
|
||||
},
|
||||
"errorAttachmentUnavailable": "Příloha už v telefonu není",
|
||||
"@errorAttachmentUnavailable": {
|
||||
"description": "Chyba při přeposlání přílohy, která už byla smazána nebo nestažena"
|
||||
},
|
||||
"errorGroupMaxMembers": "Skupina může mít nejvýš {maxMembers} členů",
|
||||
"@errorGroupMaxMembers": {
|
||||
"description": "StateError z createGroup při překročení max. počtu členů; zobrazuje UI zakládání skupiny. Počet v textu + české skloňování (člen/členové/členů) → ICU plural. | StateError z addGroupMembers — identický text jako v createGroup (ř. 583), stejný klíč. ICU plural.",
|
||||
@@ -805,28 +924,136 @@
|
||||
"@chatListNewConversation": {
|
||||
"description": "Náhled poslední zprávy v dlaždici seznamu chatů, když konverzace zatím nemá žádnou zprávu"
|
||||
},
|
||||
"chatListPhotoWithCaptionPreview": "📷 {caption}",
|
||||
"@chatListPhotoWithCaptionPreview": {
|
||||
"description": "Náhled poslední zprávy v dlaždici — fotka s popiskem; emoji je součást textu",
|
||||
"placeholders": {
|
||||
"caption": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chatListFilePreview": "📎 {fileName}",
|
||||
"@chatListFilePreview": {
|
||||
"description": "Náhled poslední zprávy v dlaždici — soubor; emoji je součást textu. Skládaný literál: obsahuje vnořený fallback \"Soubor\" (viz samostatný záznam) — při lokalizaci předat placeholderu už vyřešenou hodnotu msg.fileName ?? commonFile.",
|
||||
"placeholders": {
|
||||
"fileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chatListReadReceiptPreview": "Přečteno",
|
||||
"@chatListReadReceiptPreview": {
|
||||
"description": "Náhled poslední zprávy v dlaždici pro zprávu typu readReceipt (potvrzení o přečtení)"
|
||||
},
|
||||
"chatListDraft": "Koncept",
|
||||
"@chatListDraft": {
|
||||
"description": "Předpona náhledu v seznamu chatů, když je v chatu rozepsaná zpráva"
|
||||
},
|
||||
"notesTitle": "Poznámky pro sebe",
|
||||
"@notesTitle": {
|
||||
"description": "Název chatu se sebou samým (seznam chatů, hlavička, kontakty, přeposlání)"
|
||||
},
|
||||
"notesSubtitle": "Jen v tomto telefonu, šifrovaně",
|
||||
"@notesSubtitle": {
|
||||
"description": "Podtitulek poznámek pro sebe — nic se neodesílá na server"
|
||||
},
|
||||
"callVoice": "Zavolat",
|
||||
"@callVoice": {
|
||||
"description": "Tlačítko hlasového hovoru v hlavičce chatu"
|
||||
},
|
||||
"callVideo": "Videohovor",
|
||||
"@callVideo": {
|
||||
"description": "Tlačítko videohovoru v hlavičce chatu"
|
||||
},
|
||||
"callStatusRinging": "Vyzvání…",
|
||||
"@callStatusRinging": {
|
||||
"description": "Stav odchozího hovoru, dokud protistrana nezvedne"
|
||||
},
|
||||
"callIncomingVoice": "Příchozí hovor",
|
||||
"@callIncomingVoice": {
|
||||
"description": "Stav příchozího hlasového hovoru"
|
||||
},
|
||||
"callIncomingVideo": "Příchozí videohovor",
|
||||
"@callIncomingVideo": {
|
||||
"description": "Stav příchozího videohovoru"
|
||||
},
|
||||
"callStatusConnecting": "Spojuji…",
|
||||
"@callStatusConnecting": {
|
||||
"description": "Stav po přijetí, než naběhne spojení"
|
||||
},
|
||||
"callEnded": "Hovor skončil",
|
||||
"@callEnded": {
|
||||
"description": "Krátce po konci hovoru"
|
||||
},
|
||||
"callEndedBusy": "Obsazeno",
|
||||
"@callEndedBusy": {
|
||||
"description": "Protistrana má jiný hovor"
|
||||
},
|
||||
"callEndedRejected": "Hovor odmítnut",
|
||||
"@callEndedRejected": {
|
||||
"description": "Hovor byl odmítnut"
|
||||
},
|
||||
"callEndedUnanswered": "Nikdo to nevzal",
|
||||
"@callEndedUnanswered": {
|
||||
"description": "Odchozí hovor vypršel bez odpovědi"
|
||||
},
|
||||
"callEndedFailed": "Spojení se nepodařilo",
|
||||
"@callEndedFailed": {
|
||||
"description": "Hovor se nepodařilo spojit"
|
||||
},
|
||||
"callEncrypted": "Šifrováno end-to-end",
|
||||
"@callEncrypted": {
|
||||
"description": "Podtitulek na obrazovce hovoru"
|
||||
},
|
||||
"callAccept": "Přijmout",
|
||||
"@callAccept": {
|
||||
"description": "Přijetí příchozího hovoru"
|
||||
},
|
||||
"callReject": "Odmítnout",
|
||||
"@callReject": {
|
||||
"description": "Odmítnutí příchozího hovoru"
|
||||
},
|
||||
"callMute": "Ztlumit mikrofon",
|
||||
"@callMute": {
|
||||
"description": "Přepínač mikrofonu (popisek pro čtečku)"
|
||||
},
|
||||
"callSpeaker": "Reproduktor",
|
||||
"@callSpeaker": {
|
||||
"description": "Přepínač hlasitého odposlechu"
|
||||
},
|
||||
"callCamera": "Vypnout kameru",
|
||||
"@callCamera": {
|
||||
"description": "Přepínač kamery ve videohovoru"
|
||||
},
|
||||
"callSwitchCamera": "Přepnout kameru",
|
||||
"@callSwitchCamera": {
|
||||
"description": "Přední/zadní kamera"
|
||||
},
|
||||
"callHangUp": "Zavěsit",
|
||||
"@callHangUp": {
|
||||
"description": "Ukončení hovoru"
|
||||
},
|
||||
"callLogMissed": "Zmeškaný hovor",
|
||||
"@callLogMissed": {
|
||||
"description": "Záznam v chatu"
|
||||
},
|
||||
"callLogUnanswered": "Nepřijatý hovor",
|
||||
"@callLogUnanswered": {
|
||||
"description": "Odchozí hovor, který protistrana nevzala"
|
||||
},
|
||||
"callLogRejected": "Odmítnutý hovor",
|
||||
"@callLogRejected": {
|
||||
"description": "Záznam v chatu"
|
||||
},
|
||||
"callLogBusy": "Obsazeno",
|
||||
"@callLogBusy": {
|
||||
"description": "Záznam v chatu — protistrana měla jiný hovor"
|
||||
},
|
||||
"callLogFailed": "Hovor se nespojil",
|
||||
"@callLogFailed": {
|
||||
"description": "Záznam v chatu"
|
||||
},
|
||||
"callLogVoice": "Hovor · {duration}",
|
||||
"@callLogVoice": {
|
||||
"description": "Záznam proběhlého hlasového hovoru s délkou",
|
||||
"placeholders": {
|
||||
"duration": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"callLogVideo": "Videohovor · {duration}",
|
||||
"@callLogVideo": {
|
||||
"description": "Záznam proběhlého videohovoru s délkou",
|
||||
"placeholders": {
|
||||
"duration": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chatListTimeNow": "teď",
|
||||
"@chatListTimeNow": {
|
||||
"description": "Relativní čas poslední zprávy v dlaždici, méně než 1 minuta"
|
||||
@@ -1254,6 +1481,15 @@
|
||||
"@pinUnlockError": {
|
||||
"description": "Červená chybová hláška na odemykací obrazovce při jiné chybě odemčení než špatný PIN"
|
||||
},
|
||||
"pinLockedFor": "Příliš mnoho pokusů. Zkus to za {seconds} s.",
|
||||
"@pinLockedFor": {
|
||||
"description": "Hláška na odemykací obrazovce po sérii špatných PINů; {seconds} = počet sekund do dalšího pokusu",
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pushTitle": "Push notifikace",
|
||||
"@pushTitle": {
|
||||
"description": "Titulek SimpleDialog výběru režimu pushů. Stejný text jako title ListTile v nastavení (ř. 184) → stejný klíč. | Title ListTile v nastavení — stejný text a klíč jako titulek dialogu (ř. 44)"
|
||||
@@ -1262,15 +1498,15 @@
|
||||
"@pushModeOffDesc": {
|
||||
"description": "Popisek volby 'Vypnuto' v dialogu výběru režimu pushů"
|
||||
},
|
||||
"pushModeUp": "UnifiedPush (doporučeno)",
|
||||
"pushModeUp": "UnifiedPush (bez Googlu)",
|
||||
"@pushModeUp": {
|
||||
"description": "Label volby 'up' v dialogu výběru režimu pushů ('UnifiedPush' je název technologie — nepřekládat, přeložit jen '(doporučeno)')"
|
||||
"description": "Label volby 'up' v dialogu výběru režimu pushů ('UnifiedPush' je název technologie — nepřekládat, přeložit jen '(bez Googlu)')"
|
||||
},
|
||||
"pushModeUpDesc": "Degooglované — potřebuje aplikaci ntfy (F-Droid/Play).",
|
||||
"@pushModeUpDesc": {
|
||||
"description": "Popisek volby UnifiedPush v dialogu výběru režimu pushů ('ntfy', 'F-Droid', 'Play' jsou názvy — nepřekládat)"
|
||||
},
|
||||
"pushModeFcm": "Google (FCM)",
|
||||
"pushModeFcm": "Google (FCM, výchozí)",
|
||||
"@pushModeFcm": {
|
||||
"description": "Label volby 'fcm' v dialogu výběru režimu pushů (vlastní název — pravděpodobně shodný ve všech jazycích, ale klíč mít)"
|
||||
},
|
||||
@@ -1287,6 +1523,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"pushHintOff": "Zprávy ti přijdou, až Šepot otevřeš — budíčky nejsou nastavené.",
|
||||
"@pushHintOff": {
|
||||
"description": "Proužek nad seznamem chatů, když push není vůbec zapnutý."
|
||||
},
|
||||
"pushHintFailed": "Budíčky nefungují, zprávy uvidíš až po otevření Šepotu.",
|
||||
"@pushHintFailed": {
|
||||
"description": "Proužek nad seznamem chatů, když distributor registraci odmítl."
|
||||
},
|
||||
"pushRegistrationFailed": "Budíčky nechodí — distributor registraci odmítl. Zkus push vypnout a znovu zapnout.",
|
||||
"@pushRegistrationFailed": {
|
||||
"description": "Podtitulek dlaždice Push v Nastavení, když distributor odmítl registraci. Bez téhle hlášky uživatel vidí push jako zapnutý, ale zprávy mu chodí až po otevření appky."
|
||||
},
|
||||
"pushNoDistributorTitle": "Žádný distributor",
|
||||
"@pushNoDistributorTitle": {
|
||||
"description": "Titulek AlertDialog, když v systému není nainstalovaný žádný UnifiedPush distributor"
|
||||
@@ -1316,7 +1564,7 @@
|
||||
"@pushNoDistributorFallback": {
|
||||
"description": "Vnořený fallback literál uvnitř subtitle 'UnifiedPush · …', když není vybraný žádný distributor — vyhodnotit před dosazením do pushSubtitleUnifiedPush"
|
||||
},
|
||||
"pushSubtitleFcm": "Google (FCM) — rychlé, opt-in",
|
||||
"pushSubtitleFcm": "Google (FCM)",
|
||||
"@pushSubtitleFcm": {
|
||||
"description": "Subtitle ListTile v nastavení, když je aktivní režim FCM"
|
||||
},
|
||||
@@ -1419,6 +1667,14 @@
|
||||
"@qrShareTitle": {
|
||||
"description": "AppBar titulek obrazovky sdílení vlastního QR/Šepot ID"
|
||||
},
|
||||
"qrShareConnectingTitle": "Připojuji k relayi…",
|
||||
"@qrShareConnectingTitle": {
|
||||
"description": "Titulek na QR obrazovce, když vlastní registrace na relay ještě nedoběhla — routing (aid+da) není hotový, QR by byl neúplný."
|
||||
},
|
||||
"qrShareConnectingBody": "Až se Šepot poprvé připojí, objeví se tvůj QR kód a odkaz. Chvilku strpení — stačí pár sekund.",
|
||||
"@qrShareConnectingBody": {
|
||||
"description": "Vysvětlení pod titulkem qrShareConnectingTitle. 'Šepot' je značka, nepřekládat."
|
||||
},
|
||||
"qrIdentityNotFound": "Identita nebyla nalezena",
|
||||
"@qrIdentityNotFound": {
|
||||
"description": "Text uprostřed obrazovky, když identityProvider vrátí null (nemělo by běžně nastat)"
|
||||
@@ -1734,5 +1990,354 @@
|
||||
"photoPreviewDecodeFailed": "Fotku se nepodařilo načíst",
|
||||
"@photoPreviewDecodeFailed": {
|
||||
"description": "SnackBar, když se vybraná fotka nedá dekódovat — náhled se zavře bez odeslání"
|
||||
},
|
||||
"chatActionInfo": "Info o zprávě",
|
||||
"@chatActionInfo": {
|
||||
"description": "Položka v nabídce zprávy — detail doručení a přečtení."
|
||||
},
|
||||
"messageInfoSent": "Odesláno",
|
||||
"@messageInfoSent": {
|
||||
"description": "Info o zprávě: řádek času odeslání."
|
||||
},
|
||||
"messageInfoDelivered": "Doručeno",
|
||||
"@messageInfoDelivered": {
|
||||
"description": "Info o zprávě: řádek času doručení."
|
||||
},
|
||||
"messageInfoRead": "Přečteno",
|
||||
"@messageInfoRead": {
|
||||
"description": "Info o zprávě: řádek času přečtení."
|
||||
},
|
||||
"messageInfoPending": "zatím ne",
|
||||
"@messageInfoPending": {
|
||||
"description": "Info o zprávě: událost ještě nenastala."
|
||||
},
|
||||
"messageInfoReadReceiptsOff": "Kdy si zprávu přečetli, uvidíš, jen když sám sdílíš potvrzení přečtení (Nastavení).",
|
||||
"@messageInfoReadReceiptsOff": {
|
||||
"description": "Info o zprávě: vysvětlení, proč chybí čas přečtení."
|
||||
},
|
||||
"chatStatusReadAt": "Přečteno {time}",
|
||||
"@chatStatusReadAt": {
|
||||
"description": "Řádek pod poslední odeslanou zprávou — čas přečtení.",
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chatStatusDeliveredAt": "Doručeno {time}",
|
||||
"@chatStatusDeliveredAt": {
|
||||
"description": "Řádek pod poslední odeslanou zprávou — čas doručení.",
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"groupsAddMembers": "Přidat členy",
|
||||
"@groupsAddMembers": {
|
||||
"description": "Nabídka skupiny a titulek obrazovky pro přidání lidí."
|
||||
},
|
||||
"groupsAddMembersAction": "Přidat",
|
||||
"@groupsAddMembersAction": {
|
||||
"description": "Potvrzovací tlačítko v AppBaru při přidávání členů."
|
||||
},
|
||||
"groupsAddMembersFailed": "Přidání se nepovedlo: {error}",
|
||||
"@groupsAddMembersFailed": {
|
||||
"description": "Chyba při přidávání členů.",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"groupsNoContactsToAdd": "Všechny tvoje kontakty už ve skupině jsou. Nového člověka nejdřív přidej do kontaktů přes QR kód.",
|
||||
"@groupsNoContactsToAdd": {
|
||||
"description": "Prázdný výběr — všechny kontakty už ve skupině jsou."
|
||||
},
|
||||
"navChats": "Chaty",
|
||||
"@navChats": {
|
||||
"description": "Karta spodní navigace a nadpis seznamu chatů."
|
||||
},
|
||||
"chatListNewChat": "Nový chat",
|
||||
"@chatListNewChat": {
|
||||
"description": "Tlačítko pro zahájení nového chatu."
|
||||
},
|
||||
"chatListSearchHint": "Hledat v chatech a zprávách",
|
||||
"@chatListSearchHint": {
|
||||
"description": "Placeholder hledání v seznamu chatů."
|
||||
},
|
||||
"chatFilterAll": "Vše",
|
||||
"@chatFilterAll": {
|
||||
"description": "Filtr seznamu chatů — vše."
|
||||
},
|
||||
"chatFilterUnread": "Nepřečtené",
|
||||
"@chatFilterUnread": {
|
||||
"description": "Filtr seznamu chatů — nepřečtené."
|
||||
},
|
||||
"chatFilterGroups": "Skupiny",
|
||||
"@chatFilterGroups": {
|
||||
"description": "Filtr seznamu chatů — skupiny."
|
||||
},
|
||||
"chatFilterArchive": "Archiv",
|
||||
"@chatFilterArchive": {
|
||||
"description": "Odkaz na archiv v řádku filtrů."
|
||||
},
|
||||
"chatListNoResults": "Nic nenalezeno.",
|
||||
"@chatListNoResults": {
|
||||
"description": "Hledání v chatech nic nenašlo."
|
||||
},
|
||||
"chatListFilterEmpty": "Tady zatím nic není.",
|
||||
"@chatListFilterEmpty": {
|
||||
"description": "Vybraný filtr je prázdný."
|
||||
},
|
||||
"chatListPin": "Připnout",
|
||||
"@chatListPin": {
|
||||
"description": "Akce: připnout chat nahoru."
|
||||
},
|
||||
"chatListUnpin": "Odepnout",
|
||||
"@chatListUnpin": {
|
||||
"description": "Akce: odepnout chat."
|
||||
},
|
||||
"chatOpenProfile": "Zobrazit profil",
|
||||
"@chatOpenProfile": {
|
||||
"description": "Hlavička chatu / menu: otevřít profil kontaktu."
|
||||
},
|
||||
"groupsProfile": "Profil skupiny",
|
||||
"@groupsProfile": {
|
||||
"description": "Menu skupinového chatu: profil skupiny."
|
||||
},
|
||||
"chatSearchHint": "Hledat v konverzaci",
|
||||
"@chatSearchHint": {
|
||||
"description": "Hledání v jedné konverzaci — placeholder a popisek tlačítka."
|
||||
},
|
||||
"chatSearchNoMatches": "Nic",
|
||||
"@chatSearchNoMatches": {
|
||||
"description": "Hledání v konverzaci bez výsledku (krátce, vedle pole)."
|
||||
},
|
||||
"chatSearchCounter": "{current} z {total}",
|
||||
"@chatSearchCounter": {
|
||||
"description": "Pořadí aktuální shody hledání, např. „2 z 5“.",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chatSearchOlder": "Starší shoda",
|
||||
"@chatSearchOlder": {
|
||||
"description": "Tlačítko: předchozí (starší) shoda."
|
||||
},
|
||||
"chatSearchNewer": "Novější shoda",
|
||||
"@chatSearchNewer": {
|
||||
"description": "Tlačítko: další (novější) shoda."
|
||||
},
|
||||
"chatUnreadDivider": "{count, plural, one{1 nová zpráva} few{{count} nové zprávy} other{{count} nových zpráv}}",
|
||||
"@chatUnreadDivider": {
|
||||
"description": "Oddělovač nad první nepřečtenou zprávou.",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chatJumpToLatest": "Na nejnovější zprávy",
|
||||
"@chatJumpToLatest": {
|
||||
"description": "Tlačítko: skok na nejnovější zprávy."
|
||||
},
|
||||
"chatAttach": "Přiložit",
|
||||
"@chatAttach": {
|
||||
"description": "Tlačítko přílohy v poli pro psaní."
|
||||
},
|
||||
"chatSend": "Odeslat",
|
||||
"@chatSend": {
|
||||
"description": "Tlačítko odeslat."
|
||||
},
|
||||
"chatRecordVoice": "Nahrát hlasovou zprávu",
|
||||
"@chatRecordVoice": {
|
||||
"description": "Tlačítko nahrát hlasovou zprávu."
|
||||
},
|
||||
"profileSharedMedia": "Sdílená média",
|
||||
"@profileSharedMedia": {
|
||||
"description": "Karta sdílených fotek v profilu."
|
||||
},
|
||||
"profileMessage": "Zpráva",
|
||||
"@profileMessage": {
|
||||
"description": "Akce profilu: napsat zprávu."
|
||||
},
|
||||
"profileSearch": "Hledat",
|
||||
"@profileSearch": {
|
||||
"description": "Akce profilu: hledat v konverzaci."
|
||||
},
|
||||
"profileUnmute": "Zvuk",
|
||||
"@profileUnmute": {
|
||||
"description": "Akce profilu: zrušit ztlumení (krátce)."
|
||||
},
|
||||
"profileDisappearing": "Mizení",
|
||||
"@profileDisappearing": {
|
||||
"description": "Akce profilu: mizící zprávy (krátce)."
|
||||
},
|
||||
"profileCommonGroups": "Společné skupiny",
|
||||
"@profileCommonGroups": {
|
||||
"description": "Karta společných skupin v profilu kontaktu."
|
||||
},
|
||||
"profileSafetyNumber": "Bezpečnostní číslo",
|
||||
"@profileSafetyNumber": {
|
||||
"description": "Řádek profilu: bezpečnostní číslo."
|
||||
},
|
||||
"profileSafetyNumberHint": "Porovnejte osobně nebo QR kódem",
|
||||
"@profileSafetyNumberHint": {
|
||||
"description": "Podtitulek bezpečnostního čísla u neověřeného kontaktu."
|
||||
},
|
||||
"profileVerifiedInPerson": "Ověřeno osobně",
|
||||
"@profileVerifiedInPerson": {
|
||||
"description": "Stav ověřeného kontaktu v hlavičce profilu."
|
||||
},
|
||||
"profileVerifyNow": "Ověřit kontakt",
|
||||
"@profileVerifyNow": {
|
||||
"description": "Tlačítko v profilu neověřeného kontaktu."
|
||||
},
|
||||
"profileAdd": "Přidat",
|
||||
"@profileAdd": {
|
||||
"description": "Akce profilu skupiny: přidat členy (krátce)."
|
||||
},
|
||||
"profileGroupSubtitle": "Skupina · {members} · založena {date}",
|
||||
"@profileGroupSubtitle": {
|
||||
"description": "Podtitulek profilu skupiny: počet členů · datum založení.",
|
||||
"placeholders": {
|
||||
"members": {
|
||||
"type": "String"
|
||||
},
|
||||
"date": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileMembersOfMax": "{count} ze {max}",
|
||||
"@profileMembersOfMax": {
|
||||
"description": "Počet členů ze stropu skupiny, např. „4 ze 128“.",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"max": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chatTyping": "píše…",
|
||||
"@chatTyping": {
|
||||
"description": "Podtitulek chatu / náhled v seznamu: protistrana píše."
|
||||
},
|
||||
"chatTypingNamed": "{name} píše…",
|
||||
"@chatTypingNamed": {
|
||||
"description": "Skupina: kdo píše (křestní jméno).",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chatTypingSeveral": "Několik lidí píše…",
|
||||
"@chatTypingSeveral": {
|
||||
"description": "Skupina: píše víc lidí najednou."
|
||||
},
|
||||
"settingsTypingTitle": "Ukazovat, že píšu",
|
||||
"@settingsTypingTitle": {
|
||||
"description": "Nastavení: přepínač „píše…“."
|
||||
},
|
||||
"settingsTypingSubtitle": "Oboustranné — kdo nesdílí, nevidí (výchozí vypnuto)",
|
||||
"@settingsTypingSubtitle": {
|
||||
"description": "Popis přepínače „píše…“."
|
||||
},
|
||||
"pollAttach": "Anketa",
|
||||
"@pollAttach": {
|
||||
"description": "Položka nabídky příloh: vytvořit anketu."
|
||||
},
|
||||
"pollCreateTitle": "Nová anketa",
|
||||
"@pollCreateTitle": {
|
||||
"description": "Nadpis formuláře nové ankety."
|
||||
},
|
||||
"pollQuestion": "Otázka",
|
||||
"@pollQuestion": {
|
||||
"description": "Pole otázky ankety."
|
||||
},
|
||||
"pollOptionHint": "Možnost {number}",
|
||||
"@pollOptionHint": {
|
||||
"description": "Placeholder možnosti ankety s pořadím.",
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pollAddOption": "Přidat možnost",
|
||||
"@pollAddOption": {
|
||||
"description": "Tlačítko: přidat možnost do ankety."
|
||||
},
|
||||
"pollRemoveOption": "Odebrat možnost",
|
||||
"@pollRemoveOption": {
|
||||
"description": "Tlačítko: odebrat možnost."
|
||||
},
|
||||
"pollAllowMultiple": "Povolit víc odpovědí",
|
||||
"@pollAllowMultiple": {
|
||||
"description": "Přepínač: víc odpovědí na osobu."
|
||||
},
|
||||
"pollSingle": "Anketa",
|
||||
"@pollSingle": {
|
||||
"description": "Hlavička ankety s jednou volbou."
|
||||
},
|
||||
"pollMulti": "Anketa, více voleb",
|
||||
"@pollMulti": {
|
||||
"description": "Hlavička ankety s více volbami."
|
||||
},
|
||||
"pollVoters": "{count, plural, =0{zatím bez hlasů} one{1 hlas} few{{count} hlasy} other{{count} hlasů}}",
|
||||
"@pollVoters": {
|
||||
"description": "Počet hlasujících v anketě.",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pollFallback": "Anketa: {question}",
|
||||
"@pollFallback": {
|
||||
"description": "Záložní text ankety pro starší verze appky — první řádek.",
|
||||
"placeholders": {
|
||||
"question": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pollFallbackUpdate": "Hlasovat jde v aktuální verzi Šepotu.",
|
||||
"@pollFallbackUpdate": {
|
||||
"description": "Záložní text ankety — výzva k aktualizaci."
|
||||
},
|
||||
"profilePhotoEdit": "Změnit profilovou fotku",
|
||||
"@profilePhotoEdit": {
|
||||
"description": "Popisek avataru v Nastavení (čtečka obrazovky)."
|
||||
},
|
||||
"profilePhotoPick": "Vybrat fotku",
|
||||
"@profilePhotoPick": {
|
||||
"description": "Akce: vybrat profilovou fotku z galerie."
|
||||
},
|
||||
"profilePhotoRemove": "Odebrat fotku",
|
||||
"@profilePhotoRemove": {
|
||||
"description": "Akce: odebrat profilovou fotku."
|
||||
},
|
||||
"profilePhotoHint": "Fotku uvidí jen lidé, se kterými si píšeš — šifrovaně, stejně jako zprávy. Poloha ani údaje o fotce se neposílají.",
|
||||
"@profilePhotoHint": {
|
||||
"description": "Vysvětlení, kdo profilovou fotku uvidí."
|
||||
},
|
||||
"profilePhotoFailed": "Fotku se nepodařilo nastavit: {error}",
|
||||
"@profilePhotoFailed": {
|
||||
"description": "Chyba nastavení profilové fotky.",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+136
-7
@@ -18,9 +18,23 @@
|
||||
"groupsRename": "Gruppe umbenennen",
|
||||
"groupsLeave": "Gruppe verlassen",
|
||||
"groupsLeftBanner": "Du bist nicht mehr in der Gruppe. Der Verlauf bleibt, neue Nachrichten kommen nicht mehr an.",
|
||||
"groupsMembersWithCount": "Mitglieder ({count})",
|
||||
"groupsYou": "Du",
|
||||
"groupsFounder": "Hat die Gruppe gegründet",
|
||||
"groupsAdmin": "Admin",
|
||||
"groupsMakeAdmin": "Zum Admin machen",
|
||||
"groupsDismissAdmin": "Adminrolle entziehen",
|
||||
"groupsRemoveMember": "Aus der Gruppe entfernen",
|
||||
"groupsRemoveMemberConfirmTitle": "{name} aus der Gruppe entfernen?",
|
||||
"groupsRemoveMemberConfirmBody": "Neue Nachrichten der Gruppe kommen dann nicht mehr an. Der bisherige Verlauf bleibt erhalten.",
|
||||
"groupsRemoveAction": "Entfernen",
|
||||
"groupsViewContact": "Kontakt anzeigen",
|
||||
"groupsAdminOnlyHint": "Nur Admins können Namen, Mitglieder und Admins der Gruppe ändern.",
|
||||
"groupsAdminOnlyCompatHint": "Nur Admins können die Gruppe umbenennen oder Admins ändern. Personen hinzufügen kann vorerst jeder, weil jemand in der Gruppe eine ältere Šepot-Version hat.",
|
||||
"groupsAdminActionFailed": "Änderung fehlgeschlagen: {error}",
|
||||
"groupsResyncNotice": "Die Gruppe gleicht sich mit dem Admin ab. Änderungen an Mitgliedern können sich kurz verzögern.",
|
||||
"groupsRejectedChangeNotice": "Jemand ohne Berechtigung hat versucht, die Gruppe zu ändern. Die Änderung wurde nicht übernommen.",
|
||||
"errorGroupNotAdmin": "Das kann nur ein Admin der Gruppe.",
|
||||
"errorGroupLastAdmin": "Du bist der einzige Admin. Mach zuerst jemand anderen zum Admin.",
|
||||
"groupsNameHint": "Gruppenname",
|
||||
"commonCancel": "Abbrechen",
|
||||
"commonSave": "Speichern",
|
||||
@@ -51,6 +65,12 @@
|
||||
"chatEditingMessage": "Nachricht bearbeiten",
|
||||
"chatReply": "Antwort",
|
||||
"chatActionReply": "Antworten",
|
||||
"chatActionForward": "Weiterleiten",
|
||||
"forwardTitle": "Weiterleiten an…",
|
||||
"forwardSearchHint": "Kontakt oder Gruppe suchen",
|
||||
"forwardEmpty": "Nichts gefunden",
|
||||
"forwardDone": "Weitergeleitet an {name}",
|
||||
"forwardFailed": "Weiterleiten fehlgeschlagen: {error}",
|
||||
"commonCopy": "Kopieren",
|
||||
"commonEdit": "Bearbeiten",
|
||||
"chatDeleteForMe": "Für mich löschen",
|
||||
@@ -68,6 +88,9 @@
|
||||
"attachmentTakePhoto": "Foto aufnehmen",
|
||||
"attachmentDocument": "Dokument",
|
||||
"attachmentTooLarge": "Datei ist zu groß (max. {maxMb} MB)",
|
||||
"attachmentKeepsMetadataTitle": "Foto enthält den Standort",
|
||||
"attachmentKeepsMetadataBody": "Aus diesem Fotoformat (z. B. HEIC) kann ich die Metadaten nicht entfernen. Es würde im Original gesendet, einschließlich Aufnahmeort und Angaben zum Telefon. Sicherer ist das Senden über die Galerie, dort werden die Metadaten entfernt.",
|
||||
"attachmentSendOriginal": "Original senden",
|
||||
"attachmentPickFailed": "Auswahl des Anhangs fehlgeschlagen: {error}",
|
||||
"attachmentSendFailed": "Senden des Anhangs fehlgeschlagen: {error}",
|
||||
"chatSendFailed": "Senden fehlgeschlagen: {error}",
|
||||
@@ -75,8 +98,11 @@
|
||||
"chatEdited": "bearbeitet",
|
||||
"errorTrustRootChanged": "Der Trust Root des Relays hat sich geändert! Kommunikation wird verweigert.",
|
||||
"errorRelayUnavailable": "Relay nicht erreichbar (Sender-Zertifikat)",
|
||||
"errorNotYetRegistered": "Šepot verbindet sich noch mit dem Relay — versuch es gleich noch einmal.",
|
||||
"errorContactLinkIncomplete": "Dieser Link ist unvollständig — die andere Person hat ihn erstellt, bevor sie verbunden war. Bitte sie um einen neuen sepot://-Link.",
|
||||
"errorAttachmentTooLarge": "Anhang ist zu groß (max. {maxMb} MB)",
|
||||
"errorGroupNeedsMember": "Die Gruppe braucht mindestens 1 Mitglied",
|
||||
"errorAttachmentUnavailable": "Der Anhang ist nicht mehr auf diesem Telefon",
|
||||
"errorGroupMaxMembers": "Die Gruppe kann höchstens {maxMembers} Mitglieder haben",
|
||||
"errorMemberNoDeliveryAddress": "{displayName} hat keine Zustelladresse — frag nach einem neuen Link",
|
||||
"errorContactNoDeliveryAddress": "Der Kontakt hat keine Zustelladresse — bitte ihn um einen neuen sepot://-Link",
|
||||
@@ -161,9 +187,36 @@
|
||||
"attachmentOpenFailed": "Öffnen des Anhangs fehlgeschlagen: {error}",
|
||||
"commonFile": "Datei",
|
||||
"chatListNewConversation": "Neue Unterhaltung",
|
||||
"chatListPhotoWithCaptionPreview": "📷 {caption}",
|
||||
"chatListFilePreview": "📎 {fileName}",
|
||||
"chatListReadReceiptPreview": "Gelesen",
|
||||
"chatListDraft": "Entwurf",
|
||||
"notesTitle": "Notizen für mich",
|
||||
"notesSubtitle": "Nur auf diesem Telefon, verschlüsselt",
|
||||
"callVoice": "Anrufen",
|
||||
"callVideo": "Videoanruf",
|
||||
"callStatusRinging": "Klingelt…",
|
||||
"callIncomingVoice": "Eingehender Anruf",
|
||||
"callIncomingVideo": "Eingehender Videoanruf",
|
||||
"callStatusConnecting": "Verbinde…",
|
||||
"callEnded": "Anruf beendet",
|
||||
"callEndedBusy": "Besetzt",
|
||||
"callEndedRejected": "Anruf abgelehnt",
|
||||
"callEndedUnanswered": "Keine Antwort",
|
||||
"callEndedFailed": "Verbindung fehlgeschlagen",
|
||||
"callEncrypted": "Ende-zu-Ende-verschlüsselt",
|
||||
"callAccept": "Annehmen",
|
||||
"callReject": "Ablehnen",
|
||||
"callMute": "Stummschalten",
|
||||
"callSpeaker": "Lautsprecher",
|
||||
"callCamera": "Kamera aus",
|
||||
"callSwitchCamera": "Kamera wechseln",
|
||||
"callHangUp": "Auflegen",
|
||||
"callLogMissed": "Verpasster Anruf",
|
||||
"callLogUnanswered": "Unbeantworteter Anruf",
|
||||
"callLogRejected": "Abgelehnter Anruf",
|
||||
"callLogBusy": "Besetzt",
|
||||
"callLogFailed": "Anruf fehlgeschlagen",
|
||||
"callLogVoice": "Anruf · {duration}",
|
||||
"callLogVideo": "Videoanruf · {duration}",
|
||||
"chatListTimeNow": "jetzt",
|
||||
"chatListTimeMinutes": "{minutes} Min.",
|
||||
"qrScanTitle": "QR-Code scannen",
|
||||
@@ -262,20 +315,24 @@
|
||||
"pinUnlockWithBiometrics": "Mit Biometrie entsperren",
|
||||
"pinWrong2": "Falsche PIN",
|
||||
"pinUnlockError": "Fehler beim Entsperren",
|
||||
"pinLockedFor": "Zu viele Versuche. Versuche es in {seconds} s erneut.",
|
||||
"pushTitle": "Push-Benachrichtigungen",
|
||||
"pushModeOffDesc": "Nachrichten werden erst beim Öffnen der App abgerufen.",
|
||||
"pushModeUp": "UnifiedPush (empfohlen)",
|
||||
"pushModeUp": "UnifiedPush (ohne Google)",
|
||||
"pushModeUpDesc": "Google-frei — braucht die App ntfy (F-Droid/Play).",
|
||||
"pushModeFcm": "Google (FCM)",
|
||||
"pushModeFcm": "Google (FCM, Standard)",
|
||||
"pushModeFcmDesc": "Zuverlässig ohne zusätzliche App. Der Push-Token des Geräts geht an Google; der Inhalt der Nachrichten nie.",
|
||||
"pushSetupFailed": "Einrichtung der Push-Benachrichtigungen fehlgeschlagen: {error}",
|
||||
"pushHintOff": "Nachrichten kommen erst an, wenn du Šepot öffnest — keine Weckrufe eingerichtet.",
|
||||
"pushHintFailed": "Weckrufe funktionieren nicht; Nachrichten siehst du erst beim Öffnen von Šepot.",
|
||||
"pushRegistrationFailed": "Es kommen keine Weckrufe an — der Distributor hat die Registrierung abgelehnt. Schalte Push aus und wieder ein.",
|
||||
"pushNoDistributorTitle": "Kein Distributor",
|
||||
"pushNoDistributorBody": "Für Google-freie Benachrichtigungen installiere einen UnifiedPush-Distributor, z. B. ntfy (F-Droid oder Play). Aktiviere Push danach erneut.",
|
||||
"commonOk": "OK",
|
||||
"pushPickDistributorTitle": "Distributor wählen",
|
||||
"pushSubtitleUnifiedPush": "UnifiedPush · {distributor}",
|
||||
"pushNoDistributorFallback": "kein Distributor",
|
||||
"pushSubtitleFcm": "Google (FCM) — schnell, opt-in",
|
||||
"pushSubtitleFcm": "Google (FCM)",
|
||||
"restoreErrorNameRequired": "Gib einen Anzeigenamen ein.",
|
||||
"restoreErrorWordCount": "{wordCount, plural, one{Die Phrase hat 1 Wort, benötigt werden {requiredWords}.} other{Die Phrase hat {wordCount} Wörter, benötigt werden {requiredWords}.}}",
|
||||
"restoreErrorInvalidPhrase": "Die Phrase ist ungültig — prüfe die Wörter und ihre Reihenfolge.",
|
||||
@@ -294,6 +351,8 @@
|
||||
"chatListArchive": "Archivieren",
|
||||
"qrShareTitle": "Meine Šepot-ID",
|
||||
"qrIdentityNotFound": "Identität nicht gefunden",
|
||||
"qrShareConnectingTitle": "Verbinde mit dem Relay…",
|
||||
"qrShareConnectingBody": "Dein QR-Code und Link erscheinen, sobald sich Šepot zum ersten Mal verbindet. Einen Moment — es dauert nur ein paar Sekunden.",
|
||||
"commonCopiedToClipboard": "In die Zwischenablage kopiert",
|
||||
"qrShareInviteMessage": "Füg mich auf Šepot hinzu!\n\n{shareString}",
|
||||
"commonShare": "Teilen",
|
||||
@@ -357,5 +416,75 @@
|
||||
"photoPreviewSend": "Senden",
|
||||
"photoPreviewTextDialogTitle": "Text auf dem Foto",
|
||||
"photoPreviewRemoveText": "Entfernen",
|
||||
"photoPreviewDecodeFailed": "Foto konnte nicht geladen werden"
|
||||
"photoPreviewDecodeFailed": "Foto konnte nicht geladen werden",
|
||||
"chatActionInfo": "Nachrichteninfo",
|
||||
"messageInfoSent": "Gesendet",
|
||||
"messageInfoDelivered": "Zugestellt",
|
||||
"messageInfoRead": "Gelesen",
|
||||
"messageInfoPending": "noch nicht",
|
||||
"messageInfoReadReceiptsOff": "Wann deine Nachrichten gelesen wurden, siehst du nur, wenn du selbst Lesebestätigungen teilst (Einstellungen).",
|
||||
"chatStatusReadAt": "Gelesen {time}",
|
||||
"chatStatusDeliveredAt": "Zugestellt {time}",
|
||||
"groupsAddMembers": "Mitglieder hinzufügen",
|
||||
"groupsAddMembersAction": "Hinzufügen",
|
||||
"groupsAddMembersFailed": "Hinzufügen fehlgeschlagen: {error}",
|
||||
"groupsNoContactsToAdd": "Alle deine Kontakte sind schon in der Gruppe. Füge neue Personen zuerst per QR-Code zu deinen Kontakten hinzu.",
|
||||
"navChats": "Chats",
|
||||
"chatListNewChat": "Neuer Chat",
|
||||
"chatListSearchHint": "Chats und Nachrichten durchsuchen",
|
||||
"chatFilterAll": "Alle",
|
||||
"chatFilterUnread": "Ungelesen",
|
||||
"chatFilterGroups": "Gruppen",
|
||||
"chatFilterArchive": "Archiv",
|
||||
"chatListNoResults": "Nichts gefunden.",
|
||||
"chatListFilterEmpty": "Hier ist noch nichts.",
|
||||
"chatListPin": "Anheften",
|
||||
"chatListUnpin": "Lösen",
|
||||
"chatOpenProfile": "Profil anzeigen",
|
||||
"groupsProfile": "Gruppeninfo",
|
||||
"chatSearchHint": "Im Chat suchen",
|
||||
"chatSearchNoMatches": "Keine",
|
||||
"chatSearchCounter": "{current} von {total}",
|
||||
"chatSearchOlder": "Ältere Treffer",
|
||||
"chatSearchNewer": "Neuerer Treffer",
|
||||
"chatUnreadDivider": "{count, plural, one{1 neue Nachricht} other{{count} neue Nachrichten}}",
|
||||
"chatJumpToLatest": "Zu den neuesten",
|
||||
"chatAttach": "Anhängen",
|
||||
"chatSend": "Senden",
|
||||
"chatRecordVoice": "Sprachnachricht aufnehmen",
|
||||
"profileSharedMedia": "Geteilte Medien",
|
||||
"profileMessage": "Nachricht",
|
||||
"profileSearch": "Suchen",
|
||||
"profileUnmute": "Ton an",
|
||||
"profileDisappearing": "Verschwinden",
|
||||
"profileCommonGroups": "Gemeinsame Gruppen",
|
||||
"profileSafetyNumber": "Sicherheitsnummer",
|
||||
"profileSafetyNumberHint": "Persönlich oder per QR-Code vergleichen",
|
||||
"profileVerifiedInPerson": "Persönlich verifiziert",
|
||||
"profileVerifyNow": "Kontakt verifizieren",
|
||||
"profileAdd": "Hinzufügen",
|
||||
"profileGroupSubtitle": "Gruppe · {members} · erstellt am {date}",
|
||||
"profileMembersOfMax": "{count} von {max}",
|
||||
"chatTyping": "schreibt…",
|
||||
"chatTypingNamed": "{name} schreibt…",
|
||||
"chatTypingSeveral": "Mehrere schreiben…",
|
||||
"settingsTypingTitle": "Tippindikator",
|
||||
"settingsTypingSubtitle": "Gegenseitig — wer nicht teilt, sieht nichts (standardmäßig aus)",
|
||||
"pollAttach": "Umfrage",
|
||||
"pollCreateTitle": "Neue Umfrage",
|
||||
"pollQuestion": "Frage",
|
||||
"pollOptionHint": "Option {number}",
|
||||
"pollAddOption": "Option hinzufügen",
|
||||
"pollRemoveOption": "Option entfernen",
|
||||
"pollAllowMultiple": "Mehrere Antworten erlauben",
|
||||
"pollSingle": "Umfrage",
|
||||
"pollMulti": "Umfrage, Mehrfachauswahl",
|
||||
"pollVoters": "{count, plural, =0{noch keine Stimmen} one{1 Stimme} other{{count} Stimmen}}",
|
||||
"pollFallback": "Umfrage: {question}",
|
||||
"pollFallbackUpdate": "Zum Abstimmen Šepot aktualisieren.",
|
||||
"profilePhotoEdit": "Profilbild ändern",
|
||||
"profilePhotoPick": "Foto auswählen",
|
||||
"profilePhotoRemove": "Foto entfernen",
|
||||
"profilePhotoHint": "Nur deine Chatpartner sehen es — Ende-zu-Ende-verschlüsselt wie Nachrichten. Standort und Fotodaten werden nicht gesendet.",
|
||||
"profilePhotoFailed": "Foto konnte nicht gesetzt werden: {error}"
|
||||
}
|
||||
|
||||
+136
-7
@@ -18,9 +18,23 @@
|
||||
"groupsRename": "Rename group",
|
||||
"groupsLeave": "Leave group",
|
||||
"groupsLeftBanner": "You're no longer in this group. Your history stays, but new messages won't arrive.",
|
||||
"groupsMembersWithCount": "Members ({count})",
|
||||
"groupsYou": "You",
|
||||
"groupsFounder": "Founder",
|
||||
"groupsAdmin": "Admin",
|
||||
"groupsMakeAdmin": "Make admin",
|
||||
"groupsDismissAdmin": "Remove admin role",
|
||||
"groupsRemoveMember": "Remove from group",
|
||||
"groupsRemoveMemberConfirmTitle": "Remove {name} from the group?",
|
||||
"groupsRemoveMemberConfirmBody": "They will stop getting new messages from this group. Their existing history stays.",
|
||||
"groupsRemoveAction": "Remove",
|
||||
"groupsViewContact": "View contact",
|
||||
"groupsAdminOnlyHint": "Only admins can change the group name, members and admins.",
|
||||
"groupsAdminOnlyCompatHint": "Only admins can rename the group or change admins. Anyone can still add people, because someone in the group has an older version of Šepot.",
|
||||
"groupsAdminActionFailed": "The change failed: {error}",
|
||||
"groupsResyncNotice": "The group is catching up with its admin. Member changes may show up with a delay.",
|
||||
"groupsRejectedChangeNotice": "Someone without permission tried to change the group. The change was not applied.",
|
||||
"errorGroupNotAdmin": "Only a group admin can do this.",
|
||||
"errorGroupLastAdmin": "You're the only admin. Make someone else an admin first.",
|
||||
"groupsNameHint": "Group name",
|
||||
"commonCancel": "Cancel",
|
||||
"commonSave": "Save",
|
||||
@@ -51,6 +65,12 @@
|
||||
"chatEditingMessage": "Editing message",
|
||||
"chatReply": "Reply",
|
||||
"chatActionReply": "Reply",
|
||||
"chatActionForward": "Forward",
|
||||
"forwardTitle": "Forward to…",
|
||||
"forwardSearchHint": "Search contact or group",
|
||||
"forwardEmpty": "Nothing here",
|
||||
"forwardDone": "Forwarded to {name}",
|
||||
"forwardFailed": "Forwarding failed: {error}",
|
||||
"commonCopy": "Copy",
|
||||
"commonEdit": "Edit",
|
||||
"chatDeleteForMe": "Delete for me",
|
||||
@@ -68,6 +88,9 @@
|
||||
"attachmentTakePhoto": "Take photo",
|
||||
"attachmentDocument": "Document",
|
||||
"attachmentTooLarge": "File is too large (max {maxMb} MB)",
|
||||
"attachmentKeepsMetadataTitle": "Photo will include location",
|
||||
"attachmentKeepsMetadataBody": "I can't remove metadata from this photo format (e.g. HEIC). It would be sent as the original, including where it was taken and details about the phone. Sending it via Gallery is safer, as metadata is removed there.",
|
||||
"attachmentSendOriginal": "Send original",
|
||||
"attachmentPickFailed": "Picking attachment failed: {error}",
|
||||
"attachmentSendFailed": "Sending attachment failed: {error}",
|
||||
"chatSendFailed": "Sending failed: {error}",
|
||||
@@ -75,8 +98,11 @@
|
||||
"chatEdited": "edited",
|
||||
"errorTrustRootChanged": "The relay's trust root has changed! Refusing to communicate.",
|
||||
"errorRelayUnavailable": "Relay unavailable (sender certificate)",
|
||||
"errorNotYetRegistered": "Šepot is still connecting to the relay — try sending again in a moment.",
|
||||
"errorContactLinkIncomplete": "This link is incomplete — the other person created it before connecting. Ask them for a new sepot:// link.",
|
||||
"errorAttachmentTooLarge": "Attachment is too large (max {maxMb} MB)",
|
||||
"errorGroupNeedsMember": "A group needs at least 1 member",
|
||||
"errorAttachmentUnavailable": "The attachment is no longer on this phone",
|
||||
"errorGroupMaxMembers": "A group can have at most {maxMembers} members",
|
||||
"errorMemberNoDeliveryAddress": "{displayName} has no delivery address — ask them for a new link",
|
||||
"errorContactNoDeliveryAddress": "This contact has no delivery address — ask them for a new sepot:// link",
|
||||
@@ -161,9 +187,36 @@
|
||||
"attachmentOpenFailed": "Opening attachment failed: {error}",
|
||||
"commonFile": "File",
|
||||
"chatListNewConversation": "New conversation",
|
||||
"chatListPhotoWithCaptionPreview": "📷 {caption}",
|
||||
"chatListFilePreview": "📎 {fileName}",
|
||||
"chatListReadReceiptPreview": "Read",
|
||||
"chatListDraft": "Draft",
|
||||
"notesTitle": "Note to self",
|
||||
"notesSubtitle": "Only on this phone, encrypted",
|
||||
"callVoice": "Call",
|
||||
"callVideo": "Video call",
|
||||
"callStatusRinging": "Ringing…",
|
||||
"callIncomingVoice": "Incoming call",
|
||||
"callIncomingVideo": "Incoming video call",
|
||||
"callStatusConnecting": "Connecting…",
|
||||
"callEnded": "Call ended",
|
||||
"callEndedBusy": "Busy",
|
||||
"callEndedRejected": "Call declined",
|
||||
"callEndedUnanswered": "No answer",
|
||||
"callEndedFailed": "Couldn't connect",
|
||||
"callEncrypted": "End-to-end encrypted",
|
||||
"callAccept": "Accept",
|
||||
"callReject": "Decline",
|
||||
"callMute": "Mute",
|
||||
"callSpeaker": "Speaker",
|
||||
"callCamera": "Turn off camera",
|
||||
"callSwitchCamera": "Switch camera",
|
||||
"callHangUp": "Hang up",
|
||||
"callLogMissed": "Missed call",
|
||||
"callLogUnanswered": "Unanswered call",
|
||||
"callLogRejected": "Declined call",
|
||||
"callLogBusy": "Busy",
|
||||
"callLogFailed": "Call failed",
|
||||
"callLogVoice": "Call · {duration}",
|
||||
"callLogVideo": "Video call · {duration}",
|
||||
"chatListTimeNow": "now",
|
||||
"chatListTimeMinutes": "{minutes} min",
|
||||
"qrScanTitle": "Scan QR code",
|
||||
@@ -262,20 +315,24 @@
|
||||
"pinUnlockWithBiometrics": "Unlock with biometrics",
|
||||
"pinWrong2": "Wrong PIN",
|
||||
"pinUnlockError": "Unlock error",
|
||||
"pinLockedFor": "Too many attempts. Try again in {seconds} s.",
|
||||
"pushTitle": "Push notifications",
|
||||
"pushModeOffDesc": "Messages arrive only after you open the app.",
|
||||
"pushModeUp": "UnifiedPush (recommended)",
|
||||
"pushModeUp": "UnifiedPush (no Google)",
|
||||
"pushModeUpDesc": "De-Googled — needs the ntfy app (F-Droid/Play).",
|
||||
"pushModeFcm": "Google (FCM)",
|
||||
"pushModeFcm": "Google (FCM, default)",
|
||||
"pushModeFcmDesc": "Reliable without an extra app. The device push token goes to Google; message content never does.",
|
||||
"pushSetupFailed": "Setting up push failed: {error}",
|
||||
"pushHintOff": "Messages arrive only when you open Šepot — wake-ups aren’t set up.",
|
||||
"pushHintFailed": "Wake-ups aren’t working; you’ll see messages only after opening Šepot.",
|
||||
"pushRegistrationFailed": "Wake-ups aren’t arriving — the distributor refused registration. Try turning push off and on again.",
|
||||
"pushNoDistributorTitle": "No distributor",
|
||||
"pushNoDistributorBody": "For de-Googled notifications, install a UnifiedPush distributor such as ntfy (F-Droid or Play). Then turn push on again.",
|
||||
"commonOk": "OK",
|
||||
"pushPickDistributorTitle": "Choose a distributor",
|
||||
"pushSubtitleUnifiedPush": "UnifiedPush · {distributor}",
|
||||
"pushNoDistributorFallback": "no distributor",
|
||||
"pushSubtitleFcm": "Google (FCM) — fast, opt-in",
|
||||
"pushSubtitleFcm": "Google (FCM)",
|
||||
"restoreErrorNameRequired": "Enter a display name.",
|
||||
"restoreErrorWordCount": "{wordCount, plural, one{The phrase has 1 word, {requiredWords} are needed.} other{The phrase has {wordCount} words, {requiredWords} are needed.}}",
|
||||
"restoreErrorInvalidPhrase": "The phrase isn't valid — check the words and their order.",
|
||||
@@ -294,6 +351,8 @@
|
||||
"chatListArchive": "Archive",
|
||||
"qrShareTitle": "My Šepot ID",
|
||||
"qrIdentityNotFound": "Identity not found",
|
||||
"qrShareConnectingTitle": "Connecting to the relay…",
|
||||
"qrShareConnectingBody": "Your QR code and link will appear once Šepot connects for the first time. Just a moment — it only takes a few seconds.",
|
||||
"commonCopiedToClipboard": "Copied to clipboard",
|
||||
"qrShareInviteMessage": "Add me on Šepot!\n\n{shareString}",
|
||||
"commonShare": "Share",
|
||||
@@ -357,5 +416,75 @@
|
||||
"photoPreviewSend": "Send",
|
||||
"photoPreviewTextDialogTitle": "Text on photo",
|
||||
"photoPreviewRemoveText": "Remove",
|
||||
"photoPreviewDecodeFailed": "Couldn't load the photo"
|
||||
"photoPreviewDecodeFailed": "Couldn't load the photo",
|
||||
"chatActionInfo": "Message info",
|
||||
"messageInfoSent": "Sent",
|
||||
"messageInfoDelivered": "Delivered",
|
||||
"messageInfoRead": "Read",
|
||||
"messageInfoPending": "not yet",
|
||||
"messageInfoReadReceiptsOff": "You only see when people read your messages if you share read receipts yourself (Settings).",
|
||||
"chatStatusReadAt": "Read {time}",
|
||||
"chatStatusDeliveredAt": "Delivered {time}",
|
||||
"groupsAddMembers": "Add members",
|
||||
"groupsAddMembersAction": "Add",
|
||||
"groupsAddMembersFailed": "Could not add members: {error}",
|
||||
"groupsNoContactsToAdd": "All your contacts are already in this group. Add someone new to your contacts via QR code first.",
|
||||
"navChats": "Chats",
|
||||
"chatListNewChat": "New chat",
|
||||
"chatListSearchHint": "Search chats and messages",
|
||||
"chatFilterAll": "All",
|
||||
"chatFilterUnread": "Unread",
|
||||
"chatFilterGroups": "Groups",
|
||||
"chatFilterArchive": "Archive",
|
||||
"chatListNoResults": "Nothing found.",
|
||||
"chatListFilterEmpty": "Nothing here yet.",
|
||||
"chatListPin": "Pin",
|
||||
"chatListUnpin": "Unpin",
|
||||
"chatOpenProfile": "View profile",
|
||||
"groupsProfile": "Group info",
|
||||
"chatSearchHint": "Search in chat",
|
||||
"chatSearchNoMatches": "None",
|
||||
"chatSearchCounter": "{current} of {total}",
|
||||
"chatSearchOlder": "Older match",
|
||||
"chatSearchNewer": "Newer match",
|
||||
"chatUnreadDivider": "{count, plural, one{1 new message} other{{count} new messages}}",
|
||||
"chatJumpToLatest": "Jump to latest",
|
||||
"chatAttach": "Attach",
|
||||
"chatSend": "Send",
|
||||
"chatRecordVoice": "Record voice message",
|
||||
"profileSharedMedia": "Shared media",
|
||||
"profileMessage": "Message",
|
||||
"profileSearch": "Search",
|
||||
"profileUnmute": "Unmute",
|
||||
"profileDisappearing": "Disappear",
|
||||
"profileCommonGroups": "Groups in common",
|
||||
"profileSafetyNumber": "Safety number",
|
||||
"profileSafetyNumberHint": "Compare in person or by QR code",
|
||||
"profileVerifiedInPerson": "Verified in person",
|
||||
"profileVerifyNow": "Verify contact",
|
||||
"profileAdd": "Add",
|
||||
"profileGroupSubtitle": "Group · {members} · created {date}",
|
||||
"profileMembersOfMax": "{count} of {max}",
|
||||
"chatTyping": "typing…",
|
||||
"chatTypingNamed": "{name} is typing…",
|
||||
"chatTypingSeveral": "Several people are typing…",
|
||||
"settingsTypingTitle": "Typing indicators",
|
||||
"settingsTypingSubtitle": "Mutual — if you don't share it, you don't see it (off by default)",
|
||||
"pollAttach": "Poll",
|
||||
"pollCreateTitle": "New poll",
|
||||
"pollQuestion": "Question",
|
||||
"pollOptionHint": "Option {number}",
|
||||
"pollAddOption": "Add option",
|
||||
"pollRemoveOption": "Remove option",
|
||||
"pollAllowMultiple": "Allow multiple answers",
|
||||
"pollSingle": "Poll",
|
||||
"pollMulti": "Poll, multiple choice",
|
||||
"pollVoters": "{count, plural, =0{no votes yet} one{1 vote} other{{count} votes}}",
|
||||
"pollFallback": "Poll: {question}",
|
||||
"pollFallbackUpdate": "Update Šepot to vote.",
|
||||
"profilePhotoEdit": "Change profile photo",
|
||||
"profilePhotoPick": "Choose photo",
|
||||
"profilePhotoRemove": "Remove photo",
|
||||
"profilePhotoHint": "Only people you chat with will see it — end-to-end encrypted like your messages. No location or photo metadata is sent.",
|
||||
"profilePhotoFailed": "Could not set photo: {error}"
|
||||
}
|
||||
|
||||
+136
-7
@@ -18,9 +18,23 @@
|
||||
"groupsRename": "Rinomina gruppo",
|
||||
"groupsLeave": "Abbandona il gruppo",
|
||||
"groupsLeftBanner": "Non sei più nel gruppo. La cronologia resta, i nuovi messaggi non arriveranno.",
|
||||
"groupsMembersWithCount": "Membri ({count})",
|
||||
"groupsYou": "Tu",
|
||||
"groupsFounder": "Fondatore",
|
||||
"groupsAdmin": "Amministratore",
|
||||
"groupsMakeAdmin": "Rendi amministratore",
|
||||
"groupsDismissAdmin": "Rimuovi ruolo di amministratore",
|
||||
"groupsRemoveMember": "Rimuovi dal gruppo",
|
||||
"groupsRemoveMemberConfirmTitle": "Rimuovere {name} dal gruppo?",
|
||||
"groupsRemoveMemberConfirmBody": "Non riceverà più i nuovi messaggi del gruppo. La cronologia esistente resta.",
|
||||
"groupsRemoveAction": "Rimuovi",
|
||||
"groupsViewContact": "Vedi contatto",
|
||||
"groupsAdminOnlyHint": "Solo gli amministratori possono cambiare nome, membri e amministratori del gruppo.",
|
||||
"groupsAdminOnlyCompatHint": "Solo gli amministratori possono rinominare il gruppo o cambiare gli amministratori. Per ora chiunque può aggiungere persone, perché qualcuno nel gruppo ha una versione precedente di Šepot.",
|
||||
"groupsAdminActionFailed": "Modifica non riuscita: {error}",
|
||||
"groupsResyncNotice": "Il gruppo si sta allineando con l'amministratore. Le modifiche ai membri potrebbero arrivare in ritardo.",
|
||||
"groupsRejectedChangeNotice": "Qualcuno senza permesso ha provato a modificare il gruppo. La modifica non è stata applicata.",
|
||||
"errorGroupNotAdmin": "Solo un amministratore del gruppo può farlo.",
|
||||
"errorGroupLastAdmin": "Sei l'unico amministratore. Prima rendi amministratore qualcun altro.",
|
||||
"groupsNameHint": "Nome del gruppo",
|
||||
"commonCancel": "Annulla",
|
||||
"commonSave": "Salva",
|
||||
@@ -51,6 +65,12 @@
|
||||
"chatEditingMessage": "Modifica messaggio",
|
||||
"chatReply": "Risposta",
|
||||
"chatActionReply": "Rispondi",
|
||||
"chatActionForward": "Inoltra",
|
||||
"forwardTitle": "Inoltra a…",
|
||||
"forwardSearchHint": "Cerca contatto o gruppo",
|
||||
"forwardEmpty": "Nessun risultato",
|
||||
"forwardDone": "Inoltrato a {name}",
|
||||
"forwardFailed": "Inoltro non riuscito: {error}",
|
||||
"commonCopy": "Copia",
|
||||
"commonEdit": "Modifica",
|
||||
"chatDeleteForMe": "Elimina per me",
|
||||
@@ -68,6 +88,9 @@
|
||||
"attachmentTakePhoto": "Scatta foto",
|
||||
"attachmentDocument": "Documento",
|
||||
"attachmentTooLarge": "Il file è troppo grande (max {maxMb} MB)",
|
||||
"attachmentKeepsMetadataTitle": "La foto includerà la posizione",
|
||||
"attachmentKeepsMetadataBody": "Non riesco a rimuovere i metadati da questo formato (ad es. HEIC). Verrebbe inviata in originale, compresi il luogo dello scatto e i dati del telefono. È più sicuro inviarla dalla Galleria, dove i metadati vengono rimossi.",
|
||||
"attachmentSendOriginal": "Invia originale",
|
||||
"attachmentPickFailed": "Selezione dell'allegato non riuscita: {error}",
|
||||
"attachmentSendFailed": "Invio dell'allegato non riuscito: {error}",
|
||||
"chatSendFailed": "Invio non riuscito: {error}",
|
||||
@@ -75,8 +98,11 @@
|
||||
"chatEdited": "modificato",
|
||||
"errorTrustRootChanged": "Il trust root del relay è cambiato! Comunicazione rifiutata.",
|
||||
"errorRelayUnavailable": "Relay non disponibile (sender certificate)",
|
||||
"errorNotYetRegistered": "Šepot si sta ancora connettendo al relay — riprova a inviare tra un istante.",
|
||||
"errorContactLinkIncomplete": "Questo link è incompleto — l'altra persona l'ha creato prima di connettersi. Chiedile un nuovo link sepot://.",
|
||||
"errorAttachmentTooLarge": "L'allegato è troppo grande (max {maxMb} MB)",
|
||||
"errorGroupNeedsMember": "Il gruppo ha bisogno di almeno 1 membro",
|
||||
"errorAttachmentUnavailable": "L'allegato non è più su questo telefono",
|
||||
"errorGroupMaxMembers": "Il gruppo può avere al massimo {maxMembers} membri",
|
||||
"errorMemberNoDeliveryAddress": "{displayName} non ha un indirizzo di consegna — chiedi un nuovo link",
|
||||
"errorContactNoDeliveryAddress": "Il contatto non ha un indirizzo di consegna — chiedigli un nuovo link sepot://",
|
||||
@@ -161,9 +187,36 @@
|
||||
"attachmentOpenFailed": "Impossibile aprire l'allegato: {error}",
|
||||
"commonFile": "File",
|
||||
"chatListNewConversation": "Nuova conversazione",
|
||||
"chatListPhotoWithCaptionPreview": "📷 {caption}",
|
||||
"chatListFilePreview": "📎 {fileName}",
|
||||
"chatListReadReceiptPreview": "Letto",
|
||||
"chatListDraft": "Bozza",
|
||||
"notesTitle": "Note per me",
|
||||
"notesSubtitle": "Solo su questo telefono, cifrato",
|
||||
"callVoice": "Chiama",
|
||||
"callVideo": "Videochiamata",
|
||||
"callStatusRinging": "Squilla…",
|
||||
"callIncomingVoice": "Chiamata in arrivo",
|
||||
"callIncomingVideo": "Videochiamata in arrivo",
|
||||
"callStatusConnecting": "Connessione…",
|
||||
"callEnded": "Chiamata terminata",
|
||||
"callEndedBusy": "Occupato",
|
||||
"callEndedRejected": "Chiamata rifiutata",
|
||||
"callEndedUnanswered": "Nessuna risposta",
|
||||
"callEndedFailed": "Connessione non riuscita",
|
||||
"callEncrypted": "Crittografia end-to-end",
|
||||
"callAccept": "Accetta",
|
||||
"callReject": "Rifiuta",
|
||||
"callMute": "Disattiva microfono",
|
||||
"callSpeaker": "Altoparlante",
|
||||
"callCamera": "Disattiva fotocamera",
|
||||
"callSwitchCamera": "Cambia fotocamera",
|
||||
"callHangUp": "Riaggancia",
|
||||
"callLogMissed": "Chiamata persa",
|
||||
"callLogUnanswered": "Chiamata senza risposta",
|
||||
"callLogRejected": "Chiamata rifiutata",
|
||||
"callLogBusy": "Occupato",
|
||||
"callLogFailed": "Chiamata non riuscita",
|
||||
"callLogVoice": "Chiamata · {duration}",
|
||||
"callLogVideo": "Videochiamata · {duration}",
|
||||
"chatListTimeNow": "adesso",
|
||||
"chatListTimeMinutes": "{minutes} min",
|
||||
"qrScanTitle": "Scansiona codice QR",
|
||||
@@ -262,20 +315,24 @@
|
||||
"pinUnlockWithBiometrics": "Sblocca con biometria",
|
||||
"pinWrong2": "PIN errato",
|
||||
"pinUnlockError": "Errore di sblocco",
|
||||
"pinLockedFor": "Troppi tentativi. Riprova tra {seconds} s.",
|
||||
"pushTitle": "Notifiche push",
|
||||
"pushModeOffDesc": "I messaggi verranno scaricati solo all'apertura dell'app.",
|
||||
"pushModeUp": "UnifiedPush (consigliato)",
|
||||
"pushModeUp": "UnifiedPush (senza Google)",
|
||||
"pushModeUpDesc": "Senza Google — richiede l'app ntfy (F-Droid/Play).",
|
||||
"pushModeFcm": "Google (FCM)",
|
||||
"pushModeFcm": "Google (FCM, predefinito)",
|
||||
"pushModeFcmDesc": "Affidabile senza altre app. Il token push del dispositivo va a Google; il contenuto dei messaggi mai.",
|
||||
"pushSetupFailed": "Configurazione delle notifiche push non riuscita: {error}",
|
||||
"pushHintOff": "I messaggi arrivano solo quando apri Šepot — le notifiche non sono configurate.",
|
||||
"pushHintFailed": "Le notifiche non funzionano: vedrai i messaggi solo aprendo Šepot.",
|
||||
"pushRegistrationFailed": "Le notifiche non arrivano — il distributore ha rifiutato la registrazione. Prova a disattivare e riattivare il push.",
|
||||
"pushNoDistributorTitle": "Nessun distributore",
|
||||
"pushNoDistributorBody": "Per notifiche senza Google installa un distributore UnifiedPush, ad es. ntfy (F-Droid o Play). Poi riattiva le notifiche push.",
|
||||
"commonOk": "OK",
|
||||
"pushPickDistributorTitle": "Scegli il distributore",
|
||||
"pushSubtitleUnifiedPush": "UnifiedPush · {distributor}",
|
||||
"pushNoDistributorFallback": "nessun distributore",
|
||||
"pushSubtitleFcm": "Google (FCM) — veloce, opt-in",
|
||||
"pushSubtitleFcm": "Google (FCM)",
|
||||
"restoreErrorNameRequired": "Inserisci il nome visualizzato.",
|
||||
"restoreErrorWordCount": "{wordCount, plural, one{La frase ha 1 parola, ne servono {requiredWords}.} other{La frase ha {wordCount} parole, ne servono {requiredWords}.}}",
|
||||
"restoreErrorInvalidPhrase": "La frase non è valida — controlla le parole e il loro ordine.",
|
||||
@@ -294,6 +351,8 @@
|
||||
"chatListArchive": "Archivia",
|
||||
"qrShareTitle": "Il mio Šepot ID",
|
||||
"qrIdentityNotFound": "Identità non trovata",
|
||||
"qrShareConnectingTitle": "Connessione al relay…",
|
||||
"qrShareConnectingBody": "Il tuo codice QR e il link appariranno appena Šepot si connette per la prima volta. Un istante — bastano pochi secondi.",
|
||||
"commonCopiedToClipboard": "Copiato negli appunti",
|
||||
"qrShareInviteMessage": "Aggiungimi su Šepot!\n\n{shareString}",
|
||||
"commonShare": "Condividi",
|
||||
@@ -357,5 +416,75 @@
|
||||
"photoPreviewSend": "Invia",
|
||||
"photoPreviewTextDialogTitle": "Testo sulla foto",
|
||||
"photoPreviewRemoveText": "Rimuovi",
|
||||
"photoPreviewDecodeFailed": "Impossibile caricare la foto"
|
||||
"photoPreviewDecodeFailed": "Impossibile caricare la foto",
|
||||
"chatActionInfo": "Info messaggio",
|
||||
"messageInfoSent": "Inviato",
|
||||
"messageInfoDelivered": "Consegnato",
|
||||
"messageInfoRead": "Letto",
|
||||
"messageInfoPending": "non ancora",
|
||||
"messageInfoReadReceiptsOff": "Vedi quando i tuoi messaggi vengono letti solo se condividi anche tu le conferme di lettura (Impostazioni).",
|
||||
"chatStatusReadAt": "Letto {time}",
|
||||
"chatStatusDeliveredAt": "Consegnato {time}",
|
||||
"groupsAddMembers": "Aggiungi membri",
|
||||
"groupsAddMembersAction": "Aggiungi",
|
||||
"groupsAddMembersFailed": "Impossibile aggiungere: {error}",
|
||||
"groupsNoContactsToAdd": "Tutti i tuoi contatti sono già nel gruppo. Aggiungi prima una nuova persona ai contatti tramite codice QR.",
|
||||
"navChats": "Chat",
|
||||
"chatListNewChat": "Nuova chat",
|
||||
"chatListSearchHint": "Cerca in chat e messaggi",
|
||||
"chatFilterAll": "Tutte",
|
||||
"chatFilterUnread": "Non lette",
|
||||
"chatFilterGroups": "Gruppi",
|
||||
"chatFilterArchive": "Archivio",
|
||||
"chatListNoResults": "Nessun risultato.",
|
||||
"chatListFilterEmpty": "Ancora niente qui.",
|
||||
"chatListPin": "Fissa",
|
||||
"chatListUnpin": "Sblocca",
|
||||
"chatOpenProfile": "Vedi profilo",
|
||||
"groupsProfile": "Info gruppo",
|
||||
"chatSearchHint": "Cerca nella chat",
|
||||
"chatSearchNoMatches": "Nessuno",
|
||||
"chatSearchCounter": "{current} di {total}",
|
||||
"chatSearchOlder": "Risultato precedente",
|
||||
"chatSearchNewer": "Risultato successivo",
|
||||
"chatUnreadDivider": "{count, plural, one{1 nuovo messaggio} other{{count} nuovi messaggi}}",
|
||||
"chatJumpToLatest": "Vai ai più recenti",
|
||||
"chatAttach": "Allega",
|
||||
"chatSend": "Invia",
|
||||
"chatRecordVoice": "Registra messaggio vocale",
|
||||
"profileSharedMedia": "Media condivisi",
|
||||
"profileMessage": "Messaggio",
|
||||
"profileSearch": "Cerca",
|
||||
"profileUnmute": "Riattiva",
|
||||
"profileDisappearing": "Effimeri",
|
||||
"profileCommonGroups": "Gruppi in comune",
|
||||
"profileSafetyNumber": "Numero di sicurezza",
|
||||
"profileSafetyNumberHint": "Confronta di persona o con codice QR",
|
||||
"profileVerifiedInPerson": "Verificato di persona",
|
||||
"profileVerifyNow": "Verifica contatto",
|
||||
"profileAdd": "Aggiungi",
|
||||
"profileGroupSubtitle": "Gruppo · {members} · creato il {date}",
|
||||
"profileMembersOfMax": "{count} su {max}",
|
||||
"chatTyping": "sta scrivendo…",
|
||||
"chatTypingNamed": "{name} sta scrivendo…",
|
||||
"chatTypingSeveral": "Più persone stanno scrivendo…",
|
||||
"settingsTypingTitle": "Indicatore di scrittura",
|
||||
"settingsTypingSubtitle": "Reciproco — se non lo condividi, non lo vedi (disattivato di default)",
|
||||
"pollAttach": "Sondaggio",
|
||||
"pollCreateTitle": "Nuovo sondaggio",
|
||||
"pollQuestion": "Domanda",
|
||||
"pollOptionHint": "Opzione {number}",
|
||||
"pollAddOption": "Aggiungi opzione",
|
||||
"pollRemoveOption": "Rimuovi opzione",
|
||||
"pollAllowMultiple": "Consenti più risposte",
|
||||
"pollSingle": "Sondaggio",
|
||||
"pollMulti": "Sondaggio, scelta multipla",
|
||||
"pollVoters": "{count, plural, =0{ancora nessun voto} one{1 voto} other{{count} voti}}",
|
||||
"pollFallback": "Sondaggio: {question}",
|
||||
"pollFallbackUpdate": "Aggiorna Šepot per votare.",
|
||||
"profilePhotoEdit": "Cambia foto profilo",
|
||||
"profilePhotoPick": "Scegli foto",
|
||||
"profilePhotoRemove": "Rimuovi foto",
|
||||
"profilePhotoHint": "La vedranno solo le persone con cui chatti — cifrata come i messaggi. Posizione e metadati non vengono inviati.",
|
||||
"profilePhotoFailed": "Impossibile impostare la foto: {error}"
|
||||
}
|
||||
|
||||
+136
-7
@@ -18,9 +18,23 @@
|
||||
"groupsRename": "Переименовать группу",
|
||||
"groupsLeave": "Покинуть группу",
|
||||
"groupsLeftBanner": "Вы больше не в группе. История сохранится, но новые сообщения приходить не будут.",
|
||||
"groupsMembersWithCount": "Участники ({count})",
|
||||
"groupsYou": "Вы",
|
||||
"groupsFounder": "Создатель",
|
||||
"groupsAdmin": "Администратор",
|
||||
"groupsMakeAdmin": "Сделать администратором",
|
||||
"groupsDismissAdmin": "Снять роль администратора",
|
||||
"groupsRemoveMember": "Удалить из группы",
|
||||
"groupsRemoveMemberConfirmTitle": "Удалить {name} из группы?",
|
||||
"groupsRemoveMemberConfirmBody": "Новые сообщения группы перестанут приходить. Прежняя история сохранится.",
|
||||
"groupsRemoveAction": "Удалить",
|
||||
"groupsViewContact": "Открыть контакт",
|
||||
"groupsAdminOnlyHint": "Название, участников и администраторов группы меняют только администраторы.",
|
||||
"groupsAdminOnlyCompatHint": "Переименовать группу и менять администраторов могут только администраторы. Добавлять людей пока может любой, потому что у кого-то в группе старая версия Šepot.",
|
||||
"groupsAdminActionFailed": "Не удалось внести изменение: {error}",
|
||||
"groupsResyncNotice": "Группа сверяется с администратором. Изменения состава могут появиться с задержкой.",
|
||||
"groupsRejectedChangeNotice": "Кто-то без прав попытался изменить группу. Изменение не применено.",
|
||||
"errorGroupNotAdmin": "Это может сделать только администратор группы.",
|
||||
"errorGroupLastAdmin": "Вы единственный администратор. Сначала назначьте администратором кого-то ещё.",
|
||||
"groupsNameHint": "Название группы",
|
||||
"commonCancel": "Отмена",
|
||||
"commonSave": "Сохранить",
|
||||
@@ -51,6 +65,12 @@
|
||||
"chatEditingMessage": "Редактирование сообщения",
|
||||
"chatReply": "Ответ",
|
||||
"chatActionReply": "Ответить",
|
||||
"chatActionForward": "Переслать",
|
||||
"forwardTitle": "Переслать в…",
|
||||
"forwardSearchHint": "Найти контакт или группу",
|
||||
"forwardEmpty": "Ничего не найдено",
|
||||
"forwardDone": "Переслано: {name}",
|
||||
"forwardFailed": "Не удалось переслать: {error}",
|
||||
"commonCopy": "Копировать",
|
||||
"commonEdit": "Изменить",
|
||||
"chatDeleteForMe": "Удалить у меня",
|
||||
@@ -68,6 +88,9 @@
|
||||
"attachmentTakePhoto": "Сделать фото",
|
||||
"attachmentDocument": "Документ",
|
||||
"attachmentTooLarge": "Файл слишком большой (макс. {maxMb} МБ)",
|
||||
"attachmentKeepsMetadataTitle": "Фото будет с геолокацией",
|
||||
"attachmentKeepsMetadataBody": "Из этого формата фото (например, HEIC) я не могу удалить метаданные. Оно будет отправлено в оригинале, включая место съёмки и данные о телефоне. Безопаснее отправить его через Галерею — там метаданные удаляются.",
|
||||
"attachmentSendOriginal": "Отправить оригинал",
|
||||
"attachmentPickFailed": "Не удалось выбрать вложение: {error}",
|
||||
"attachmentSendFailed": "Не удалось отправить вложение: {error}",
|
||||
"chatSendFailed": "Не удалось отправить: {error}",
|
||||
@@ -75,8 +98,11 @@
|
||||
"chatEdited": "изменено",
|
||||
"errorTrustRootChanged": "Trust root релея изменился! Соединение отклонено.",
|
||||
"errorRelayUnavailable": "Релей недоступен (sender certificate)",
|
||||
"errorNotYetRegistered": "Šepot ещё подключается к релею — попробуйте отправить сообщение через пару секунд.",
|
||||
"errorContactLinkIncomplete": "Ссылка неполная — собеседник создал её до подключения. Попросите у него новую ссылку sepot://.",
|
||||
"errorAttachmentTooLarge": "Вложение слишком большое (макс. {maxMb} МБ)",
|
||||
"errorGroupNeedsMember": "В группе должен быть хотя бы 1 участник",
|
||||
"errorAttachmentUnavailable": "Вложения больше нет на этом телефоне",
|
||||
"errorGroupMaxMembers": "Максимальное число участников группы — {maxMembers}",
|
||||
"errorMemberNoDeliveryAddress": "У контакта {displayName} нет адреса доставки — запросите новую ссылку",
|
||||
"errorContactNoDeliveryAddress": "У контакта нет адреса доставки — попросите у него новую ссылку sepot://",
|
||||
@@ -161,9 +187,36 @@
|
||||
"attachmentOpenFailed": "Не удалось открыть вложение: {error}",
|
||||
"commonFile": "Файл",
|
||||
"chatListNewConversation": "Новый чат",
|
||||
"chatListPhotoWithCaptionPreview": "📷 {caption}",
|
||||
"chatListFilePreview": "📎 {fileName}",
|
||||
"chatListReadReceiptPreview": "Прочитано",
|
||||
"chatListDraft": "Черновик",
|
||||
"notesTitle": "Заметки для себя",
|
||||
"notesSubtitle": "Только на этом телефоне, в зашифрованном виде",
|
||||
"callVoice": "Позвонить",
|
||||
"callVideo": "Видеозвонок",
|
||||
"callStatusRinging": "Вызов…",
|
||||
"callIncomingVoice": "Входящий звонок",
|
||||
"callIncomingVideo": "Входящий видеозвонок",
|
||||
"callStatusConnecting": "Соединение…",
|
||||
"callEnded": "Звонок завершён",
|
||||
"callEndedBusy": "Занято",
|
||||
"callEndedRejected": "Звонок отклонён",
|
||||
"callEndedUnanswered": "Нет ответа",
|
||||
"callEndedFailed": "Не удалось соединиться",
|
||||
"callEncrypted": "Сквозное шифрование",
|
||||
"callAccept": "Принять",
|
||||
"callReject": "Отклонить",
|
||||
"callMute": "Выключить микрофон",
|
||||
"callSpeaker": "Динамик",
|
||||
"callCamera": "Выключить камеру",
|
||||
"callSwitchCamera": "Сменить камеру",
|
||||
"callHangUp": "Завершить",
|
||||
"callLogMissed": "Пропущенный звонок",
|
||||
"callLogUnanswered": "Звонок без ответа",
|
||||
"callLogRejected": "Отклонённый звонок",
|
||||
"callLogBusy": "Занято",
|
||||
"callLogFailed": "Звонок не удался",
|
||||
"callLogVoice": "Звонок · {duration}",
|
||||
"callLogVideo": "Видеозвонок · {duration}",
|
||||
"chatListTimeNow": "сейчас",
|
||||
"chatListTimeMinutes": "{minutes} мин",
|
||||
"qrScanTitle": "Сканировать QR-код",
|
||||
@@ -262,20 +315,24 @@
|
||||
"pinUnlockWithBiometrics": "Разблокировать биометрией",
|
||||
"pinWrong2": "Неверный PIN",
|
||||
"pinUnlockError": "Ошибка разблокировки",
|
||||
"pinLockedFor": "Слишком много попыток. Повторите через {seconds} с.",
|
||||
"pushTitle": "Push-уведомления",
|
||||
"pushModeOffDesc": "Сообщения загрузятся только после открытия приложения.",
|
||||
"pushModeUp": "UnifiedPush (рекомендуется)",
|
||||
"pushModeUp": "UnifiedPush (без Google)",
|
||||
"pushModeUpDesc": "Без сервисов Google — требуется приложение ntfy (F-Droid/Play).",
|
||||
"pushModeFcm": "Google (FCM)",
|
||||
"pushModeFcm": "Google (FCM, по умолчанию)",
|
||||
"pushModeFcmDesc": "Надёжно и без дополнительных приложений. Push-токен устройства передаётся Google; содержимое сообщений — никогда.",
|
||||
"pushSetupFailed": "Не удалось настроить push: {error}",
|
||||
"pushHintOff": "Сообщения придут, только когда откроешь Šepot — сигналы не настроены.",
|
||||
"pushHintFailed": "Сигналы не работают: сообщения увидишь только после открытия Šepot.",
|
||||
"pushRegistrationFailed": "Сигналы не приходят — дистрибьютор отклонил регистрацию. Попробуй выключить и снова включить push.",
|
||||
"pushNoDistributorTitle": "Нет дистрибьютора",
|
||||
"pushNoDistributorBody": "Для уведомлений без сервисов Google установите дистрибьютор UnifiedPush, например ntfy (F-Droid или Play). Затем включите push снова.",
|
||||
"commonOk": "OK",
|
||||
"pushPickDistributorTitle": "Выберите дистрибьютора",
|
||||
"pushSubtitleUnifiedPush": "UnifiedPush · {distributor}",
|
||||
"pushNoDistributorFallback": "без дистрибьютора",
|
||||
"pushSubtitleFcm": "Google (FCM) — быстро, только по вашему выбору",
|
||||
"pushSubtitleFcm": "Google (FCM)",
|
||||
"restoreErrorNameRequired": "Введите отображаемое имя.",
|
||||
"restoreErrorWordCount": "{wordCount, plural, one{Во фразе {wordCount} слово, а нужно {requiredWords}.} few{Во фразе {wordCount} слова, а нужно {requiredWords}.} many{Во фразе {wordCount} слов, а нужно {requiredWords}.} other{Во фразе {wordCount} слова, а нужно {requiredWords}.}}",
|
||||
"restoreErrorInvalidPhrase": "Фраза недействительна — проверьте слова и их порядок.",
|
||||
@@ -294,6 +351,8 @@
|
||||
"chatListArchive": "Архивировать",
|
||||
"qrShareTitle": "Мой Šepot ID",
|
||||
"qrIdentityNotFound": "Идентичность не найдена",
|
||||
"qrShareConnectingTitle": "Подключение к релею…",
|
||||
"qrShareConnectingBody": "Ваш QR-код и ссылка появятся, как только Šepot подключится в первый раз. Подождите пару секунд.",
|
||||
"commonCopiedToClipboard": "Скопировано в буфер обмена",
|
||||
"qrShareInviteMessage": "Добавьте меня в Šepot!\n\n{shareString}",
|
||||
"commonShare": "Поделиться",
|
||||
@@ -357,5 +416,75 @@
|
||||
"photoPreviewSend": "Отправить",
|
||||
"photoPreviewTextDialogTitle": "Текст на фото",
|
||||
"photoPreviewRemoveText": "Удалить",
|
||||
"photoPreviewDecodeFailed": "Не удалось загрузить фото"
|
||||
"photoPreviewDecodeFailed": "Не удалось загрузить фото",
|
||||
"chatActionInfo": "Сведения о сообщении",
|
||||
"messageInfoSent": "Отправлено",
|
||||
"messageInfoDelivered": "Доставлено",
|
||||
"messageInfoRead": "Прочитано",
|
||||
"messageInfoPending": "ещё нет",
|
||||
"messageInfoReadReceiptsOff": "Время прочтения видно, только если вы сами делитесь отчётами о прочтении (Настройки).",
|
||||
"chatStatusReadAt": "Прочитано {time}",
|
||||
"chatStatusDeliveredAt": "Доставлено {time}",
|
||||
"groupsAddMembers": "Добавить участников",
|
||||
"groupsAddMembersAction": "Добавить",
|
||||
"groupsAddMembersFailed": "Не удалось добавить: {error}",
|
||||
"groupsNoContactsToAdd": "Все ваши контакты уже в группе. Сначала добавьте нового человека в контакты по QR-коду.",
|
||||
"navChats": "Чаты",
|
||||
"chatListNewChat": "Новый чат",
|
||||
"chatListSearchHint": "Поиск по чатам и сообщениям",
|
||||
"chatFilterAll": "Все",
|
||||
"chatFilterUnread": "Непрочитанные",
|
||||
"chatFilterGroups": "Группы",
|
||||
"chatFilterArchive": "Архив",
|
||||
"chatListNoResults": "Ничего не найдено.",
|
||||
"chatListFilterEmpty": "Здесь пока ничего нет.",
|
||||
"chatListPin": "Закрепить",
|
||||
"chatListUnpin": "Открепить",
|
||||
"chatOpenProfile": "Открыть профиль",
|
||||
"groupsProfile": "Информация о группе",
|
||||
"chatSearchHint": "Поиск в чате",
|
||||
"chatSearchNoMatches": "Нет",
|
||||
"chatSearchCounter": "{current} из {total}",
|
||||
"chatSearchOlder": "Предыдущее совпадение",
|
||||
"chatSearchNewer": "Следующее совпадение",
|
||||
"chatUnreadDivider": "{count, plural, one{{count} новое сообщение} few{{count} новых сообщения} other{{count} новых сообщений}}",
|
||||
"chatJumpToLatest": "К последним сообщениям",
|
||||
"chatAttach": "Прикрепить",
|
||||
"chatSend": "Отправить",
|
||||
"chatRecordVoice": "Записать голосовое",
|
||||
"profileSharedMedia": "Общие медиа",
|
||||
"profileMessage": "Написать",
|
||||
"profileSearch": "Поиск",
|
||||
"profileUnmute": "Включить звук",
|
||||
"profileDisappearing": "Исчезание",
|
||||
"profileCommonGroups": "Общие группы",
|
||||
"profileSafetyNumber": "Код безопасности",
|
||||
"profileSafetyNumberHint": "Сравните лично или по QR-коду",
|
||||
"profileVerifiedInPerson": "Проверено лично",
|
||||
"profileVerifyNow": "Проверить контакт",
|
||||
"profileAdd": "Добавить",
|
||||
"profileGroupSubtitle": "Группа · {members} · создана {date}",
|
||||
"profileMembersOfMax": "{count} из {max}",
|
||||
"chatTyping": "печатает…",
|
||||
"chatTypingNamed": "{name} печатает…",
|
||||
"chatTypingSeveral": "Несколько человек печатают…",
|
||||
"settingsTypingTitle": "Индикатор набора",
|
||||
"settingsTypingSubtitle": "Взаимно — не делитесь, не видите (по умолчанию выкл.)",
|
||||
"pollAttach": "Опрос",
|
||||
"pollCreateTitle": "Новый опрос",
|
||||
"pollQuestion": "Вопрос",
|
||||
"pollOptionHint": "Вариант {number}",
|
||||
"pollAddOption": "Добавить вариант",
|
||||
"pollRemoveOption": "Удалить вариант",
|
||||
"pollAllowMultiple": "Разрешить несколько ответов",
|
||||
"pollSingle": "Опрос",
|
||||
"pollMulti": "Опрос, несколько ответов",
|
||||
"pollVoters": "{count, plural, =0{пока нет голосов} one{{count} голос} few{{count} голоса} other{{count} голосов}}",
|
||||
"pollFallback": "Опрос: {question}",
|
||||
"pollFallbackUpdate": "Обновите Šepot, чтобы голосовать.",
|
||||
"profilePhotoEdit": "Изменить фото профиля",
|
||||
"profilePhotoPick": "Выбрать фото",
|
||||
"profilePhotoRemove": "Удалить фото",
|
||||
"profilePhotoHint": "Фото увидят только ваши собеседники — зашифровано, как и сообщения. Геоданные и метаданные не отправляются.",
|
||||
"profilePhotoFailed": "Не удалось установить фото: {error}"
|
||||
}
|
||||
|
||||
@@ -212,12 +212,6 @@ abstract class AppLocalizations {
|
||||
/// **'Už nejsi ve skupině. Historie zůstává, nové zprávy nedorazí.'**
|
||||
String get groupsLeftBanner;
|
||||
|
||||
/// Nadpis bottom sheetu se seznamem členů skupiny
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Členové ({count})'**
|
||||
String groupsMembersWithCount(int count);
|
||||
|
||||
/// Fallback jména vlastního avataru v seznamu členů skupiny | Titulek ListTile vlastní osoby v seznamu členů skupiny — stejný klíč jako ř. 486
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
@@ -230,6 +224,96 @@ abstract class AppLocalizations {
|
||||
/// **'Zakladatel'**
|
||||
String get groupsFounder;
|
||||
|
||||
/// Odznak správce skupiny u člena v seznamu členů
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Správce'**
|
||||
String get groupsAdmin;
|
||||
|
||||
/// Akce v nabídce člena: udělit roli správce
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Udělat správcem'**
|
||||
String get groupsMakeAdmin;
|
||||
|
||||
/// Akce v nabídce člena: odebrat roli správce
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Odebrat roli správce'**
|
||||
String get groupsDismissAdmin;
|
||||
|
||||
/// Akce v nabídce člena: odebrat ze skupiny
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Odebrat ze skupiny'**
|
||||
String get groupsRemoveMember;
|
||||
|
||||
/// Titulek potvrzení odebrání člena
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Odebrat {name} ze skupiny?'**
|
||||
String groupsRemoveMemberConfirmTitle(String name);
|
||||
|
||||
/// Text potvrzení odebrání člena
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Přestanou mu chodit nové zprávy skupiny. Dosavadní historie mu zůstane.'**
|
||||
String get groupsRemoveMemberConfirmBody;
|
||||
|
||||
/// Potvrzovací tlačítko odebrání člena
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Odebrat'**
|
||||
String get groupsRemoveAction;
|
||||
|
||||
/// Akce v nabídce člena: otevřít profil kontaktu
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Zobrazit kontakt'**
|
||||
String get groupsViewContact;
|
||||
|
||||
/// Vysvětlení pod seznamem členů pro ne-správce ve vynucené skupině
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Jméno, členy a správce skupiny mění jen správci.'**
|
||||
String get groupsAdminOnlyHint;
|
||||
|
||||
/// Vysvětlení pro ne-správce, dokud je ve skupině člen se starší verzí aplikace
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Jméno a správce mění jen správci. Přidávat lidi zatím může každý, protože někdo ve skupině má starší verzi Šepotu.'**
|
||||
String get groupsAdminOnlyCompatHint;
|
||||
|
||||
/// SnackBar při selhání správcovské akce
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Změna se nepovedla: {error}'**
|
||||
String groupsAdminActionFailed(String error);
|
||||
|
||||
/// Upozornění v profilu skupiny, když chybí mezistav od správce
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Stav skupiny se srovnává se správcem. Změny členů se můžou chvíli opozdit.'**
|
||||
String get groupsResyncNotice;
|
||||
|
||||
/// Upozornění v profilu skupiny po odmítnuté neoprávněné změně
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Někdo bez oprávnění zkusil skupinu změnit. Změna se neprovedla.'**
|
||||
String get groupsRejectedChangeNotice;
|
||||
|
||||
/// Chyba: akci smí udělat jen správce skupiny
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Tohle může udělat jen správce skupiny.'**
|
||||
String get errorGroupNotAdmin;
|
||||
|
||||
/// Chyba: poslední správce se nemůže vzdát role
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Jsi jediný správce. Nejdřív udělej správcem někoho dalšího.'**
|
||||
String get errorGroupLastAdmin;
|
||||
|
||||
/// hintText TextFieldu v dialogu přejmenování skupiny | labelText textového pole pro jméno skupiny (max 64 znaků)
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
@@ -410,6 +494,42 @@ abstract class AppLocalizations {
|
||||
/// **'Odpovědět'**
|
||||
String get chatActionReply;
|
||||
|
||||
/// Akce v nabídce po podržení zprávy
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Přeposlat'**
|
||||
String get chatActionForward;
|
||||
|
||||
/// Nadpis panelu výběru chatu pro přeposlání
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Přeposlat do…'**
|
||||
String get forwardTitle;
|
||||
|
||||
/// Nápověda v poli hledání panelu přeposlání
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Hledat kontakt nebo skupinu'**
|
||||
String get forwardSearchHint;
|
||||
|
||||
/// Prázdný výsledek hledání v panelu přeposlání
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Nic tu není'**
|
||||
String get forwardEmpty;
|
||||
|
||||
/// SnackBar po přeposlání zprávy
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Přeposláno: {name}'**
|
||||
String forwardDone(String name);
|
||||
|
||||
/// SnackBar při chybě přeposlání
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Přeposlání se nepovedlo: {error}'**
|
||||
String forwardFailed(String error);
|
||||
|
||||
/// Položka akčního bottom sheetu zprávy — kopírování textu do schránky | Popisek OutlinedButton.icon pro zkopírování Šepot ID do schránky
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
@@ -512,6 +632,24 @@ abstract class AppLocalizations {
|
||||
/// **'Soubor je příliš velký (max {maxMb} MB)'**
|
||||
String attachmentTooLarge(int maxMb);
|
||||
|
||||
/// Titulek dialogu před odesláním obrázku jako dokumentu, když z něj nejde odstranit EXIF (HEIC, RAW)
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Fotka odejde i s polohou'**
|
||||
String get attachmentKeepsMetadataTitle;
|
||||
|
||||
/// Text téhož dialogu
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Tenhle formát fotky (třeba HEIC) neumím zbavit metadat. Odešla by v originále včetně polohy, kde vznikla, a údajů o telefonu. Bezpečnější je poslat ji přes Galerii, kde se metadata odstraní.'**
|
||||
String get attachmentKeepsMetadataBody;
|
||||
|
||||
/// Potvrzovací tlačítko téhož dialogu
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Poslat originál'**
|
||||
String get attachmentSendOriginal;
|
||||
|
||||
/// SnackBar (_showError) při selhání výběru přílohy
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
@@ -554,6 +692,18 @@ abstract class AppLocalizations {
|
||||
/// **'Relay nedostupný (sender certificate)'**
|
||||
String get errorRelayUnavailable;
|
||||
|
||||
/// NotYetRegisteredError z _sendContent, když vlastní registrace na relay ještě nedoběhla (čerstvá instalace). 'Šepot' je značka, nepřekládat.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Šepot se ještě připojuje k relayi — zkus zprávu poslat za chvíli.'**
|
||||
String get errorNotYetRegistered;
|
||||
|
||||
/// ContactLinkIncompleteError při přidání kontaktu z odkazu/QR bez routingu (aid+da). 'sepot://' je schéma odkazu, zachovat.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Odkaz je neúplný — protistrana ho vytvořila dřív, než se stihla připojit. Požádej ji o nový sepot:// odkaz.'**
|
||||
String get errorContactLinkIncomplete;
|
||||
|
||||
/// StateError z _prepareAttachment při překročení limitu velikosti přílohy; zobrazuje UI výběru/odeslání přílohy. POZOR: v kódu jde o dva adjacentní literály 'Příloha je příliš velká ' (ř. 448) + '(max ${…} MB)' (ř. 449) — sloučit do jednoho klíče.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
@@ -566,6 +716,12 @@ abstract class AppLocalizations {
|
||||
/// **'Skupina potřebuje aspoň 1 člena'**
|
||||
String get errorGroupNeedsMember;
|
||||
|
||||
/// Chyba při přeposlání přílohy, která už byla smazána nebo nestažena
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Příloha už v telefonu není'**
|
||||
String get errorAttachmentUnavailable;
|
||||
|
||||
/// StateError z createGroup při překročení max. počtu členů; zobrazuje UI zakládání skupiny. Počet v textu + české skloňování (člen/členové/členů) → ICU plural. | StateError z addGroupMembers — identický text jako v createGroup (ř. 583), stejný klíč. ICU plural.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
@@ -1070,24 +1226,186 @@ abstract class AppLocalizations {
|
||||
/// **'Nová konverzace'**
|
||||
String get chatListNewConversation;
|
||||
|
||||
/// Náhled poslední zprávy v dlaždici — fotka s popiskem; emoji je součást textu
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'📷 {caption}'**
|
||||
String chatListPhotoWithCaptionPreview(String caption);
|
||||
|
||||
/// Náhled poslední zprávy v dlaždici — soubor; emoji je součást textu. Skládaný literál: obsahuje vnořený fallback "Soubor" (viz samostatný záznam) — při lokalizaci předat placeholderu už vyřešenou hodnotu msg.fileName ?? commonFile.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'📎 {fileName}'**
|
||||
String chatListFilePreview(String fileName);
|
||||
|
||||
/// Náhled poslední zprávy v dlaždici pro zprávu typu readReceipt (potvrzení o přečtení)
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Přečteno'**
|
||||
String get chatListReadReceiptPreview;
|
||||
|
||||
/// Předpona náhledu v seznamu chatů, když je v chatu rozepsaná zpráva
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Koncept'**
|
||||
String get chatListDraft;
|
||||
|
||||
/// Název chatu se sebou samým (seznam chatů, hlavička, kontakty, přeposlání)
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Poznámky pro sebe'**
|
||||
String get notesTitle;
|
||||
|
||||
/// Podtitulek poznámek pro sebe — nic se neodesílá na server
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Jen v tomto telefonu, šifrovaně'**
|
||||
String get notesSubtitle;
|
||||
|
||||
/// Tlačítko hlasového hovoru v hlavičce chatu
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Zavolat'**
|
||||
String get callVoice;
|
||||
|
||||
/// Tlačítko videohovoru v hlavičce chatu
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Videohovor'**
|
||||
String get callVideo;
|
||||
|
||||
/// Stav odchozího hovoru, dokud protistrana nezvedne
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Vyzvání…'**
|
||||
String get callStatusRinging;
|
||||
|
||||
/// Stav příchozího hlasového hovoru
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Příchozí hovor'**
|
||||
String get callIncomingVoice;
|
||||
|
||||
/// Stav příchozího videohovoru
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Příchozí videohovor'**
|
||||
String get callIncomingVideo;
|
||||
|
||||
/// Stav po přijetí, než naběhne spojení
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Spojuji…'**
|
||||
String get callStatusConnecting;
|
||||
|
||||
/// Krátce po konci hovoru
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Hovor skončil'**
|
||||
String get callEnded;
|
||||
|
||||
/// Protistrana má jiný hovor
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Obsazeno'**
|
||||
String get callEndedBusy;
|
||||
|
||||
/// Hovor byl odmítnut
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Hovor odmítnut'**
|
||||
String get callEndedRejected;
|
||||
|
||||
/// Odchozí hovor vypršel bez odpovědi
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Nikdo to nevzal'**
|
||||
String get callEndedUnanswered;
|
||||
|
||||
/// Hovor se nepodařilo spojit
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Spojení se nepodařilo'**
|
||||
String get callEndedFailed;
|
||||
|
||||
/// Podtitulek na obrazovce hovoru
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Šifrováno end-to-end'**
|
||||
String get callEncrypted;
|
||||
|
||||
/// Přijetí příchozího hovoru
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Přijmout'**
|
||||
String get callAccept;
|
||||
|
||||
/// Odmítnutí příchozího hovoru
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Odmítnout'**
|
||||
String get callReject;
|
||||
|
||||
/// Přepínač mikrofonu (popisek pro čtečku)
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Ztlumit mikrofon'**
|
||||
String get callMute;
|
||||
|
||||
/// Přepínač hlasitého odposlechu
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Reproduktor'**
|
||||
String get callSpeaker;
|
||||
|
||||
/// Přepínač kamery ve videohovoru
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Vypnout kameru'**
|
||||
String get callCamera;
|
||||
|
||||
/// Přední/zadní kamera
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Přepnout kameru'**
|
||||
String get callSwitchCamera;
|
||||
|
||||
/// Ukončení hovoru
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Zavěsit'**
|
||||
String get callHangUp;
|
||||
|
||||
/// Záznam v chatu
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Zmeškaný hovor'**
|
||||
String get callLogMissed;
|
||||
|
||||
/// Odchozí hovor, který protistrana nevzala
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Nepřijatý hovor'**
|
||||
String get callLogUnanswered;
|
||||
|
||||
/// Záznam v chatu
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Odmítnutý hovor'**
|
||||
String get callLogRejected;
|
||||
|
||||
/// Záznam v chatu — protistrana měla jiný hovor
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Obsazeno'**
|
||||
String get callLogBusy;
|
||||
|
||||
/// Záznam v chatu
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Hovor se nespojil'**
|
||||
String get callLogFailed;
|
||||
|
||||
/// Záznam proběhlého hlasového hovoru s délkou
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Hovor · {duration}'**
|
||||
String callLogVoice(String duration);
|
||||
|
||||
/// Záznam proběhlého videohovoru s délkou
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Videohovor · {duration}'**
|
||||
String callLogVideo(String duration);
|
||||
|
||||
/// Relativní čas poslední zprávy v dlaždici, méně než 1 minuta
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
@@ -1676,6 +1994,12 @@ abstract class AppLocalizations {
|
||||
/// **'Chyba odemčení'**
|
||||
String get pinUnlockError;
|
||||
|
||||
/// Hláška na odemykací obrazovce po sérii špatných PINů; {seconds} = počet sekund do dalšího pokusu
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Příliš mnoho pokusů. Zkus to za {seconds} s.'**
|
||||
String pinLockedFor(int seconds);
|
||||
|
||||
/// Titulek SimpleDialog výběru režimu pushů. Stejný text jako title ListTile v nastavení (ř. 184) → stejný klíč. | Title ListTile v nastavení — stejný text a klíč jako titulek dialogu (ř. 44)
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
@@ -1688,10 +2012,10 @@ abstract class AppLocalizations {
|
||||
/// **'Zprávy se stáhnou až po otevření appky.'**
|
||||
String get pushModeOffDesc;
|
||||
|
||||
/// Label volby 'up' v dialogu výběru režimu pushů ('UnifiedPush' je název technologie — nepřekládat, přeložit jen '(doporučeno)')
|
||||
/// Label volby 'up' v dialogu výběru režimu pushů ('UnifiedPush' je název technologie — nepřekládat, přeložit jen '(bez Googlu)')
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'UnifiedPush (doporučeno)'**
|
||||
/// **'UnifiedPush (bez Googlu)'**
|
||||
String get pushModeUp;
|
||||
|
||||
/// Popisek volby UnifiedPush v dialogu výběru režimu pushů ('ntfy', 'F-Droid', 'Play' jsou názvy — nepřekládat)
|
||||
@@ -1703,7 +2027,7 @@ abstract class AppLocalizations {
|
||||
/// Label volby 'fcm' v dialogu výběru režimu pushů (vlastní název — pravděpodobně shodný ve všech jazycích, ale klíč mít)
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Google (FCM)'**
|
||||
/// **'Google (FCM, výchozí)'**
|
||||
String get pushModeFcm;
|
||||
|
||||
/// Popisek volby Google (FCM) v dialogu výběru režimu pushů. POZOR: v kódu DVA adjacentní literály (ř. 51–52: '…jde ' + 'Googlu; obsah…') — sloučit do jednoho klíče.
|
||||
@@ -1718,6 +2042,24 @@ abstract class AppLocalizations {
|
||||
/// **'Nastavení pushů selhalo: {error}'**
|
||||
String pushSetupFailed(String error);
|
||||
|
||||
/// Proužek nad seznamem chatů, když push není vůbec zapnutý.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Zprávy ti přijdou, až Šepot otevřeš — budíčky nejsou nastavené.'**
|
||||
String get pushHintOff;
|
||||
|
||||
/// Proužek nad seznamem chatů, když distributor registraci odmítl.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Budíčky nefungují, zprávy uvidíš až po otevření Šepotu.'**
|
||||
String get pushHintFailed;
|
||||
|
||||
/// Podtitulek dlaždice Push v Nastavení, když distributor odmítl registraci. Bez téhle hlášky uživatel vidí push jako zapnutý, ale zprávy mu chodí až po otevření appky.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Budíčky nechodí — distributor registraci odmítl. Zkus push vypnout a znovu zapnout.'**
|
||||
String get pushRegistrationFailed;
|
||||
|
||||
/// Titulek AlertDialog, když v systému není nainstalovaný žádný UnifiedPush distributor
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
@@ -1757,7 +2099,7 @@ abstract class AppLocalizations {
|
||||
/// Subtitle ListTile v nastavení, když je aktivní režim FCM
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Google (FCM) — rychlé, opt-in'**
|
||||
/// **'Google (FCM)'**
|
||||
String get pushSubtitleFcm;
|
||||
|
||||
/// Validační chyba pod formulářem obnovy účtu, když je jméno prázdné
|
||||
@@ -1862,6 +2204,18 @@ abstract class AppLocalizations {
|
||||
/// **'Můj Šepot ID'**
|
||||
String get qrShareTitle;
|
||||
|
||||
/// Titulek na QR obrazovce, když vlastní registrace na relay ještě nedoběhla — routing (aid+da) není hotový, QR by byl neúplný.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Připojuji k relayi…'**
|
||||
String get qrShareConnectingTitle;
|
||||
|
||||
/// Vysvětlení pod titulkem qrShareConnectingTitle. 'Šepot' je značka, nepřekládat.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Až se Šepot poprvé připojí, objeví se tvůj QR kód a odkaz. Chvilku strpení — stačí pár sekund.'**
|
||||
String get qrShareConnectingBody;
|
||||
|
||||
/// Text uprostřed obrazovky, když identityProvider vrátí null (nemělo by běžně nastat)
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
@@ -2251,6 +2605,426 @@ abstract class AppLocalizations {
|
||||
/// In cs, this message translates to:
|
||||
/// **'Fotku se nepodařilo načíst'**
|
||||
String get photoPreviewDecodeFailed;
|
||||
|
||||
/// Položka v nabídce zprávy — detail doručení a přečtení.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Info o zprávě'**
|
||||
String get chatActionInfo;
|
||||
|
||||
/// Info o zprávě: řádek času odeslání.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Odesláno'**
|
||||
String get messageInfoSent;
|
||||
|
||||
/// Info o zprávě: řádek času doručení.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Doručeno'**
|
||||
String get messageInfoDelivered;
|
||||
|
||||
/// Info o zprávě: řádek času přečtení.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Přečteno'**
|
||||
String get messageInfoRead;
|
||||
|
||||
/// Info o zprávě: událost ještě nenastala.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'zatím ne'**
|
||||
String get messageInfoPending;
|
||||
|
||||
/// Info o zprávě: vysvětlení, proč chybí čas přečtení.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Kdy si zprávu přečetli, uvidíš, jen když sám sdílíš potvrzení přečtení (Nastavení).'**
|
||||
String get messageInfoReadReceiptsOff;
|
||||
|
||||
/// Řádek pod poslední odeslanou zprávou — čas přečtení.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Přečteno {time}'**
|
||||
String chatStatusReadAt(String time);
|
||||
|
||||
/// Řádek pod poslední odeslanou zprávou — čas doručení.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Doručeno {time}'**
|
||||
String chatStatusDeliveredAt(String time);
|
||||
|
||||
/// Nabídka skupiny a titulek obrazovky pro přidání lidí.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Přidat členy'**
|
||||
String get groupsAddMembers;
|
||||
|
||||
/// Potvrzovací tlačítko v AppBaru při přidávání členů.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Přidat'**
|
||||
String get groupsAddMembersAction;
|
||||
|
||||
/// Chyba při přidávání členů.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Přidání se nepovedlo: {error}'**
|
||||
String groupsAddMembersFailed(String error);
|
||||
|
||||
/// Prázdný výběr — všechny kontakty už ve skupině jsou.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Všechny tvoje kontakty už ve skupině jsou. Nového člověka nejdřív přidej do kontaktů přes QR kód.'**
|
||||
String get groupsNoContactsToAdd;
|
||||
|
||||
/// Karta spodní navigace a nadpis seznamu chatů.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Chaty'**
|
||||
String get navChats;
|
||||
|
||||
/// Tlačítko pro zahájení nového chatu.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Nový chat'**
|
||||
String get chatListNewChat;
|
||||
|
||||
/// Placeholder hledání v seznamu chatů.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Hledat v chatech a zprávách'**
|
||||
String get chatListSearchHint;
|
||||
|
||||
/// Filtr seznamu chatů — vše.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Vše'**
|
||||
String get chatFilterAll;
|
||||
|
||||
/// Filtr seznamu chatů — nepřečtené.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Nepřečtené'**
|
||||
String get chatFilterUnread;
|
||||
|
||||
/// Filtr seznamu chatů — skupiny.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Skupiny'**
|
||||
String get chatFilterGroups;
|
||||
|
||||
/// Odkaz na archiv v řádku filtrů.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Archiv'**
|
||||
String get chatFilterArchive;
|
||||
|
||||
/// Hledání v chatech nic nenašlo.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Nic nenalezeno.'**
|
||||
String get chatListNoResults;
|
||||
|
||||
/// Vybraný filtr je prázdný.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Tady zatím nic není.'**
|
||||
String get chatListFilterEmpty;
|
||||
|
||||
/// Akce: připnout chat nahoru.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Připnout'**
|
||||
String get chatListPin;
|
||||
|
||||
/// Akce: odepnout chat.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Odepnout'**
|
||||
String get chatListUnpin;
|
||||
|
||||
/// Hlavička chatu / menu: otevřít profil kontaktu.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Zobrazit profil'**
|
||||
String get chatOpenProfile;
|
||||
|
||||
/// Menu skupinového chatu: profil skupiny.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Profil skupiny'**
|
||||
String get groupsProfile;
|
||||
|
||||
/// Hledání v jedné konverzaci — placeholder a popisek tlačítka.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Hledat v konverzaci'**
|
||||
String get chatSearchHint;
|
||||
|
||||
/// Hledání v konverzaci bez výsledku (krátce, vedle pole).
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Nic'**
|
||||
String get chatSearchNoMatches;
|
||||
|
||||
/// Pořadí aktuální shody hledání, např. „2 z 5“.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'{current} z {total}'**
|
||||
String chatSearchCounter(int current, int total);
|
||||
|
||||
/// Tlačítko: předchozí (starší) shoda.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Starší shoda'**
|
||||
String get chatSearchOlder;
|
||||
|
||||
/// Tlačítko: další (novější) shoda.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Novější shoda'**
|
||||
String get chatSearchNewer;
|
||||
|
||||
/// Oddělovač nad první nepřečtenou zprávou.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'{count, plural, one{1 nová zpráva} few{{count} nové zprávy} other{{count} nových zpráv}}'**
|
||||
String chatUnreadDivider(int count);
|
||||
|
||||
/// Tlačítko: skok na nejnovější zprávy.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Na nejnovější zprávy'**
|
||||
String get chatJumpToLatest;
|
||||
|
||||
/// Tlačítko přílohy v poli pro psaní.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Přiložit'**
|
||||
String get chatAttach;
|
||||
|
||||
/// Tlačítko odeslat.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Odeslat'**
|
||||
String get chatSend;
|
||||
|
||||
/// Tlačítko nahrát hlasovou zprávu.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Nahrát hlasovou zprávu'**
|
||||
String get chatRecordVoice;
|
||||
|
||||
/// Karta sdílených fotek v profilu.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Sdílená média'**
|
||||
String get profileSharedMedia;
|
||||
|
||||
/// Akce profilu: napsat zprávu.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Zpráva'**
|
||||
String get profileMessage;
|
||||
|
||||
/// Akce profilu: hledat v konverzaci.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Hledat'**
|
||||
String get profileSearch;
|
||||
|
||||
/// Akce profilu: zrušit ztlumení (krátce).
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Zvuk'**
|
||||
String get profileUnmute;
|
||||
|
||||
/// Akce profilu: mizící zprávy (krátce).
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Mizení'**
|
||||
String get profileDisappearing;
|
||||
|
||||
/// Karta společných skupin v profilu kontaktu.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Společné skupiny'**
|
||||
String get profileCommonGroups;
|
||||
|
||||
/// Řádek profilu: bezpečnostní číslo.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Bezpečnostní číslo'**
|
||||
String get profileSafetyNumber;
|
||||
|
||||
/// Podtitulek bezpečnostního čísla u neověřeného kontaktu.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Porovnejte osobně nebo QR kódem'**
|
||||
String get profileSafetyNumberHint;
|
||||
|
||||
/// Stav ověřeného kontaktu v hlavičce profilu.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Ověřeno osobně'**
|
||||
String get profileVerifiedInPerson;
|
||||
|
||||
/// Tlačítko v profilu neověřeného kontaktu.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Ověřit kontakt'**
|
||||
String get profileVerifyNow;
|
||||
|
||||
/// Akce profilu skupiny: přidat členy (krátce).
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Přidat'**
|
||||
String get profileAdd;
|
||||
|
||||
/// Podtitulek profilu skupiny: počet členů · datum založení.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Skupina · {members} · založena {date}'**
|
||||
String profileGroupSubtitle(String members, String date);
|
||||
|
||||
/// Počet členů ze stropu skupiny, např. „4 ze 128“.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'{count} ze {max}'**
|
||||
String profileMembersOfMax(int count, int max);
|
||||
|
||||
/// Podtitulek chatu / náhled v seznamu: protistrana píše.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'píše…'**
|
||||
String get chatTyping;
|
||||
|
||||
/// Skupina: kdo píše (křestní jméno).
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'{name} píše…'**
|
||||
String chatTypingNamed(String name);
|
||||
|
||||
/// Skupina: píše víc lidí najednou.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Několik lidí píše…'**
|
||||
String get chatTypingSeveral;
|
||||
|
||||
/// Nastavení: přepínač „píše…“.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Ukazovat, že píšu'**
|
||||
String get settingsTypingTitle;
|
||||
|
||||
/// Popis přepínače „píše…“.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Oboustranné — kdo nesdílí, nevidí (výchozí vypnuto)'**
|
||||
String get settingsTypingSubtitle;
|
||||
|
||||
/// Položka nabídky příloh: vytvořit anketu.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Anketa'**
|
||||
String get pollAttach;
|
||||
|
||||
/// Nadpis formuláře nové ankety.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Nová anketa'**
|
||||
String get pollCreateTitle;
|
||||
|
||||
/// Pole otázky ankety.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Otázka'**
|
||||
String get pollQuestion;
|
||||
|
||||
/// Placeholder možnosti ankety s pořadím.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Možnost {number}'**
|
||||
String pollOptionHint(int number);
|
||||
|
||||
/// Tlačítko: přidat možnost do ankety.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Přidat možnost'**
|
||||
String get pollAddOption;
|
||||
|
||||
/// Tlačítko: odebrat možnost.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Odebrat možnost'**
|
||||
String get pollRemoveOption;
|
||||
|
||||
/// Přepínač: víc odpovědí na osobu.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Povolit víc odpovědí'**
|
||||
String get pollAllowMultiple;
|
||||
|
||||
/// Hlavička ankety s jednou volbou.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Anketa'**
|
||||
String get pollSingle;
|
||||
|
||||
/// Hlavička ankety s více volbami.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Anketa, více voleb'**
|
||||
String get pollMulti;
|
||||
|
||||
/// Počet hlasujících v anketě.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'{count, plural, =0{zatím bez hlasů} one{1 hlas} few{{count} hlasy} other{{count} hlasů}}'**
|
||||
String pollVoters(int count);
|
||||
|
||||
/// Záložní text ankety pro starší verze appky — první řádek.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Anketa: {question}'**
|
||||
String pollFallback(String question);
|
||||
|
||||
/// Záložní text ankety — výzva k aktualizaci.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Hlasovat jde v aktuální verzi Šepotu.'**
|
||||
String get pollFallbackUpdate;
|
||||
|
||||
/// Popisek avataru v Nastavení (čtečka obrazovky).
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Změnit profilovou fotku'**
|
||||
String get profilePhotoEdit;
|
||||
|
||||
/// Akce: vybrat profilovou fotku z galerie.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Vybrat fotku'**
|
||||
String get profilePhotoPick;
|
||||
|
||||
/// Akce: odebrat profilovou fotku.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Odebrat fotku'**
|
||||
String get profilePhotoRemove;
|
||||
|
||||
/// Vysvětlení, kdo profilovou fotku uvidí.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Fotku uvidí jen lidé, se kterými si píšeš — šifrovaně, stejně jako zprávy. Poloha ani údaje o fotce se neposílají.'**
|
||||
String get profilePhotoHint;
|
||||
|
||||
/// Chyba nastavení profilové fotky.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Fotku se nepodařilo nastavit: {error}'**
|
||||
String profilePhotoFailed(String error);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -75,17 +75,67 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
String get groupsLeftBanner =>
|
||||
'Už nejsi ve skupině. Historie zůstává, nové zprávy nedorazí.';
|
||||
|
||||
@override
|
||||
String groupsMembersWithCount(int count) {
|
||||
return 'Členové ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsYou => 'Ty';
|
||||
|
||||
@override
|
||||
String get groupsFounder => 'Zakladatel';
|
||||
|
||||
@override
|
||||
String get groupsAdmin => 'Správce';
|
||||
|
||||
@override
|
||||
String get groupsMakeAdmin => 'Udělat správcem';
|
||||
|
||||
@override
|
||||
String get groupsDismissAdmin => 'Odebrat roli správce';
|
||||
|
||||
@override
|
||||
String get groupsRemoveMember => 'Odebrat ze skupiny';
|
||||
|
||||
@override
|
||||
String groupsRemoveMemberConfirmTitle(String name) {
|
||||
return 'Odebrat $name ze skupiny?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsRemoveMemberConfirmBody =>
|
||||
'Přestanou mu chodit nové zprávy skupiny. Dosavadní historie mu zůstane.';
|
||||
|
||||
@override
|
||||
String get groupsRemoveAction => 'Odebrat';
|
||||
|
||||
@override
|
||||
String get groupsViewContact => 'Zobrazit kontakt';
|
||||
|
||||
@override
|
||||
String get groupsAdminOnlyHint =>
|
||||
'Jméno, členy a správce skupiny mění jen správci.';
|
||||
|
||||
@override
|
||||
String get groupsAdminOnlyCompatHint =>
|
||||
'Jméno a správce mění jen správci. Přidávat lidi zatím může každý, protože někdo ve skupině má starší verzi Šepotu.';
|
||||
|
||||
@override
|
||||
String groupsAdminActionFailed(String error) {
|
||||
return 'Změna se nepovedla: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsResyncNotice =>
|
||||
'Stav skupiny se srovnává se správcem. Změny členů se můžou chvíli opozdit.';
|
||||
|
||||
@override
|
||||
String get groupsRejectedChangeNotice =>
|
||||
'Někdo bez oprávnění zkusil skupinu změnit. Změna se neprovedla.';
|
||||
|
||||
@override
|
||||
String get errorGroupNotAdmin => 'Tohle může udělat jen správce skupiny.';
|
||||
|
||||
@override
|
||||
String get errorGroupLastAdmin =>
|
||||
'Jsi jediný správce. Nejdřív udělej správcem někoho dalšího.';
|
||||
|
||||
@override
|
||||
String get groupsNameHint => 'Název skupiny';
|
||||
|
||||
@@ -192,6 +242,28 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
@override
|
||||
String get chatActionReply => 'Odpovědět';
|
||||
|
||||
@override
|
||||
String get chatActionForward => 'Přeposlat';
|
||||
|
||||
@override
|
||||
String get forwardTitle => 'Přeposlat do…';
|
||||
|
||||
@override
|
||||
String get forwardSearchHint => 'Hledat kontakt nebo skupinu';
|
||||
|
||||
@override
|
||||
String get forwardEmpty => 'Nic tu není';
|
||||
|
||||
@override
|
||||
String forwardDone(String name) {
|
||||
return 'Přeposláno: $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String forwardFailed(String error) {
|
||||
return 'Přeposlání se nepovedlo: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get commonCopy => 'Kopírovat';
|
||||
|
||||
@@ -248,6 +320,16 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
return 'Soubor je příliš velký (max $maxMb MB)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get attachmentKeepsMetadataTitle => 'Fotka odejde i s polohou';
|
||||
|
||||
@override
|
||||
String get attachmentKeepsMetadataBody =>
|
||||
'Tenhle formát fotky (třeba HEIC) neumím zbavit metadat. Odešla by v originále včetně polohy, kde vznikla, a údajů o telefonu. Bezpečnější je poslat ji přes Galerii, kde se metadata odstraní.';
|
||||
|
||||
@override
|
||||
String get attachmentSendOriginal => 'Poslat originál';
|
||||
|
||||
@override
|
||||
String attachmentPickFailed(String error) {
|
||||
return 'Výběr přílohy selhal: $error';
|
||||
@@ -276,6 +358,14 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
@override
|
||||
String get errorRelayUnavailable => 'Relay nedostupný (sender certificate)';
|
||||
|
||||
@override
|
||||
String get errorNotYetRegistered =>
|
||||
'Šepot se ještě připojuje k relayi — zkus zprávu poslat za chvíli.';
|
||||
|
||||
@override
|
||||
String get errorContactLinkIncomplete =>
|
||||
'Odkaz je neúplný — protistrana ho vytvořila dřív, než se stihla připojit. Požádej ji o nový sepot:// odkaz.';
|
||||
|
||||
@override
|
||||
String errorAttachmentTooLarge(int maxMb) {
|
||||
return 'Příloha je příliš velká (max $maxMb MB)';
|
||||
@@ -284,6 +374,9 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
@override
|
||||
String get errorGroupNeedsMember => 'Skupina potřebuje aspoň 1 člena';
|
||||
|
||||
@override
|
||||
String get errorAttachmentUnavailable => 'Příloha už v telefonu není';
|
||||
|
||||
@override
|
||||
String errorGroupMaxMembers(int maxMembers) {
|
||||
return 'Skupina může mít nejvýš $maxMembers členů';
|
||||
@@ -593,19 +686,100 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
@override
|
||||
String get chatListNewConversation => 'Nová konverzace';
|
||||
|
||||
@override
|
||||
String chatListPhotoWithCaptionPreview(String caption) {
|
||||
return '📷 $caption';
|
||||
}
|
||||
|
||||
@override
|
||||
String chatListFilePreview(String fileName) {
|
||||
return '📎 $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatListReadReceiptPreview => 'Přečteno';
|
||||
|
||||
@override
|
||||
String get chatListDraft => 'Koncept';
|
||||
|
||||
@override
|
||||
String get notesTitle => 'Poznámky pro sebe';
|
||||
|
||||
@override
|
||||
String get notesSubtitle => 'Jen v tomto telefonu, šifrovaně';
|
||||
|
||||
@override
|
||||
String get callVoice => 'Zavolat';
|
||||
|
||||
@override
|
||||
String get callVideo => 'Videohovor';
|
||||
|
||||
@override
|
||||
String get callStatusRinging => 'Vyzvání…';
|
||||
|
||||
@override
|
||||
String get callIncomingVoice => 'Příchozí hovor';
|
||||
|
||||
@override
|
||||
String get callIncomingVideo => 'Příchozí videohovor';
|
||||
|
||||
@override
|
||||
String get callStatusConnecting => 'Spojuji…';
|
||||
|
||||
@override
|
||||
String get callEnded => 'Hovor skončil';
|
||||
|
||||
@override
|
||||
String get callEndedBusy => 'Obsazeno';
|
||||
|
||||
@override
|
||||
String get callEndedRejected => 'Hovor odmítnut';
|
||||
|
||||
@override
|
||||
String get callEndedUnanswered => 'Nikdo to nevzal';
|
||||
|
||||
@override
|
||||
String get callEndedFailed => 'Spojení se nepodařilo';
|
||||
|
||||
@override
|
||||
String get callEncrypted => 'Šifrováno end-to-end';
|
||||
|
||||
@override
|
||||
String get callAccept => 'Přijmout';
|
||||
|
||||
@override
|
||||
String get callReject => 'Odmítnout';
|
||||
|
||||
@override
|
||||
String get callMute => 'Ztlumit mikrofon';
|
||||
|
||||
@override
|
||||
String get callSpeaker => 'Reproduktor';
|
||||
|
||||
@override
|
||||
String get callCamera => 'Vypnout kameru';
|
||||
|
||||
@override
|
||||
String get callSwitchCamera => 'Přepnout kameru';
|
||||
|
||||
@override
|
||||
String get callHangUp => 'Zavěsit';
|
||||
|
||||
@override
|
||||
String get callLogMissed => 'Zmeškaný hovor';
|
||||
|
||||
@override
|
||||
String get callLogUnanswered => 'Nepřijatý hovor';
|
||||
|
||||
@override
|
||||
String get callLogRejected => 'Odmítnutý hovor';
|
||||
|
||||
@override
|
||||
String get callLogBusy => 'Obsazeno';
|
||||
|
||||
@override
|
||||
String get callLogFailed => 'Hovor se nespojil';
|
||||
|
||||
@override
|
||||
String callLogVoice(String duration) {
|
||||
return 'Hovor · $duration';
|
||||
}
|
||||
|
||||
@override
|
||||
String callLogVideo(String duration) {
|
||||
return 'Videohovor · $duration';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatListTimeNow => 'teď';
|
||||
|
||||
@@ -928,6 +1102,11 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
@override
|
||||
String get pinUnlockError => 'Chyba odemčení';
|
||||
|
||||
@override
|
||||
String pinLockedFor(int seconds) {
|
||||
return 'Příliš mnoho pokusů. Zkus to za $seconds s.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushTitle => 'Push notifikace';
|
||||
|
||||
@@ -935,14 +1114,14 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
String get pushModeOffDesc => 'Zprávy se stáhnou až po otevření appky.';
|
||||
|
||||
@override
|
||||
String get pushModeUp => 'UnifiedPush (doporučeno)';
|
||||
String get pushModeUp => 'UnifiedPush (bez Googlu)';
|
||||
|
||||
@override
|
||||
String get pushModeUpDesc =>
|
||||
'Degooglované — potřebuje aplikaci ntfy (F-Droid/Play).';
|
||||
|
||||
@override
|
||||
String get pushModeFcm => 'Google (FCM)';
|
||||
String get pushModeFcm => 'Google (FCM, výchozí)';
|
||||
|
||||
@override
|
||||
String get pushModeFcmDesc =>
|
||||
@@ -953,6 +1132,18 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
return 'Nastavení pushů selhalo: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushHintOff =>
|
||||
'Zprávy ti přijdou, až Šepot otevřeš — budíčky nejsou nastavené.';
|
||||
|
||||
@override
|
||||
String get pushHintFailed =>
|
||||
'Budíčky nefungují, zprávy uvidíš až po otevření Šepotu.';
|
||||
|
||||
@override
|
||||
String get pushRegistrationFailed =>
|
||||
'Budíčky nechodí — distributor registraci odmítl. Zkus push vypnout a znovu zapnout.';
|
||||
|
||||
@override
|
||||
String get pushNoDistributorTitle => 'Žádný distributor';
|
||||
|
||||
@@ -975,7 +1166,7 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
String get pushNoDistributorFallback => 'bez distributora';
|
||||
|
||||
@override
|
||||
String get pushSubtitleFcm => 'Google (FCM) — rychlé, opt-in';
|
||||
String get pushSubtitleFcm => 'Google (FCM)';
|
||||
|
||||
@override
|
||||
String get restoreErrorNameRequired => 'Zadej zobrazované jméno.';
|
||||
@@ -1048,6 +1239,13 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
@override
|
||||
String get qrShareTitle => 'Můj Šepot ID';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingTitle => 'Připojuji k relayi…';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingBody =>
|
||||
'Až se Šepot poprvé připojí, objeví se tvůj QR kód a odkaz. Chvilku strpení — stačí pár sekund.';
|
||||
|
||||
@override
|
||||
String get qrIdentityNotFound => 'Identita nebyla nalezena';
|
||||
|
||||
@@ -1275,4 +1473,257 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get photoPreviewDecodeFailed => 'Fotku se nepodařilo načíst';
|
||||
|
||||
@override
|
||||
String get chatActionInfo => 'Info o zprávě';
|
||||
|
||||
@override
|
||||
String get messageInfoSent => 'Odesláno';
|
||||
|
||||
@override
|
||||
String get messageInfoDelivered => 'Doručeno';
|
||||
|
||||
@override
|
||||
String get messageInfoRead => 'Přečteno';
|
||||
|
||||
@override
|
||||
String get messageInfoPending => 'zatím ne';
|
||||
|
||||
@override
|
||||
String get messageInfoReadReceiptsOff =>
|
||||
'Kdy si zprávu přečetli, uvidíš, jen když sám sdílíš potvrzení přečtení (Nastavení).';
|
||||
|
||||
@override
|
||||
String chatStatusReadAt(String time) {
|
||||
return 'Přečteno $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String chatStatusDeliveredAt(String time) {
|
||||
return 'Doručeno $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsAddMembers => 'Přidat členy';
|
||||
|
||||
@override
|
||||
String get groupsAddMembersAction => 'Přidat';
|
||||
|
||||
@override
|
||||
String groupsAddMembersFailed(String error) {
|
||||
return 'Přidání se nepovedlo: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsNoContactsToAdd =>
|
||||
'Všechny tvoje kontakty už ve skupině jsou. Nového člověka nejdřív přidej do kontaktů přes QR kód.';
|
||||
|
||||
@override
|
||||
String get navChats => 'Chaty';
|
||||
|
||||
@override
|
||||
String get chatListNewChat => 'Nový chat';
|
||||
|
||||
@override
|
||||
String get chatListSearchHint => 'Hledat v chatech a zprávách';
|
||||
|
||||
@override
|
||||
String get chatFilterAll => 'Vše';
|
||||
|
||||
@override
|
||||
String get chatFilterUnread => 'Nepřečtené';
|
||||
|
||||
@override
|
||||
String get chatFilterGroups => 'Skupiny';
|
||||
|
||||
@override
|
||||
String get chatFilterArchive => 'Archiv';
|
||||
|
||||
@override
|
||||
String get chatListNoResults => 'Nic nenalezeno.';
|
||||
|
||||
@override
|
||||
String get chatListFilterEmpty => 'Tady zatím nic není.';
|
||||
|
||||
@override
|
||||
String get chatListPin => 'Připnout';
|
||||
|
||||
@override
|
||||
String get chatListUnpin => 'Odepnout';
|
||||
|
||||
@override
|
||||
String get chatOpenProfile => 'Zobrazit profil';
|
||||
|
||||
@override
|
||||
String get groupsProfile => 'Profil skupiny';
|
||||
|
||||
@override
|
||||
String get chatSearchHint => 'Hledat v konverzaci';
|
||||
|
||||
@override
|
||||
String get chatSearchNoMatches => 'Nic';
|
||||
|
||||
@override
|
||||
String chatSearchCounter(int current, int total) {
|
||||
return '$current z $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatSearchOlder => 'Starší shoda';
|
||||
|
||||
@override
|
||||
String get chatSearchNewer => 'Novější shoda';
|
||||
|
||||
@override
|
||||
String chatUnreadDivider(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count nových zpráv',
|
||||
few: '$count nové zprávy',
|
||||
one: '1 nová zpráva',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatJumpToLatest => 'Na nejnovější zprávy';
|
||||
|
||||
@override
|
||||
String get chatAttach => 'Přiložit';
|
||||
|
||||
@override
|
||||
String get chatSend => 'Odeslat';
|
||||
|
||||
@override
|
||||
String get chatRecordVoice => 'Nahrát hlasovou zprávu';
|
||||
|
||||
@override
|
||||
String get profileSharedMedia => 'Sdílená média';
|
||||
|
||||
@override
|
||||
String get profileMessage => 'Zpráva';
|
||||
|
||||
@override
|
||||
String get profileSearch => 'Hledat';
|
||||
|
||||
@override
|
||||
String get profileUnmute => 'Zvuk';
|
||||
|
||||
@override
|
||||
String get profileDisappearing => 'Mizení';
|
||||
|
||||
@override
|
||||
String get profileCommonGroups => 'Společné skupiny';
|
||||
|
||||
@override
|
||||
String get profileSafetyNumber => 'Bezpečnostní číslo';
|
||||
|
||||
@override
|
||||
String get profileSafetyNumberHint => 'Porovnejte osobně nebo QR kódem';
|
||||
|
||||
@override
|
||||
String get profileVerifiedInPerson => 'Ověřeno osobně';
|
||||
|
||||
@override
|
||||
String get profileVerifyNow => 'Ověřit kontakt';
|
||||
|
||||
@override
|
||||
String get profileAdd => 'Přidat';
|
||||
|
||||
@override
|
||||
String profileGroupSubtitle(String members, String date) {
|
||||
return 'Skupina · $members · založena $date';
|
||||
}
|
||||
|
||||
@override
|
||||
String profileMembersOfMax(int count, int max) {
|
||||
return '$count ze $max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatTyping => 'píše…';
|
||||
|
||||
@override
|
||||
String chatTypingNamed(String name) {
|
||||
return '$name píše…';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatTypingSeveral => 'Několik lidí píše…';
|
||||
|
||||
@override
|
||||
String get settingsTypingTitle => 'Ukazovat, že píšu';
|
||||
|
||||
@override
|
||||
String get settingsTypingSubtitle =>
|
||||
'Oboustranné — kdo nesdílí, nevidí (výchozí vypnuto)';
|
||||
|
||||
@override
|
||||
String get pollAttach => 'Anketa';
|
||||
|
||||
@override
|
||||
String get pollCreateTitle => 'Nová anketa';
|
||||
|
||||
@override
|
||||
String get pollQuestion => 'Otázka';
|
||||
|
||||
@override
|
||||
String pollOptionHint(int number) {
|
||||
return 'Možnost $number';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pollAddOption => 'Přidat možnost';
|
||||
|
||||
@override
|
||||
String get pollRemoveOption => 'Odebrat možnost';
|
||||
|
||||
@override
|
||||
String get pollAllowMultiple => 'Povolit víc odpovědí';
|
||||
|
||||
@override
|
||||
String get pollSingle => 'Anketa';
|
||||
|
||||
@override
|
||||
String get pollMulti => 'Anketa, více voleb';
|
||||
|
||||
@override
|
||||
String pollVoters(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hlasů',
|
||||
few: '$count hlasy',
|
||||
one: '1 hlas',
|
||||
zero: 'zatím bez hlasů',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String pollFallback(String question) {
|
||||
return 'Anketa: $question';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pollFallbackUpdate => 'Hlasovat jde v aktuální verzi Šepotu.';
|
||||
|
||||
@override
|
||||
String get profilePhotoEdit => 'Změnit profilovou fotku';
|
||||
|
||||
@override
|
||||
String get profilePhotoPick => 'Vybrat fotku';
|
||||
|
||||
@override
|
||||
String get profilePhotoRemove => 'Odebrat fotku';
|
||||
|
||||
@override
|
||||
String get profilePhotoHint =>
|
||||
'Fotku uvidí jen lidé, se kterými si píšeš — šifrovaně, stejně jako zprávy. Poloha ani údaje o fotce se neposílají.';
|
||||
|
||||
@override
|
||||
String profilePhotoFailed(String error) {
|
||||
return 'Fotku se nepodařilo nastavit: $error';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,17 +73,67 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get groupsLeftBanner =>
|
||||
'Du bist nicht mehr in der Gruppe. Der Verlauf bleibt, neue Nachrichten kommen nicht mehr an.';
|
||||
|
||||
@override
|
||||
String groupsMembersWithCount(int count) {
|
||||
return 'Mitglieder ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsYou => 'Du';
|
||||
|
||||
@override
|
||||
String get groupsFounder => 'Hat die Gruppe gegründet';
|
||||
|
||||
@override
|
||||
String get groupsAdmin => 'Admin';
|
||||
|
||||
@override
|
||||
String get groupsMakeAdmin => 'Zum Admin machen';
|
||||
|
||||
@override
|
||||
String get groupsDismissAdmin => 'Adminrolle entziehen';
|
||||
|
||||
@override
|
||||
String get groupsRemoveMember => 'Aus der Gruppe entfernen';
|
||||
|
||||
@override
|
||||
String groupsRemoveMemberConfirmTitle(String name) {
|
||||
return '$name aus der Gruppe entfernen?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsRemoveMemberConfirmBody =>
|
||||
'Neue Nachrichten der Gruppe kommen dann nicht mehr an. Der bisherige Verlauf bleibt erhalten.';
|
||||
|
||||
@override
|
||||
String get groupsRemoveAction => 'Entfernen';
|
||||
|
||||
@override
|
||||
String get groupsViewContact => 'Kontakt anzeigen';
|
||||
|
||||
@override
|
||||
String get groupsAdminOnlyHint =>
|
||||
'Nur Admins können Namen, Mitglieder und Admins der Gruppe ändern.';
|
||||
|
||||
@override
|
||||
String get groupsAdminOnlyCompatHint =>
|
||||
'Nur Admins können die Gruppe umbenennen oder Admins ändern. Personen hinzufügen kann vorerst jeder, weil jemand in der Gruppe eine ältere Šepot-Version hat.';
|
||||
|
||||
@override
|
||||
String groupsAdminActionFailed(String error) {
|
||||
return 'Änderung fehlgeschlagen: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsResyncNotice =>
|
||||
'Die Gruppe gleicht sich mit dem Admin ab. Änderungen an Mitgliedern können sich kurz verzögern.';
|
||||
|
||||
@override
|
||||
String get groupsRejectedChangeNotice =>
|
||||
'Jemand ohne Berechtigung hat versucht, die Gruppe zu ändern. Die Änderung wurde nicht übernommen.';
|
||||
|
||||
@override
|
||||
String get errorGroupNotAdmin => 'Das kann nur ein Admin der Gruppe.';
|
||||
|
||||
@override
|
||||
String get errorGroupLastAdmin =>
|
||||
'Du bist der einzige Admin. Mach zuerst jemand anderen zum Admin.';
|
||||
|
||||
@override
|
||||
String get groupsNameHint => 'Gruppenname';
|
||||
|
||||
@@ -191,6 +241,28 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get chatActionReply => 'Antworten';
|
||||
|
||||
@override
|
||||
String get chatActionForward => 'Weiterleiten';
|
||||
|
||||
@override
|
||||
String get forwardTitle => 'Weiterleiten an…';
|
||||
|
||||
@override
|
||||
String get forwardSearchHint => 'Kontakt oder Gruppe suchen';
|
||||
|
||||
@override
|
||||
String get forwardEmpty => 'Nichts gefunden';
|
||||
|
||||
@override
|
||||
String forwardDone(String name) {
|
||||
return 'Weitergeleitet an $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String forwardFailed(String error) {
|
||||
return 'Weiterleiten fehlgeschlagen: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get commonCopy => 'Kopieren';
|
||||
|
||||
@@ -248,6 +320,16 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return 'Datei ist zu groß (max. $maxMb MB)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get attachmentKeepsMetadataTitle => 'Foto enthält den Standort';
|
||||
|
||||
@override
|
||||
String get attachmentKeepsMetadataBody =>
|
||||
'Aus diesem Fotoformat (z. B. HEIC) kann ich die Metadaten nicht entfernen. Es würde im Original gesendet, einschließlich Aufnahmeort und Angaben zum Telefon. Sicherer ist das Senden über die Galerie, dort werden die Metadaten entfernt.';
|
||||
|
||||
@override
|
||||
String get attachmentSendOriginal => 'Original senden';
|
||||
|
||||
@override
|
||||
String attachmentPickFailed(String error) {
|
||||
return 'Auswahl des Anhangs fehlgeschlagen: $error';
|
||||
@@ -277,6 +359,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get errorRelayUnavailable =>
|
||||
'Relay nicht erreichbar (Sender-Zertifikat)';
|
||||
|
||||
@override
|
||||
String get errorNotYetRegistered =>
|
||||
'Šepot verbindet sich noch mit dem Relay — versuch es gleich noch einmal.';
|
||||
|
||||
@override
|
||||
String get errorContactLinkIncomplete =>
|
||||
'Dieser Link ist unvollständig — die andere Person hat ihn erstellt, bevor sie verbunden war. Bitte sie um einen neuen sepot://-Link.';
|
||||
|
||||
@override
|
||||
String errorAttachmentTooLarge(int maxMb) {
|
||||
return 'Anhang ist zu groß (max. $maxMb MB)';
|
||||
@@ -286,6 +376,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get errorGroupNeedsMember =>
|
||||
'Die Gruppe braucht mindestens 1 Mitglied';
|
||||
|
||||
@override
|
||||
String get errorAttachmentUnavailable =>
|
||||
'Der Anhang ist nicht mehr auf diesem Telefon';
|
||||
|
||||
@override
|
||||
String errorGroupMaxMembers(int maxMembers) {
|
||||
return 'Die Gruppe kann höchstens $maxMembers Mitglieder haben';
|
||||
@@ -596,19 +690,100 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get chatListNewConversation => 'Neue Unterhaltung';
|
||||
|
||||
@override
|
||||
String chatListPhotoWithCaptionPreview(String caption) {
|
||||
return '📷 $caption';
|
||||
}
|
||||
|
||||
@override
|
||||
String chatListFilePreview(String fileName) {
|
||||
return '📎 $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatListReadReceiptPreview => 'Gelesen';
|
||||
|
||||
@override
|
||||
String get chatListDraft => 'Entwurf';
|
||||
|
||||
@override
|
||||
String get notesTitle => 'Notizen für mich';
|
||||
|
||||
@override
|
||||
String get notesSubtitle => 'Nur auf diesem Telefon, verschlüsselt';
|
||||
|
||||
@override
|
||||
String get callVoice => 'Anrufen';
|
||||
|
||||
@override
|
||||
String get callVideo => 'Videoanruf';
|
||||
|
||||
@override
|
||||
String get callStatusRinging => 'Klingelt…';
|
||||
|
||||
@override
|
||||
String get callIncomingVoice => 'Eingehender Anruf';
|
||||
|
||||
@override
|
||||
String get callIncomingVideo => 'Eingehender Videoanruf';
|
||||
|
||||
@override
|
||||
String get callStatusConnecting => 'Verbinde…';
|
||||
|
||||
@override
|
||||
String get callEnded => 'Anruf beendet';
|
||||
|
||||
@override
|
||||
String get callEndedBusy => 'Besetzt';
|
||||
|
||||
@override
|
||||
String get callEndedRejected => 'Anruf abgelehnt';
|
||||
|
||||
@override
|
||||
String get callEndedUnanswered => 'Keine Antwort';
|
||||
|
||||
@override
|
||||
String get callEndedFailed => 'Verbindung fehlgeschlagen';
|
||||
|
||||
@override
|
||||
String get callEncrypted => 'Ende-zu-Ende-verschlüsselt';
|
||||
|
||||
@override
|
||||
String get callAccept => 'Annehmen';
|
||||
|
||||
@override
|
||||
String get callReject => 'Ablehnen';
|
||||
|
||||
@override
|
||||
String get callMute => 'Stummschalten';
|
||||
|
||||
@override
|
||||
String get callSpeaker => 'Lautsprecher';
|
||||
|
||||
@override
|
||||
String get callCamera => 'Kamera aus';
|
||||
|
||||
@override
|
||||
String get callSwitchCamera => 'Kamera wechseln';
|
||||
|
||||
@override
|
||||
String get callHangUp => 'Auflegen';
|
||||
|
||||
@override
|
||||
String get callLogMissed => 'Verpasster Anruf';
|
||||
|
||||
@override
|
||||
String get callLogUnanswered => 'Unbeantworteter Anruf';
|
||||
|
||||
@override
|
||||
String get callLogRejected => 'Abgelehnter Anruf';
|
||||
|
||||
@override
|
||||
String get callLogBusy => 'Besetzt';
|
||||
|
||||
@override
|
||||
String get callLogFailed => 'Anruf fehlgeschlagen';
|
||||
|
||||
@override
|
||||
String callLogVoice(String duration) {
|
||||
return 'Anruf · $duration';
|
||||
}
|
||||
|
||||
@override
|
||||
String callLogVideo(String duration) {
|
||||
return 'Videoanruf · $duration';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatListTimeNow => 'jetzt';
|
||||
|
||||
@@ -946,6 +1121,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get pinUnlockError => 'Fehler beim Entsperren';
|
||||
|
||||
@override
|
||||
String pinLockedFor(int seconds) {
|
||||
return 'Zu viele Versuche. Versuche es in $seconds s erneut.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushTitle => 'Push-Benachrichtigungen';
|
||||
|
||||
@@ -954,14 +1134,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
'Nachrichten werden erst beim Öffnen der App abgerufen.';
|
||||
|
||||
@override
|
||||
String get pushModeUp => 'UnifiedPush (empfohlen)';
|
||||
String get pushModeUp => 'UnifiedPush (ohne Google)';
|
||||
|
||||
@override
|
||||
String get pushModeUpDesc =>
|
||||
'Google-frei — braucht die App ntfy (F-Droid/Play).';
|
||||
|
||||
@override
|
||||
String get pushModeFcm => 'Google (FCM)';
|
||||
String get pushModeFcm => 'Google (FCM, Standard)';
|
||||
|
||||
@override
|
||||
String get pushModeFcmDesc =>
|
||||
@@ -972,6 +1152,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return 'Einrichtung der Push-Benachrichtigungen fehlgeschlagen: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushHintOff =>
|
||||
'Nachrichten kommen erst an, wenn du Šepot öffnest — keine Weckrufe eingerichtet.';
|
||||
|
||||
@override
|
||||
String get pushHintFailed =>
|
||||
'Weckrufe funktionieren nicht; Nachrichten siehst du erst beim Öffnen von Šepot.';
|
||||
|
||||
@override
|
||||
String get pushRegistrationFailed =>
|
||||
'Es kommen keine Weckrufe an — der Distributor hat die Registrierung abgelehnt. Schalte Push aus und wieder ein.';
|
||||
|
||||
@override
|
||||
String get pushNoDistributorTitle => 'Kein Distributor';
|
||||
|
||||
@@ -994,7 +1186,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get pushNoDistributorFallback => 'kein Distributor';
|
||||
|
||||
@override
|
||||
String get pushSubtitleFcm => 'Google (FCM) — schnell, opt-in';
|
||||
String get pushSubtitleFcm => 'Google (FCM)';
|
||||
|
||||
@override
|
||||
String get restoreErrorNameRequired => 'Gib einen Anzeigenamen ein.';
|
||||
@@ -1067,6 +1259,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get qrShareTitle => 'Meine Šepot-ID';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingTitle => 'Verbinde mit dem Relay…';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingBody =>
|
||||
'Dein QR-Code und Link erscheinen, sobald sich Šepot zum ersten Mal verbindet. Einen Moment — es dauert nur ein paar Sekunden.';
|
||||
|
||||
@override
|
||||
String get qrIdentityNotFound => 'Identität nicht gefunden';
|
||||
|
||||
@@ -1297,4 +1496,256 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get photoPreviewDecodeFailed => 'Foto konnte nicht geladen werden';
|
||||
|
||||
@override
|
||||
String get chatActionInfo => 'Nachrichteninfo';
|
||||
|
||||
@override
|
||||
String get messageInfoSent => 'Gesendet';
|
||||
|
||||
@override
|
||||
String get messageInfoDelivered => 'Zugestellt';
|
||||
|
||||
@override
|
||||
String get messageInfoRead => 'Gelesen';
|
||||
|
||||
@override
|
||||
String get messageInfoPending => 'noch nicht';
|
||||
|
||||
@override
|
||||
String get messageInfoReadReceiptsOff =>
|
||||
'Wann deine Nachrichten gelesen wurden, siehst du nur, wenn du selbst Lesebestätigungen teilst (Einstellungen).';
|
||||
|
||||
@override
|
||||
String chatStatusReadAt(String time) {
|
||||
return 'Gelesen $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String chatStatusDeliveredAt(String time) {
|
||||
return 'Zugestellt $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsAddMembers => 'Mitglieder hinzufügen';
|
||||
|
||||
@override
|
||||
String get groupsAddMembersAction => 'Hinzufügen';
|
||||
|
||||
@override
|
||||
String groupsAddMembersFailed(String error) {
|
||||
return 'Hinzufügen fehlgeschlagen: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsNoContactsToAdd =>
|
||||
'Alle deine Kontakte sind schon in der Gruppe. Füge neue Personen zuerst per QR-Code zu deinen Kontakten hinzu.';
|
||||
|
||||
@override
|
||||
String get navChats => 'Chats';
|
||||
|
||||
@override
|
||||
String get chatListNewChat => 'Neuer Chat';
|
||||
|
||||
@override
|
||||
String get chatListSearchHint => 'Chats und Nachrichten durchsuchen';
|
||||
|
||||
@override
|
||||
String get chatFilterAll => 'Alle';
|
||||
|
||||
@override
|
||||
String get chatFilterUnread => 'Ungelesen';
|
||||
|
||||
@override
|
||||
String get chatFilterGroups => 'Gruppen';
|
||||
|
||||
@override
|
||||
String get chatFilterArchive => 'Archiv';
|
||||
|
||||
@override
|
||||
String get chatListNoResults => 'Nichts gefunden.';
|
||||
|
||||
@override
|
||||
String get chatListFilterEmpty => 'Hier ist noch nichts.';
|
||||
|
||||
@override
|
||||
String get chatListPin => 'Anheften';
|
||||
|
||||
@override
|
||||
String get chatListUnpin => 'Lösen';
|
||||
|
||||
@override
|
||||
String get chatOpenProfile => 'Profil anzeigen';
|
||||
|
||||
@override
|
||||
String get groupsProfile => 'Gruppeninfo';
|
||||
|
||||
@override
|
||||
String get chatSearchHint => 'Im Chat suchen';
|
||||
|
||||
@override
|
||||
String get chatSearchNoMatches => 'Keine';
|
||||
|
||||
@override
|
||||
String chatSearchCounter(int current, int total) {
|
||||
return '$current von $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatSearchOlder => 'Ältere Treffer';
|
||||
|
||||
@override
|
||||
String get chatSearchNewer => 'Neuerer Treffer';
|
||||
|
||||
@override
|
||||
String chatUnreadDivider(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count neue Nachrichten',
|
||||
one: '1 neue Nachricht',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatJumpToLatest => 'Zu den neuesten';
|
||||
|
||||
@override
|
||||
String get chatAttach => 'Anhängen';
|
||||
|
||||
@override
|
||||
String get chatSend => 'Senden';
|
||||
|
||||
@override
|
||||
String get chatRecordVoice => 'Sprachnachricht aufnehmen';
|
||||
|
||||
@override
|
||||
String get profileSharedMedia => 'Geteilte Medien';
|
||||
|
||||
@override
|
||||
String get profileMessage => 'Nachricht';
|
||||
|
||||
@override
|
||||
String get profileSearch => 'Suchen';
|
||||
|
||||
@override
|
||||
String get profileUnmute => 'Ton an';
|
||||
|
||||
@override
|
||||
String get profileDisappearing => 'Verschwinden';
|
||||
|
||||
@override
|
||||
String get profileCommonGroups => 'Gemeinsame Gruppen';
|
||||
|
||||
@override
|
||||
String get profileSafetyNumber => 'Sicherheitsnummer';
|
||||
|
||||
@override
|
||||
String get profileSafetyNumberHint =>
|
||||
'Persönlich oder per QR-Code vergleichen';
|
||||
|
||||
@override
|
||||
String get profileVerifiedInPerson => 'Persönlich verifiziert';
|
||||
|
||||
@override
|
||||
String get profileVerifyNow => 'Kontakt verifizieren';
|
||||
|
||||
@override
|
||||
String get profileAdd => 'Hinzufügen';
|
||||
|
||||
@override
|
||||
String profileGroupSubtitle(String members, String date) {
|
||||
return 'Gruppe · $members · erstellt am $date';
|
||||
}
|
||||
|
||||
@override
|
||||
String profileMembersOfMax(int count, int max) {
|
||||
return '$count von $max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatTyping => 'schreibt…';
|
||||
|
||||
@override
|
||||
String chatTypingNamed(String name) {
|
||||
return '$name schreibt…';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatTypingSeveral => 'Mehrere schreiben…';
|
||||
|
||||
@override
|
||||
String get settingsTypingTitle => 'Tippindikator';
|
||||
|
||||
@override
|
||||
String get settingsTypingSubtitle =>
|
||||
'Gegenseitig — wer nicht teilt, sieht nichts (standardmäßig aus)';
|
||||
|
||||
@override
|
||||
String get pollAttach => 'Umfrage';
|
||||
|
||||
@override
|
||||
String get pollCreateTitle => 'Neue Umfrage';
|
||||
|
||||
@override
|
||||
String get pollQuestion => 'Frage';
|
||||
|
||||
@override
|
||||
String pollOptionHint(int number) {
|
||||
return 'Option $number';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pollAddOption => 'Option hinzufügen';
|
||||
|
||||
@override
|
||||
String get pollRemoveOption => 'Option entfernen';
|
||||
|
||||
@override
|
||||
String get pollAllowMultiple => 'Mehrere Antworten erlauben';
|
||||
|
||||
@override
|
||||
String get pollSingle => 'Umfrage';
|
||||
|
||||
@override
|
||||
String get pollMulti => 'Umfrage, Mehrfachauswahl';
|
||||
|
||||
@override
|
||||
String pollVoters(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Stimmen',
|
||||
one: '1 Stimme',
|
||||
zero: 'noch keine Stimmen',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String pollFallback(String question) {
|
||||
return 'Umfrage: $question';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pollFallbackUpdate => 'Zum Abstimmen Šepot aktualisieren.';
|
||||
|
||||
@override
|
||||
String get profilePhotoEdit => 'Profilbild ändern';
|
||||
|
||||
@override
|
||||
String get profilePhotoPick => 'Foto auswählen';
|
||||
|
||||
@override
|
||||
String get profilePhotoRemove => 'Foto entfernen';
|
||||
|
||||
@override
|
||||
String get profilePhotoHint =>
|
||||
'Nur deine Chatpartner sehen es — Ende-zu-Ende-verschlüsselt wie Nachrichten. Standort und Fotodaten werden nicht gesendet.';
|
||||
|
||||
@override
|
||||
String profilePhotoFailed(String error) {
|
||||
return 'Foto konnte nicht gesetzt werden: $error';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,17 +73,67 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get groupsLeftBanner =>
|
||||
'You\'re no longer in this group. Your history stays, but new messages won\'t arrive.';
|
||||
|
||||
@override
|
||||
String groupsMembersWithCount(int count) {
|
||||
return 'Members ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsYou => 'You';
|
||||
|
||||
@override
|
||||
String get groupsFounder => 'Founder';
|
||||
|
||||
@override
|
||||
String get groupsAdmin => 'Admin';
|
||||
|
||||
@override
|
||||
String get groupsMakeAdmin => 'Make admin';
|
||||
|
||||
@override
|
||||
String get groupsDismissAdmin => 'Remove admin role';
|
||||
|
||||
@override
|
||||
String get groupsRemoveMember => 'Remove from group';
|
||||
|
||||
@override
|
||||
String groupsRemoveMemberConfirmTitle(String name) {
|
||||
return 'Remove $name from the group?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsRemoveMemberConfirmBody =>
|
||||
'They will stop getting new messages from this group. Their existing history stays.';
|
||||
|
||||
@override
|
||||
String get groupsRemoveAction => 'Remove';
|
||||
|
||||
@override
|
||||
String get groupsViewContact => 'View contact';
|
||||
|
||||
@override
|
||||
String get groupsAdminOnlyHint =>
|
||||
'Only admins can change the group name, members and admins.';
|
||||
|
||||
@override
|
||||
String get groupsAdminOnlyCompatHint =>
|
||||
'Only admins can rename the group or change admins. Anyone can still add people, because someone in the group has an older version of Šepot.';
|
||||
|
||||
@override
|
||||
String groupsAdminActionFailed(String error) {
|
||||
return 'The change failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsResyncNotice =>
|
||||
'The group is catching up with its admin. Member changes may show up with a delay.';
|
||||
|
||||
@override
|
||||
String get groupsRejectedChangeNotice =>
|
||||
'Someone without permission tried to change the group. The change was not applied.';
|
||||
|
||||
@override
|
||||
String get errorGroupNotAdmin => 'Only a group admin can do this.';
|
||||
|
||||
@override
|
||||
String get errorGroupLastAdmin =>
|
||||
'You\'re the only admin. Make someone else an admin first.';
|
||||
|
||||
@override
|
||||
String get groupsNameHint => 'Group name';
|
||||
|
||||
@@ -190,6 +240,28 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get chatActionReply => 'Reply';
|
||||
|
||||
@override
|
||||
String get chatActionForward => 'Forward';
|
||||
|
||||
@override
|
||||
String get forwardTitle => 'Forward to…';
|
||||
|
||||
@override
|
||||
String get forwardSearchHint => 'Search contact or group';
|
||||
|
||||
@override
|
||||
String get forwardEmpty => 'Nothing here';
|
||||
|
||||
@override
|
||||
String forwardDone(String name) {
|
||||
return 'Forwarded to $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String forwardFailed(String error) {
|
||||
return 'Forwarding failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get commonCopy => 'Copy';
|
||||
|
||||
@@ -246,6 +318,16 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return 'File is too large (max $maxMb MB)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get attachmentKeepsMetadataTitle => 'Photo will include location';
|
||||
|
||||
@override
|
||||
String get attachmentKeepsMetadataBody =>
|
||||
'I can\'t remove metadata from this photo format (e.g. HEIC). It would be sent as the original, including where it was taken and details about the phone. Sending it via Gallery is safer, as metadata is removed there.';
|
||||
|
||||
@override
|
||||
String get attachmentSendOriginal => 'Send original';
|
||||
|
||||
@override
|
||||
String attachmentPickFailed(String error) {
|
||||
return 'Picking attachment failed: $error';
|
||||
@@ -274,6 +356,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get errorRelayUnavailable => 'Relay unavailable (sender certificate)';
|
||||
|
||||
@override
|
||||
String get errorNotYetRegistered =>
|
||||
'Šepot is still connecting to the relay — try sending again in a moment.';
|
||||
|
||||
@override
|
||||
String get errorContactLinkIncomplete =>
|
||||
'This link is incomplete — the other person created it before connecting. Ask them for a new sepot:// link.';
|
||||
|
||||
@override
|
||||
String errorAttachmentTooLarge(int maxMb) {
|
||||
return 'Attachment is too large (max $maxMb MB)';
|
||||
@@ -282,6 +372,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get errorGroupNeedsMember => 'A group needs at least 1 member';
|
||||
|
||||
@override
|
||||
String get errorAttachmentUnavailable =>
|
||||
'The attachment is no longer on this phone';
|
||||
|
||||
@override
|
||||
String errorGroupMaxMembers(int maxMembers) {
|
||||
return 'A group can have at most $maxMembers members';
|
||||
@@ -588,19 +682,100 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get chatListNewConversation => 'New conversation';
|
||||
|
||||
@override
|
||||
String chatListPhotoWithCaptionPreview(String caption) {
|
||||
return '📷 $caption';
|
||||
}
|
||||
|
||||
@override
|
||||
String chatListFilePreview(String fileName) {
|
||||
return '📎 $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatListReadReceiptPreview => 'Read';
|
||||
|
||||
@override
|
||||
String get chatListDraft => 'Draft';
|
||||
|
||||
@override
|
||||
String get notesTitle => 'Note to self';
|
||||
|
||||
@override
|
||||
String get notesSubtitle => 'Only on this phone, encrypted';
|
||||
|
||||
@override
|
||||
String get callVoice => 'Call';
|
||||
|
||||
@override
|
||||
String get callVideo => 'Video call';
|
||||
|
||||
@override
|
||||
String get callStatusRinging => 'Ringing…';
|
||||
|
||||
@override
|
||||
String get callIncomingVoice => 'Incoming call';
|
||||
|
||||
@override
|
||||
String get callIncomingVideo => 'Incoming video call';
|
||||
|
||||
@override
|
||||
String get callStatusConnecting => 'Connecting…';
|
||||
|
||||
@override
|
||||
String get callEnded => 'Call ended';
|
||||
|
||||
@override
|
||||
String get callEndedBusy => 'Busy';
|
||||
|
||||
@override
|
||||
String get callEndedRejected => 'Call declined';
|
||||
|
||||
@override
|
||||
String get callEndedUnanswered => 'No answer';
|
||||
|
||||
@override
|
||||
String get callEndedFailed => 'Couldn\'t connect';
|
||||
|
||||
@override
|
||||
String get callEncrypted => 'End-to-end encrypted';
|
||||
|
||||
@override
|
||||
String get callAccept => 'Accept';
|
||||
|
||||
@override
|
||||
String get callReject => 'Decline';
|
||||
|
||||
@override
|
||||
String get callMute => 'Mute';
|
||||
|
||||
@override
|
||||
String get callSpeaker => 'Speaker';
|
||||
|
||||
@override
|
||||
String get callCamera => 'Turn off camera';
|
||||
|
||||
@override
|
||||
String get callSwitchCamera => 'Switch camera';
|
||||
|
||||
@override
|
||||
String get callHangUp => 'Hang up';
|
||||
|
||||
@override
|
||||
String get callLogMissed => 'Missed call';
|
||||
|
||||
@override
|
||||
String get callLogUnanswered => 'Unanswered call';
|
||||
|
||||
@override
|
||||
String get callLogRejected => 'Declined call';
|
||||
|
||||
@override
|
||||
String get callLogBusy => 'Busy';
|
||||
|
||||
@override
|
||||
String get callLogFailed => 'Call failed';
|
||||
|
||||
@override
|
||||
String callLogVoice(String duration) {
|
||||
return 'Call · $duration';
|
||||
}
|
||||
|
||||
@override
|
||||
String callLogVideo(String duration) {
|
||||
return 'Video call · $duration';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatListTimeNow => 'now';
|
||||
|
||||
@@ -926,6 +1101,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get pinUnlockError => 'Unlock error';
|
||||
|
||||
@override
|
||||
String pinLockedFor(int seconds) {
|
||||
return 'Too many attempts. Try again in $seconds s.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushTitle => 'Push notifications';
|
||||
|
||||
@@ -933,14 +1113,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get pushModeOffDesc => 'Messages arrive only after you open the app.';
|
||||
|
||||
@override
|
||||
String get pushModeUp => 'UnifiedPush (recommended)';
|
||||
String get pushModeUp => 'UnifiedPush (no Google)';
|
||||
|
||||
@override
|
||||
String get pushModeUpDesc =>
|
||||
'De-Googled — needs the ntfy app (F-Droid/Play).';
|
||||
|
||||
@override
|
||||
String get pushModeFcm => 'Google (FCM)';
|
||||
String get pushModeFcm => 'Google (FCM, default)';
|
||||
|
||||
@override
|
||||
String get pushModeFcmDesc =>
|
||||
@@ -951,6 +1131,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return 'Setting up push failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushHintOff =>
|
||||
'Messages arrive only when you open Šepot — wake-ups aren’t set up.';
|
||||
|
||||
@override
|
||||
String get pushHintFailed =>
|
||||
'Wake-ups aren’t working; you’ll see messages only after opening Šepot.';
|
||||
|
||||
@override
|
||||
String get pushRegistrationFailed =>
|
||||
'Wake-ups aren’t arriving — the distributor refused registration. Try turning push off and on again.';
|
||||
|
||||
@override
|
||||
String get pushNoDistributorTitle => 'No distributor';
|
||||
|
||||
@@ -973,7 +1165,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get pushNoDistributorFallback => 'no distributor';
|
||||
|
||||
@override
|
||||
String get pushSubtitleFcm => 'Google (FCM) — fast, opt-in';
|
||||
String get pushSubtitleFcm => 'Google (FCM)';
|
||||
|
||||
@override
|
||||
String get restoreErrorNameRequired => 'Enter a display name.';
|
||||
@@ -1044,6 +1236,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get qrShareTitle => 'My Šepot ID';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingTitle => 'Connecting to the relay…';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingBody =>
|
||||
'Your QR code and link will appear once Šepot connects for the first time. Just a moment — it only takes a few seconds.';
|
||||
|
||||
@override
|
||||
String get qrIdentityNotFound => 'Identity not found';
|
||||
|
||||
@@ -1270,4 +1469,255 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get photoPreviewDecodeFailed => 'Couldn\'t load the photo';
|
||||
|
||||
@override
|
||||
String get chatActionInfo => 'Message info';
|
||||
|
||||
@override
|
||||
String get messageInfoSent => 'Sent';
|
||||
|
||||
@override
|
||||
String get messageInfoDelivered => 'Delivered';
|
||||
|
||||
@override
|
||||
String get messageInfoRead => 'Read';
|
||||
|
||||
@override
|
||||
String get messageInfoPending => 'not yet';
|
||||
|
||||
@override
|
||||
String get messageInfoReadReceiptsOff =>
|
||||
'You only see when people read your messages if you share read receipts yourself (Settings).';
|
||||
|
||||
@override
|
||||
String chatStatusReadAt(String time) {
|
||||
return 'Read $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String chatStatusDeliveredAt(String time) {
|
||||
return 'Delivered $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsAddMembers => 'Add members';
|
||||
|
||||
@override
|
||||
String get groupsAddMembersAction => 'Add';
|
||||
|
||||
@override
|
||||
String groupsAddMembersFailed(String error) {
|
||||
return 'Could not add members: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsNoContactsToAdd =>
|
||||
'All your contacts are already in this group. Add someone new to your contacts via QR code first.';
|
||||
|
||||
@override
|
||||
String get navChats => 'Chats';
|
||||
|
||||
@override
|
||||
String get chatListNewChat => 'New chat';
|
||||
|
||||
@override
|
||||
String get chatListSearchHint => 'Search chats and messages';
|
||||
|
||||
@override
|
||||
String get chatFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get chatFilterUnread => 'Unread';
|
||||
|
||||
@override
|
||||
String get chatFilterGroups => 'Groups';
|
||||
|
||||
@override
|
||||
String get chatFilterArchive => 'Archive';
|
||||
|
||||
@override
|
||||
String get chatListNoResults => 'Nothing found.';
|
||||
|
||||
@override
|
||||
String get chatListFilterEmpty => 'Nothing here yet.';
|
||||
|
||||
@override
|
||||
String get chatListPin => 'Pin';
|
||||
|
||||
@override
|
||||
String get chatListUnpin => 'Unpin';
|
||||
|
||||
@override
|
||||
String get chatOpenProfile => 'View profile';
|
||||
|
||||
@override
|
||||
String get groupsProfile => 'Group info';
|
||||
|
||||
@override
|
||||
String get chatSearchHint => 'Search in chat';
|
||||
|
||||
@override
|
||||
String get chatSearchNoMatches => 'None';
|
||||
|
||||
@override
|
||||
String chatSearchCounter(int current, int total) {
|
||||
return '$current of $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatSearchOlder => 'Older match';
|
||||
|
||||
@override
|
||||
String get chatSearchNewer => 'Newer match';
|
||||
|
||||
@override
|
||||
String chatUnreadDivider(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count new messages',
|
||||
one: '1 new message',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatJumpToLatest => 'Jump to latest';
|
||||
|
||||
@override
|
||||
String get chatAttach => 'Attach';
|
||||
|
||||
@override
|
||||
String get chatSend => 'Send';
|
||||
|
||||
@override
|
||||
String get chatRecordVoice => 'Record voice message';
|
||||
|
||||
@override
|
||||
String get profileSharedMedia => 'Shared media';
|
||||
|
||||
@override
|
||||
String get profileMessage => 'Message';
|
||||
|
||||
@override
|
||||
String get profileSearch => 'Search';
|
||||
|
||||
@override
|
||||
String get profileUnmute => 'Unmute';
|
||||
|
||||
@override
|
||||
String get profileDisappearing => 'Disappear';
|
||||
|
||||
@override
|
||||
String get profileCommonGroups => 'Groups in common';
|
||||
|
||||
@override
|
||||
String get profileSafetyNumber => 'Safety number';
|
||||
|
||||
@override
|
||||
String get profileSafetyNumberHint => 'Compare in person or by QR code';
|
||||
|
||||
@override
|
||||
String get profileVerifiedInPerson => 'Verified in person';
|
||||
|
||||
@override
|
||||
String get profileVerifyNow => 'Verify contact';
|
||||
|
||||
@override
|
||||
String get profileAdd => 'Add';
|
||||
|
||||
@override
|
||||
String profileGroupSubtitle(String members, String date) {
|
||||
return 'Group · $members · created $date';
|
||||
}
|
||||
|
||||
@override
|
||||
String profileMembersOfMax(int count, int max) {
|
||||
return '$count of $max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatTyping => 'typing…';
|
||||
|
||||
@override
|
||||
String chatTypingNamed(String name) {
|
||||
return '$name is typing…';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatTypingSeveral => 'Several people are typing…';
|
||||
|
||||
@override
|
||||
String get settingsTypingTitle => 'Typing indicators';
|
||||
|
||||
@override
|
||||
String get settingsTypingSubtitle =>
|
||||
'Mutual — if you don\'t share it, you don\'t see it (off by default)';
|
||||
|
||||
@override
|
||||
String get pollAttach => 'Poll';
|
||||
|
||||
@override
|
||||
String get pollCreateTitle => 'New poll';
|
||||
|
||||
@override
|
||||
String get pollQuestion => 'Question';
|
||||
|
||||
@override
|
||||
String pollOptionHint(int number) {
|
||||
return 'Option $number';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pollAddOption => 'Add option';
|
||||
|
||||
@override
|
||||
String get pollRemoveOption => 'Remove option';
|
||||
|
||||
@override
|
||||
String get pollAllowMultiple => 'Allow multiple answers';
|
||||
|
||||
@override
|
||||
String get pollSingle => 'Poll';
|
||||
|
||||
@override
|
||||
String get pollMulti => 'Poll, multiple choice';
|
||||
|
||||
@override
|
||||
String pollVoters(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count votes',
|
||||
one: '1 vote',
|
||||
zero: 'no votes yet',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String pollFallback(String question) {
|
||||
return 'Poll: $question';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pollFallbackUpdate => 'Update Šepot to vote.';
|
||||
|
||||
@override
|
||||
String get profilePhotoEdit => 'Change profile photo';
|
||||
|
||||
@override
|
||||
String get profilePhotoPick => 'Choose photo';
|
||||
|
||||
@override
|
||||
String get profilePhotoRemove => 'Remove photo';
|
||||
|
||||
@override
|
||||
String get profilePhotoHint =>
|
||||
'Only people you chat with will see it — end-to-end encrypted like your messages. No location or photo metadata is sent.';
|
||||
|
||||
@override
|
||||
String profilePhotoFailed(String error) {
|
||||
return 'Could not set photo: $error';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,17 +73,68 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
String get groupsLeftBanner =>
|
||||
'Non sei più nel gruppo. La cronologia resta, i nuovi messaggi non arriveranno.';
|
||||
|
||||
@override
|
||||
String groupsMembersWithCount(int count) {
|
||||
return 'Membri ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsYou => 'Tu';
|
||||
|
||||
@override
|
||||
String get groupsFounder => 'Fondatore';
|
||||
|
||||
@override
|
||||
String get groupsAdmin => 'Amministratore';
|
||||
|
||||
@override
|
||||
String get groupsMakeAdmin => 'Rendi amministratore';
|
||||
|
||||
@override
|
||||
String get groupsDismissAdmin => 'Rimuovi ruolo di amministratore';
|
||||
|
||||
@override
|
||||
String get groupsRemoveMember => 'Rimuovi dal gruppo';
|
||||
|
||||
@override
|
||||
String groupsRemoveMemberConfirmTitle(String name) {
|
||||
return 'Rimuovere $name dal gruppo?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsRemoveMemberConfirmBody =>
|
||||
'Non riceverà più i nuovi messaggi del gruppo. La cronologia esistente resta.';
|
||||
|
||||
@override
|
||||
String get groupsRemoveAction => 'Rimuovi';
|
||||
|
||||
@override
|
||||
String get groupsViewContact => 'Vedi contatto';
|
||||
|
||||
@override
|
||||
String get groupsAdminOnlyHint =>
|
||||
'Solo gli amministratori possono cambiare nome, membri e amministratori del gruppo.';
|
||||
|
||||
@override
|
||||
String get groupsAdminOnlyCompatHint =>
|
||||
'Solo gli amministratori possono rinominare il gruppo o cambiare gli amministratori. Per ora chiunque può aggiungere persone, perché qualcuno nel gruppo ha una versione precedente di Šepot.';
|
||||
|
||||
@override
|
||||
String groupsAdminActionFailed(String error) {
|
||||
return 'Modifica non riuscita: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsResyncNotice =>
|
||||
'Il gruppo si sta allineando con l\'amministratore. Le modifiche ai membri potrebbero arrivare in ritardo.';
|
||||
|
||||
@override
|
||||
String get groupsRejectedChangeNotice =>
|
||||
'Qualcuno senza permesso ha provato a modificare il gruppo. La modifica non è stata applicata.';
|
||||
|
||||
@override
|
||||
String get errorGroupNotAdmin =>
|
||||
'Solo un amministratore del gruppo può farlo.';
|
||||
|
||||
@override
|
||||
String get errorGroupLastAdmin =>
|
||||
'Sei l\'unico amministratore. Prima rendi amministratore qualcun altro.';
|
||||
|
||||
@override
|
||||
String get groupsNameHint => 'Nome del gruppo';
|
||||
|
||||
@@ -191,6 +242,28 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get chatActionReply => 'Rispondi';
|
||||
|
||||
@override
|
||||
String get chatActionForward => 'Inoltra';
|
||||
|
||||
@override
|
||||
String get forwardTitle => 'Inoltra a…';
|
||||
|
||||
@override
|
||||
String get forwardSearchHint => 'Cerca contatto o gruppo';
|
||||
|
||||
@override
|
||||
String get forwardEmpty => 'Nessun risultato';
|
||||
|
||||
@override
|
||||
String forwardDone(String name) {
|
||||
return 'Inoltrato a $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String forwardFailed(String error) {
|
||||
return 'Inoltro non riuscito: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get commonCopy => 'Copia';
|
||||
|
||||
@@ -247,6 +320,16 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
return 'Il file è troppo grande (max $maxMb MB)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get attachmentKeepsMetadataTitle => 'La foto includerà la posizione';
|
||||
|
||||
@override
|
||||
String get attachmentKeepsMetadataBody =>
|
||||
'Non riesco a rimuovere i metadati da questo formato (ad es. HEIC). Verrebbe inviata in originale, compresi il luogo dello scatto e i dati del telefono. È più sicuro inviarla dalla Galleria, dove i metadati vengono rimossi.';
|
||||
|
||||
@override
|
||||
String get attachmentSendOriginal => 'Invia originale';
|
||||
|
||||
@override
|
||||
String attachmentPickFailed(String error) {
|
||||
return 'Selezione dell\'allegato non riuscita: $error';
|
||||
@@ -276,6 +359,14 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
String get errorRelayUnavailable =>
|
||||
'Relay non disponibile (sender certificate)';
|
||||
|
||||
@override
|
||||
String get errorNotYetRegistered =>
|
||||
'Šepot si sta ancora connettendo al relay — riprova a inviare tra un istante.';
|
||||
|
||||
@override
|
||||
String get errorContactLinkIncomplete =>
|
||||
'Questo link è incompleto — l\'altra persona l\'ha creato prima di connettersi. Chiedile un nuovo link sepot://.';
|
||||
|
||||
@override
|
||||
String errorAttachmentTooLarge(int maxMb) {
|
||||
return 'L\'allegato è troppo grande (max $maxMb MB)';
|
||||
@@ -284,6 +375,10 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get errorGroupNeedsMember => 'Il gruppo ha bisogno di almeno 1 membro';
|
||||
|
||||
@override
|
||||
String get errorAttachmentUnavailable =>
|
||||
'L\'allegato non è più su questo telefono';
|
||||
|
||||
@override
|
||||
String errorGroupMaxMembers(int maxMembers) {
|
||||
return 'Il gruppo può avere al massimo $maxMembers membri';
|
||||
@@ -592,19 +687,100 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get chatListNewConversation => 'Nuova conversazione';
|
||||
|
||||
@override
|
||||
String chatListPhotoWithCaptionPreview(String caption) {
|
||||
return '📷 $caption';
|
||||
}
|
||||
|
||||
@override
|
||||
String chatListFilePreview(String fileName) {
|
||||
return '📎 $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatListReadReceiptPreview => 'Letto';
|
||||
|
||||
@override
|
||||
String get chatListDraft => 'Bozza';
|
||||
|
||||
@override
|
||||
String get notesTitle => 'Note per me';
|
||||
|
||||
@override
|
||||
String get notesSubtitle => 'Solo su questo telefono, cifrato';
|
||||
|
||||
@override
|
||||
String get callVoice => 'Chiama';
|
||||
|
||||
@override
|
||||
String get callVideo => 'Videochiamata';
|
||||
|
||||
@override
|
||||
String get callStatusRinging => 'Squilla…';
|
||||
|
||||
@override
|
||||
String get callIncomingVoice => 'Chiamata in arrivo';
|
||||
|
||||
@override
|
||||
String get callIncomingVideo => 'Videochiamata in arrivo';
|
||||
|
||||
@override
|
||||
String get callStatusConnecting => 'Connessione…';
|
||||
|
||||
@override
|
||||
String get callEnded => 'Chiamata terminata';
|
||||
|
||||
@override
|
||||
String get callEndedBusy => 'Occupato';
|
||||
|
||||
@override
|
||||
String get callEndedRejected => 'Chiamata rifiutata';
|
||||
|
||||
@override
|
||||
String get callEndedUnanswered => 'Nessuna risposta';
|
||||
|
||||
@override
|
||||
String get callEndedFailed => 'Connessione non riuscita';
|
||||
|
||||
@override
|
||||
String get callEncrypted => 'Crittografia end-to-end';
|
||||
|
||||
@override
|
||||
String get callAccept => 'Accetta';
|
||||
|
||||
@override
|
||||
String get callReject => 'Rifiuta';
|
||||
|
||||
@override
|
||||
String get callMute => 'Disattiva microfono';
|
||||
|
||||
@override
|
||||
String get callSpeaker => 'Altoparlante';
|
||||
|
||||
@override
|
||||
String get callCamera => 'Disattiva fotocamera';
|
||||
|
||||
@override
|
||||
String get callSwitchCamera => 'Cambia fotocamera';
|
||||
|
||||
@override
|
||||
String get callHangUp => 'Riaggancia';
|
||||
|
||||
@override
|
||||
String get callLogMissed => 'Chiamata persa';
|
||||
|
||||
@override
|
||||
String get callLogUnanswered => 'Chiamata senza risposta';
|
||||
|
||||
@override
|
||||
String get callLogRejected => 'Chiamata rifiutata';
|
||||
|
||||
@override
|
||||
String get callLogBusy => 'Occupato';
|
||||
|
||||
@override
|
||||
String get callLogFailed => 'Chiamata non riuscita';
|
||||
|
||||
@override
|
||||
String callLogVoice(String duration) {
|
||||
return 'Chiamata · $duration';
|
||||
}
|
||||
|
||||
@override
|
||||
String callLogVideo(String duration) {
|
||||
return 'Videochiamata · $duration';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatListTimeNow => 'adesso';
|
||||
|
||||
@@ -935,6 +1111,11 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get pinUnlockError => 'Errore di sblocco';
|
||||
|
||||
@override
|
||||
String pinLockedFor(int seconds) {
|
||||
return 'Troppi tentativi. Riprova tra $seconds s.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushTitle => 'Notifiche push';
|
||||
|
||||
@@ -943,14 +1124,14 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
'I messaggi verranno scaricati solo all\'apertura dell\'app.';
|
||||
|
||||
@override
|
||||
String get pushModeUp => 'UnifiedPush (consigliato)';
|
||||
String get pushModeUp => 'UnifiedPush (senza Google)';
|
||||
|
||||
@override
|
||||
String get pushModeUpDesc =>
|
||||
'Senza Google — richiede l\'app ntfy (F-Droid/Play).';
|
||||
|
||||
@override
|
||||
String get pushModeFcm => 'Google (FCM)';
|
||||
String get pushModeFcm => 'Google (FCM, predefinito)';
|
||||
|
||||
@override
|
||||
String get pushModeFcmDesc =>
|
||||
@@ -961,6 +1142,18 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
return 'Configurazione delle notifiche push non riuscita: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushHintOff =>
|
||||
'I messaggi arrivano solo quando apri Šepot — le notifiche non sono configurate.';
|
||||
|
||||
@override
|
||||
String get pushHintFailed =>
|
||||
'Le notifiche non funzionano: vedrai i messaggi solo aprendo Šepot.';
|
||||
|
||||
@override
|
||||
String get pushRegistrationFailed =>
|
||||
'Le notifiche non arrivano — il distributore ha rifiutato la registrazione. Prova a disattivare e riattivare il push.';
|
||||
|
||||
@override
|
||||
String get pushNoDistributorTitle => 'Nessun distributore';
|
||||
|
||||
@@ -983,7 +1176,7 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
String get pushNoDistributorFallback => 'nessun distributore';
|
||||
|
||||
@override
|
||||
String get pushSubtitleFcm => 'Google (FCM) — veloce, opt-in';
|
||||
String get pushSubtitleFcm => 'Google (FCM)';
|
||||
|
||||
@override
|
||||
String get restoreErrorNameRequired => 'Inserisci il nome visualizzato.';
|
||||
@@ -1055,6 +1248,13 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get qrShareTitle => 'Il mio Šepot ID';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingTitle => 'Connessione al relay…';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingBody =>
|
||||
'Il tuo codice QR e il link appariranno appena Šepot si connette per la prima volta. Un istante — bastano pochi secondi.';
|
||||
|
||||
@override
|
||||
String get qrIdentityNotFound => 'Identità non trovata';
|
||||
|
||||
@@ -1284,4 +1484,255 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get photoPreviewDecodeFailed => 'Impossibile caricare la foto';
|
||||
|
||||
@override
|
||||
String get chatActionInfo => 'Info messaggio';
|
||||
|
||||
@override
|
||||
String get messageInfoSent => 'Inviato';
|
||||
|
||||
@override
|
||||
String get messageInfoDelivered => 'Consegnato';
|
||||
|
||||
@override
|
||||
String get messageInfoRead => 'Letto';
|
||||
|
||||
@override
|
||||
String get messageInfoPending => 'non ancora';
|
||||
|
||||
@override
|
||||
String get messageInfoReadReceiptsOff =>
|
||||
'Vedi quando i tuoi messaggi vengono letti solo se condividi anche tu le conferme di lettura (Impostazioni).';
|
||||
|
||||
@override
|
||||
String chatStatusReadAt(String time) {
|
||||
return 'Letto $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String chatStatusDeliveredAt(String time) {
|
||||
return 'Consegnato $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsAddMembers => 'Aggiungi membri';
|
||||
|
||||
@override
|
||||
String get groupsAddMembersAction => 'Aggiungi';
|
||||
|
||||
@override
|
||||
String groupsAddMembersFailed(String error) {
|
||||
return 'Impossibile aggiungere: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsNoContactsToAdd =>
|
||||
'Tutti i tuoi contatti sono già nel gruppo. Aggiungi prima una nuova persona ai contatti tramite codice QR.';
|
||||
|
||||
@override
|
||||
String get navChats => 'Chat';
|
||||
|
||||
@override
|
||||
String get chatListNewChat => 'Nuova chat';
|
||||
|
||||
@override
|
||||
String get chatListSearchHint => 'Cerca in chat e messaggi';
|
||||
|
||||
@override
|
||||
String get chatFilterAll => 'Tutte';
|
||||
|
||||
@override
|
||||
String get chatFilterUnread => 'Non lette';
|
||||
|
||||
@override
|
||||
String get chatFilterGroups => 'Gruppi';
|
||||
|
||||
@override
|
||||
String get chatFilterArchive => 'Archivio';
|
||||
|
||||
@override
|
||||
String get chatListNoResults => 'Nessun risultato.';
|
||||
|
||||
@override
|
||||
String get chatListFilterEmpty => 'Ancora niente qui.';
|
||||
|
||||
@override
|
||||
String get chatListPin => 'Fissa';
|
||||
|
||||
@override
|
||||
String get chatListUnpin => 'Sblocca';
|
||||
|
||||
@override
|
||||
String get chatOpenProfile => 'Vedi profilo';
|
||||
|
||||
@override
|
||||
String get groupsProfile => 'Info gruppo';
|
||||
|
||||
@override
|
||||
String get chatSearchHint => 'Cerca nella chat';
|
||||
|
||||
@override
|
||||
String get chatSearchNoMatches => 'Nessuno';
|
||||
|
||||
@override
|
||||
String chatSearchCounter(int current, int total) {
|
||||
return '$current di $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatSearchOlder => 'Risultato precedente';
|
||||
|
||||
@override
|
||||
String get chatSearchNewer => 'Risultato successivo';
|
||||
|
||||
@override
|
||||
String chatUnreadDivider(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count nuovi messaggi',
|
||||
one: '1 nuovo messaggio',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatJumpToLatest => 'Vai ai più recenti';
|
||||
|
||||
@override
|
||||
String get chatAttach => 'Allega';
|
||||
|
||||
@override
|
||||
String get chatSend => 'Invia';
|
||||
|
||||
@override
|
||||
String get chatRecordVoice => 'Registra messaggio vocale';
|
||||
|
||||
@override
|
||||
String get profileSharedMedia => 'Media condivisi';
|
||||
|
||||
@override
|
||||
String get profileMessage => 'Messaggio';
|
||||
|
||||
@override
|
||||
String get profileSearch => 'Cerca';
|
||||
|
||||
@override
|
||||
String get profileUnmute => 'Riattiva';
|
||||
|
||||
@override
|
||||
String get profileDisappearing => 'Effimeri';
|
||||
|
||||
@override
|
||||
String get profileCommonGroups => 'Gruppi in comune';
|
||||
|
||||
@override
|
||||
String get profileSafetyNumber => 'Numero di sicurezza';
|
||||
|
||||
@override
|
||||
String get profileSafetyNumberHint => 'Confronta di persona o con codice QR';
|
||||
|
||||
@override
|
||||
String get profileVerifiedInPerson => 'Verificato di persona';
|
||||
|
||||
@override
|
||||
String get profileVerifyNow => 'Verifica contatto';
|
||||
|
||||
@override
|
||||
String get profileAdd => 'Aggiungi';
|
||||
|
||||
@override
|
||||
String profileGroupSubtitle(String members, String date) {
|
||||
return 'Gruppo · $members · creato il $date';
|
||||
}
|
||||
|
||||
@override
|
||||
String profileMembersOfMax(int count, int max) {
|
||||
return '$count su $max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatTyping => 'sta scrivendo…';
|
||||
|
||||
@override
|
||||
String chatTypingNamed(String name) {
|
||||
return '$name sta scrivendo…';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatTypingSeveral => 'Più persone stanno scrivendo…';
|
||||
|
||||
@override
|
||||
String get settingsTypingTitle => 'Indicatore di scrittura';
|
||||
|
||||
@override
|
||||
String get settingsTypingSubtitle =>
|
||||
'Reciproco — se non lo condividi, non lo vedi (disattivato di default)';
|
||||
|
||||
@override
|
||||
String get pollAttach => 'Sondaggio';
|
||||
|
||||
@override
|
||||
String get pollCreateTitle => 'Nuovo sondaggio';
|
||||
|
||||
@override
|
||||
String get pollQuestion => 'Domanda';
|
||||
|
||||
@override
|
||||
String pollOptionHint(int number) {
|
||||
return 'Opzione $number';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pollAddOption => 'Aggiungi opzione';
|
||||
|
||||
@override
|
||||
String get pollRemoveOption => 'Rimuovi opzione';
|
||||
|
||||
@override
|
||||
String get pollAllowMultiple => 'Consenti più risposte';
|
||||
|
||||
@override
|
||||
String get pollSingle => 'Sondaggio';
|
||||
|
||||
@override
|
||||
String get pollMulti => 'Sondaggio, scelta multipla';
|
||||
|
||||
@override
|
||||
String pollVoters(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count voti',
|
||||
one: '1 voto',
|
||||
zero: 'ancora nessun voto',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String pollFallback(String question) {
|
||||
return 'Sondaggio: $question';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pollFallbackUpdate => 'Aggiorna Šepot per votare.';
|
||||
|
||||
@override
|
||||
String get profilePhotoEdit => 'Cambia foto profilo';
|
||||
|
||||
@override
|
||||
String get profilePhotoPick => 'Scegli foto';
|
||||
|
||||
@override
|
||||
String get profilePhotoRemove => 'Rimuovi foto';
|
||||
|
||||
@override
|
||||
String get profilePhotoHint =>
|
||||
'La vedranno solo le persone con cui chatti — cifrata come i messaggi. Posizione e metadati non vengono inviati.';
|
||||
|
||||
@override
|
||||
String profilePhotoFailed(String error) {
|
||||
return 'Impossibile impostare la foto: $error';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,17 +75,68 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get groupsLeftBanner =>
|
||||
'Вы больше не в группе. История сохранится, но новые сообщения приходить не будут.';
|
||||
|
||||
@override
|
||||
String groupsMembersWithCount(int count) {
|
||||
return 'Участники ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsYou => 'Вы';
|
||||
|
||||
@override
|
||||
String get groupsFounder => 'Создатель';
|
||||
|
||||
@override
|
||||
String get groupsAdmin => 'Администратор';
|
||||
|
||||
@override
|
||||
String get groupsMakeAdmin => 'Сделать администратором';
|
||||
|
||||
@override
|
||||
String get groupsDismissAdmin => 'Снять роль администратора';
|
||||
|
||||
@override
|
||||
String get groupsRemoveMember => 'Удалить из группы';
|
||||
|
||||
@override
|
||||
String groupsRemoveMemberConfirmTitle(String name) {
|
||||
return 'Удалить $name из группы?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsRemoveMemberConfirmBody =>
|
||||
'Новые сообщения группы перестанут приходить. Прежняя история сохранится.';
|
||||
|
||||
@override
|
||||
String get groupsRemoveAction => 'Удалить';
|
||||
|
||||
@override
|
||||
String get groupsViewContact => 'Открыть контакт';
|
||||
|
||||
@override
|
||||
String get groupsAdminOnlyHint =>
|
||||
'Название, участников и администраторов группы меняют только администраторы.';
|
||||
|
||||
@override
|
||||
String get groupsAdminOnlyCompatHint =>
|
||||
'Переименовать группу и менять администраторов могут только администраторы. Добавлять людей пока может любой, потому что у кого-то в группе старая версия Šepot.';
|
||||
|
||||
@override
|
||||
String groupsAdminActionFailed(String error) {
|
||||
return 'Не удалось внести изменение: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsResyncNotice =>
|
||||
'Группа сверяется с администратором. Изменения состава могут появиться с задержкой.';
|
||||
|
||||
@override
|
||||
String get groupsRejectedChangeNotice =>
|
||||
'Кто-то без прав попытался изменить группу. Изменение не применено.';
|
||||
|
||||
@override
|
||||
String get errorGroupNotAdmin =>
|
||||
'Это может сделать только администратор группы.';
|
||||
|
||||
@override
|
||||
String get errorGroupLastAdmin =>
|
||||
'Вы единственный администратор. Сначала назначьте администратором кого-то ещё.';
|
||||
|
||||
@override
|
||||
String get groupsNameHint => 'Название группы';
|
||||
|
||||
@@ -192,6 +243,28 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get chatActionReply => 'Ответить';
|
||||
|
||||
@override
|
||||
String get chatActionForward => 'Переслать';
|
||||
|
||||
@override
|
||||
String get forwardTitle => 'Переслать в…';
|
||||
|
||||
@override
|
||||
String get forwardSearchHint => 'Найти контакт или группу';
|
||||
|
||||
@override
|
||||
String get forwardEmpty => 'Ничего не найдено';
|
||||
|
||||
@override
|
||||
String forwardDone(String name) {
|
||||
return 'Переслано: $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String forwardFailed(String error) {
|
||||
return 'Не удалось переслать: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get commonCopy => 'Копировать';
|
||||
|
||||
@@ -248,6 +321,16 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return 'Файл слишком большой (макс. $maxMb МБ)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get attachmentKeepsMetadataTitle => 'Фото будет с геолокацией';
|
||||
|
||||
@override
|
||||
String get attachmentKeepsMetadataBody =>
|
||||
'Из этого формата фото (например, HEIC) я не могу удалить метаданные. Оно будет отправлено в оригинале, включая место съёмки и данные о телефоне. Безопаснее отправить его через Галерею — там метаданные удаляются.';
|
||||
|
||||
@override
|
||||
String get attachmentSendOriginal => 'Отправить оригинал';
|
||||
|
||||
@override
|
||||
String attachmentPickFailed(String error) {
|
||||
return 'Не удалось выбрать вложение: $error';
|
||||
@@ -276,6 +359,14 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get errorRelayUnavailable => 'Релей недоступен (sender certificate)';
|
||||
|
||||
@override
|
||||
String get errorNotYetRegistered =>
|
||||
'Šepot ещё подключается к релею — попробуйте отправить сообщение через пару секунд.';
|
||||
|
||||
@override
|
||||
String get errorContactLinkIncomplete =>
|
||||
'Ссылка неполная — собеседник создал её до подключения. Попросите у него новую ссылку sepot://.';
|
||||
|
||||
@override
|
||||
String errorAttachmentTooLarge(int maxMb) {
|
||||
return 'Вложение слишком большое (макс. $maxMb МБ)';
|
||||
@@ -284,6 +375,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get errorGroupNeedsMember => 'В группе должен быть хотя бы 1 участник';
|
||||
|
||||
@override
|
||||
String get errorAttachmentUnavailable =>
|
||||
'Вложения больше нет на этом телефоне';
|
||||
|
||||
@override
|
||||
String errorGroupMaxMembers(int maxMembers) {
|
||||
return 'Максимальное число участников группы — $maxMembers';
|
||||
@@ -595,19 +690,100 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get chatListNewConversation => 'Новый чат';
|
||||
|
||||
@override
|
||||
String chatListPhotoWithCaptionPreview(String caption) {
|
||||
return '📷 $caption';
|
||||
}
|
||||
|
||||
@override
|
||||
String chatListFilePreview(String fileName) {
|
||||
return '📎 $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatListReadReceiptPreview => 'Прочитано';
|
||||
|
||||
@override
|
||||
String get chatListDraft => 'Черновик';
|
||||
|
||||
@override
|
||||
String get notesTitle => 'Заметки для себя';
|
||||
|
||||
@override
|
||||
String get notesSubtitle => 'Только на этом телефоне, в зашифрованном виде';
|
||||
|
||||
@override
|
||||
String get callVoice => 'Позвонить';
|
||||
|
||||
@override
|
||||
String get callVideo => 'Видеозвонок';
|
||||
|
||||
@override
|
||||
String get callStatusRinging => 'Вызов…';
|
||||
|
||||
@override
|
||||
String get callIncomingVoice => 'Входящий звонок';
|
||||
|
||||
@override
|
||||
String get callIncomingVideo => 'Входящий видеозвонок';
|
||||
|
||||
@override
|
||||
String get callStatusConnecting => 'Соединение…';
|
||||
|
||||
@override
|
||||
String get callEnded => 'Звонок завершён';
|
||||
|
||||
@override
|
||||
String get callEndedBusy => 'Занято';
|
||||
|
||||
@override
|
||||
String get callEndedRejected => 'Звонок отклонён';
|
||||
|
||||
@override
|
||||
String get callEndedUnanswered => 'Нет ответа';
|
||||
|
||||
@override
|
||||
String get callEndedFailed => 'Не удалось соединиться';
|
||||
|
||||
@override
|
||||
String get callEncrypted => 'Сквозное шифрование';
|
||||
|
||||
@override
|
||||
String get callAccept => 'Принять';
|
||||
|
||||
@override
|
||||
String get callReject => 'Отклонить';
|
||||
|
||||
@override
|
||||
String get callMute => 'Выключить микрофон';
|
||||
|
||||
@override
|
||||
String get callSpeaker => 'Динамик';
|
||||
|
||||
@override
|
||||
String get callCamera => 'Выключить камеру';
|
||||
|
||||
@override
|
||||
String get callSwitchCamera => 'Сменить камеру';
|
||||
|
||||
@override
|
||||
String get callHangUp => 'Завершить';
|
||||
|
||||
@override
|
||||
String get callLogMissed => 'Пропущенный звонок';
|
||||
|
||||
@override
|
||||
String get callLogUnanswered => 'Звонок без ответа';
|
||||
|
||||
@override
|
||||
String get callLogRejected => 'Отклонённый звонок';
|
||||
|
||||
@override
|
||||
String get callLogBusy => 'Занято';
|
||||
|
||||
@override
|
||||
String get callLogFailed => 'Звонок не удался';
|
||||
|
||||
@override
|
||||
String callLogVoice(String duration) {
|
||||
return 'Звонок · $duration';
|
||||
}
|
||||
|
||||
@override
|
||||
String callLogVideo(String duration) {
|
||||
return 'Видеозвонок · $duration';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatListTimeNow => 'сейчас';
|
||||
|
||||
@@ -942,6 +1118,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get pinUnlockError => 'Ошибка разблокировки';
|
||||
|
||||
@override
|
||||
String pinLockedFor(int seconds) {
|
||||
return 'Слишком много попыток. Повторите через $seconds с.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushTitle => 'Push-уведомления';
|
||||
|
||||
@@ -950,14 +1131,14 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
'Сообщения загрузятся только после открытия приложения.';
|
||||
|
||||
@override
|
||||
String get pushModeUp => 'UnifiedPush (рекомендуется)';
|
||||
String get pushModeUp => 'UnifiedPush (без Google)';
|
||||
|
||||
@override
|
||||
String get pushModeUpDesc =>
|
||||
'Без сервисов Google — требуется приложение ntfy (F-Droid/Play).';
|
||||
|
||||
@override
|
||||
String get pushModeFcm => 'Google (FCM)';
|
||||
String get pushModeFcm => 'Google (FCM, по умолчанию)';
|
||||
|
||||
@override
|
||||
String get pushModeFcmDesc =>
|
||||
@@ -968,6 +1149,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return 'Не удалось настроить push: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushHintOff =>
|
||||
'Сообщения придут, только когда откроешь Šepot — сигналы не настроены.';
|
||||
|
||||
@override
|
||||
String get pushHintFailed =>
|
||||
'Сигналы не работают: сообщения увидишь только после открытия Šepot.';
|
||||
|
||||
@override
|
||||
String get pushRegistrationFailed =>
|
||||
'Сигналы не приходят — дистрибьютор отклонил регистрацию. Попробуй выключить и снова включить push.';
|
||||
|
||||
@override
|
||||
String get pushNoDistributorTitle => 'Нет дистрибьютора';
|
||||
|
||||
@@ -990,8 +1183,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get pushNoDistributorFallback => 'без дистрибьютора';
|
||||
|
||||
@override
|
||||
String get pushSubtitleFcm =>
|
||||
'Google (FCM) — быстро, только по вашему выбору';
|
||||
String get pushSubtitleFcm => 'Google (FCM)';
|
||||
|
||||
@override
|
||||
String get restoreErrorNameRequired => 'Введите отображаемое имя.';
|
||||
@@ -1072,6 +1264,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get qrShareTitle => 'Мой Šepot ID';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingTitle => 'Подключение к релею…';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingBody =>
|
||||
'Ваш QR-код и ссылка появятся, как только Šepot подключится в первый раз. Подождите пару секунд.';
|
||||
|
||||
@override
|
||||
String get qrIdentityNotFound => 'Идентичность не найдена';
|
||||
|
||||
@@ -1309,4 +1508,257 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get photoPreviewDecodeFailed => 'Не удалось загрузить фото';
|
||||
|
||||
@override
|
||||
String get chatActionInfo => 'Сведения о сообщении';
|
||||
|
||||
@override
|
||||
String get messageInfoSent => 'Отправлено';
|
||||
|
||||
@override
|
||||
String get messageInfoDelivered => 'Доставлено';
|
||||
|
||||
@override
|
||||
String get messageInfoRead => 'Прочитано';
|
||||
|
||||
@override
|
||||
String get messageInfoPending => 'ещё нет';
|
||||
|
||||
@override
|
||||
String get messageInfoReadReceiptsOff =>
|
||||
'Время прочтения видно, только если вы сами делитесь отчётами о прочтении (Настройки).';
|
||||
|
||||
@override
|
||||
String chatStatusReadAt(String time) {
|
||||
return 'Прочитано $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String chatStatusDeliveredAt(String time) {
|
||||
return 'Доставлено $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsAddMembers => 'Добавить участников';
|
||||
|
||||
@override
|
||||
String get groupsAddMembersAction => 'Добавить';
|
||||
|
||||
@override
|
||||
String groupsAddMembersFailed(String error) {
|
||||
return 'Не удалось добавить: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get groupsNoContactsToAdd =>
|
||||
'Все ваши контакты уже в группе. Сначала добавьте нового человека в контакты по QR-коду.';
|
||||
|
||||
@override
|
||||
String get navChats => 'Чаты';
|
||||
|
||||
@override
|
||||
String get chatListNewChat => 'Новый чат';
|
||||
|
||||
@override
|
||||
String get chatListSearchHint => 'Поиск по чатам и сообщениям';
|
||||
|
||||
@override
|
||||
String get chatFilterAll => 'Все';
|
||||
|
||||
@override
|
||||
String get chatFilterUnread => 'Непрочитанные';
|
||||
|
||||
@override
|
||||
String get chatFilterGroups => 'Группы';
|
||||
|
||||
@override
|
||||
String get chatFilterArchive => 'Архив';
|
||||
|
||||
@override
|
||||
String get chatListNoResults => 'Ничего не найдено.';
|
||||
|
||||
@override
|
||||
String get chatListFilterEmpty => 'Здесь пока ничего нет.';
|
||||
|
||||
@override
|
||||
String get chatListPin => 'Закрепить';
|
||||
|
||||
@override
|
||||
String get chatListUnpin => 'Открепить';
|
||||
|
||||
@override
|
||||
String get chatOpenProfile => 'Открыть профиль';
|
||||
|
||||
@override
|
||||
String get groupsProfile => 'Информация о группе';
|
||||
|
||||
@override
|
||||
String get chatSearchHint => 'Поиск в чате';
|
||||
|
||||
@override
|
||||
String get chatSearchNoMatches => 'Нет';
|
||||
|
||||
@override
|
||||
String chatSearchCounter(int current, int total) {
|
||||
return '$current из $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatSearchOlder => 'Предыдущее совпадение';
|
||||
|
||||
@override
|
||||
String get chatSearchNewer => 'Следующее совпадение';
|
||||
|
||||
@override
|
||||
String chatUnreadDivider(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count новых сообщений',
|
||||
few: '$count новых сообщения',
|
||||
one: '$count новое сообщение',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatJumpToLatest => 'К последним сообщениям';
|
||||
|
||||
@override
|
||||
String get chatAttach => 'Прикрепить';
|
||||
|
||||
@override
|
||||
String get chatSend => 'Отправить';
|
||||
|
||||
@override
|
||||
String get chatRecordVoice => 'Записать голосовое';
|
||||
|
||||
@override
|
||||
String get profileSharedMedia => 'Общие медиа';
|
||||
|
||||
@override
|
||||
String get profileMessage => 'Написать';
|
||||
|
||||
@override
|
||||
String get profileSearch => 'Поиск';
|
||||
|
||||
@override
|
||||
String get profileUnmute => 'Включить звук';
|
||||
|
||||
@override
|
||||
String get profileDisappearing => 'Исчезание';
|
||||
|
||||
@override
|
||||
String get profileCommonGroups => 'Общие группы';
|
||||
|
||||
@override
|
||||
String get profileSafetyNumber => 'Код безопасности';
|
||||
|
||||
@override
|
||||
String get profileSafetyNumberHint => 'Сравните лично или по QR-коду';
|
||||
|
||||
@override
|
||||
String get profileVerifiedInPerson => 'Проверено лично';
|
||||
|
||||
@override
|
||||
String get profileVerifyNow => 'Проверить контакт';
|
||||
|
||||
@override
|
||||
String get profileAdd => 'Добавить';
|
||||
|
||||
@override
|
||||
String profileGroupSubtitle(String members, String date) {
|
||||
return 'Группа · $members · создана $date';
|
||||
}
|
||||
|
||||
@override
|
||||
String profileMembersOfMax(int count, int max) {
|
||||
return '$count из $max';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatTyping => 'печатает…';
|
||||
|
||||
@override
|
||||
String chatTypingNamed(String name) {
|
||||
return '$name печатает…';
|
||||
}
|
||||
|
||||
@override
|
||||
String get chatTypingSeveral => 'Несколько человек печатают…';
|
||||
|
||||
@override
|
||||
String get settingsTypingTitle => 'Индикатор набора';
|
||||
|
||||
@override
|
||||
String get settingsTypingSubtitle =>
|
||||
'Взаимно — не делитесь, не видите (по умолчанию выкл.)';
|
||||
|
||||
@override
|
||||
String get pollAttach => 'Опрос';
|
||||
|
||||
@override
|
||||
String get pollCreateTitle => 'Новый опрос';
|
||||
|
||||
@override
|
||||
String get pollQuestion => 'Вопрос';
|
||||
|
||||
@override
|
||||
String pollOptionHint(int number) {
|
||||
return 'Вариант $number';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pollAddOption => 'Добавить вариант';
|
||||
|
||||
@override
|
||||
String get pollRemoveOption => 'Удалить вариант';
|
||||
|
||||
@override
|
||||
String get pollAllowMultiple => 'Разрешить несколько ответов';
|
||||
|
||||
@override
|
||||
String get pollSingle => 'Опрос';
|
||||
|
||||
@override
|
||||
String get pollMulti => 'Опрос, несколько ответов';
|
||||
|
||||
@override
|
||||
String pollVoters(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count голосов',
|
||||
few: '$count голоса',
|
||||
one: '$count голос',
|
||||
zero: 'пока нет голосов',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String pollFallback(String question) {
|
||||
return 'Опрос: $question';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pollFallbackUpdate => 'Обновите Šepot, чтобы голосовать.';
|
||||
|
||||
@override
|
||||
String get profilePhotoEdit => 'Изменить фото профиля';
|
||||
|
||||
@override
|
||||
String get profilePhotoPick => 'Выбрать фото';
|
||||
|
||||
@override
|
||||
String get profilePhotoRemove => 'Удалить фото';
|
||||
|
||||
@override
|
||||
String get profilePhotoHint =>
|
||||
'Фото увидят только ваши собеседники — зашифровано, как и сообщения. Геоданные и метаданные не отправляются.';
|
||||
|
||||
@override
|
||||
String profilePhotoFailed(String error) {
|
||||
return 'Не удалось установить фото: $error';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:sepot/app.dart';
|
||||
import 'package:sepot/core/app_info.dart';
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/crypto/sepot_crypto.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
@@ -56,6 +57,14 @@ void main(List<String> args) {
|
||||
debugPrint('FlutterError: ${details.exceptionAsString()}');
|
||||
};
|
||||
|
||||
// Verze appky z balíčku — nekritické, bez ní se jen v nastavení a
|
||||
// v hlášení zneužití místo čísla ukáže AppInfo.unknownVersion.
|
||||
try {
|
||||
await AppInfo.init();
|
||||
} catch (e, s) {
|
||||
debugPrint('AppInfo.init selhalo (nekritické): $e\n$s');
|
||||
}
|
||||
|
||||
// Formáty dat všech podporovaných jazyků — nekritické, appka poběží i bez nich
|
||||
for (final lang in const ['cs', 'en', 'de', 'ru', 'it']) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
|
||||
/// Stav přenosu přílohy — čistě lokální (na drát nikdy nejde).
|
||||
enum AttachmentTransferState {
|
||||
/// Přijatá zpráva, blob se ještě nezačal stahovat.
|
||||
@@ -94,14 +96,22 @@ class AttachmentMeta {
|
||||
/// (blob_id formát hlídá AttachmentStore, tady rozsahy a typy).
|
||||
factory AttachmentMeta.fromWire(Map<String, dynamic> wire) {
|
||||
final size = wire['s'] as int;
|
||||
if (size <= 0 || size > 512 * 1024 * 1024) {
|
||||
if (size <= 0 || size > AppConstants.maxAttachmentBytes) {
|
||||
throw const FormatException('implausible attachment size');
|
||||
}
|
||||
// Krypto pole MUSÍ být validní base64 (jinak je meta k ničemu);
|
||||
// vyhodí FormatException → zpráva se zahodí jako vadná.
|
||||
base64Decode(wire['k'] as String);
|
||||
base64Decode(wire['n'] as String);
|
||||
base64Decode(wire['d'] as String);
|
||||
// Krypto pole MUSÍ být validní base64 správné délky (AES-256-GCM klíč,
|
||||
// 96bitová nonce, SHA-256 digest) — jinak by vadná meta prošla a
|
||||
// selhala až při otevření přílohy, kdy je obálka dávno ACKnutá.
|
||||
if (base64Decode(wire['k'] as String).length != 32 ||
|
||||
base64Decode(wire['n'] as String).length != 12 ||
|
||||
base64Decode(wire['d'] as String).length != 32) {
|
||||
throw const FormatException('invalid attachment key material');
|
||||
}
|
||||
final name = wire['name'] as String?;
|
||||
final mime = wire['mime'] as String;
|
||||
if ((name != null && name.length > 255) || mime.length > 100) {
|
||||
throw const FormatException('attachment name or mime too long');
|
||||
}
|
||||
// Vadný náhled zprávu nezabíjí — UI ukáže placeholder a ostrá fotka
|
||||
// se stáhne z blobu. base64Decode v build() by shodil celou bublinu.
|
||||
var thumb = wire['th'] as String?;
|
||||
@@ -118,8 +128,8 @@ class AttachmentMeta {
|
||||
nonceB64: wire['n'] as String,
|
||||
digestB64: wire['d'] as String,
|
||||
size: size,
|
||||
mime: wire['mime'] as String,
|
||||
name: wire['name'] as String?,
|
||||
mime: mime,
|
||||
name: name,
|
||||
width: wire['w'] as int?,
|
||||
height: wire['h'] as int?,
|
||||
thumbB64: thumb,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:sepot/core/crypto/attachment_cipher.dart';
|
||||
import 'package:sepot/core/storage/attachment_store.dart';
|
||||
|
||||
/// Odkaz na profilovou fotku: šifrovaný blob na relayi + klíč k němu.
|
||||
///
|
||||
/// Fotka sama nejde v každé zprávě — v profilu `p` cestuje jen tenhle
|
||||
/// odkaz (pár set bajtů). Příjemce blob stáhne jednou, ověří digest
|
||||
/// a drží si ho šifrovaný u sebe.
|
||||
class AvatarRef {
|
||||
const AvatarRef({
|
||||
required this.blobId,
|
||||
required this.key,
|
||||
required this.nonce,
|
||||
required this.digest,
|
||||
this.uploadedAt,
|
||||
});
|
||||
|
||||
/// Profilová fotka je malá (256 px JPEG) — cokoli většího je podvrh.
|
||||
static const maxBlobBytes = 256 * 1024;
|
||||
|
||||
final String blobId;
|
||||
final Uint8List key;
|
||||
final Uint8List nonce;
|
||||
final Uint8List digest;
|
||||
|
||||
/// Kdy jsem blob nahrál (jen u vlastní fotky — kvůli obnově před
|
||||
/// vypršením na relayi).
|
||||
final DateTime? uploadedAt;
|
||||
|
||||
Map<String, dynamic> toWire() => {
|
||||
'b': blobId,
|
||||
'k': base64Encode(key),
|
||||
'n': base64Encode(nonce),
|
||||
'd': base64Encode(digest),
|
||||
};
|
||||
|
||||
/// Odkaz od protistrany; cokoli podezřelého = null (fotka se neukáže).
|
||||
static AvatarRef? fromWire(Object? raw) {
|
||||
if (raw is! Map) return null;
|
||||
final b = raw['b'];
|
||||
if (b is! String || !AttachmentStore.isValidBlobId(b)) return null;
|
||||
try {
|
||||
final key = base64Decode(raw['k'] as String);
|
||||
final nonce = base64Decode(raw['n'] as String);
|
||||
final digest = base64Decode(raw['d'] as String);
|
||||
if (key.length != AttachmentCipher.keyLength ||
|
||||
nonce.length != AttachmentCipher.nonceLength ||
|
||||
digest.length != 32) {
|
||||
return null;
|
||||
}
|
||||
return AvatarRef(blobId: b, key: key, nonce: nonce, digest: digest);
|
||||
} on Object {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
...toWire(),
|
||||
if (uploadedAt != null) 'u': uploadedAt!.millisecondsSinceEpoch,
|
||||
};
|
||||
|
||||
static AvatarRef? fromJson(Object? raw) {
|
||||
final ref = fromWire(raw);
|
||||
if (ref == null || raw is! Map) return ref;
|
||||
final u = raw['u'];
|
||||
return u is int
|
||||
? AvatarRef(
|
||||
blobId: ref.blobId,
|
||||
key: ref.key,
|
||||
nonce: ref.nonce,
|
||||
digest: ref.digest,
|
||||
uploadedAt: DateTime.fromMillisecondsSinceEpoch(u),
|
||||
)
|
||||
: ref;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/// Záznam hovoru v chatu („Hovor · 3:12“, „Zmeškaný hovor“). Jen lokální:
|
||||
/// každá strana si ho zapíše sama podle toho, jak hovor u ní skončil.
|
||||
enum CallOutcome {
|
||||
/// Hovor proběhl (spojil se), [CallLog.durationSeconds] je jeho délka.
|
||||
completed,
|
||||
|
||||
/// Příchozí, nikdo ho nevzal (nebo nabídka dorazila pozdě).
|
||||
missed,
|
||||
|
||||
/// Odchozí, protistrana ho nevzala do vypršení zvonění.
|
||||
unanswered,
|
||||
|
||||
/// Odmítnutý (mnou u příchozího, protistranou u odchozího).
|
||||
rejected,
|
||||
|
||||
/// Protistrana měla jiný hovor.
|
||||
busy,
|
||||
|
||||
/// Spojení se nepodařilo navázat (síť, firewall bez TURN).
|
||||
failed,
|
||||
}
|
||||
|
||||
class CallLog {
|
||||
const CallLog({
|
||||
required this.outcome,
|
||||
required this.outgoing,
|
||||
required this.video,
|
||||
this.durationSeconds = 0,
|
||||
});
|
||||
|
||||
final CallOutcome outcome;
|
||||
final bool outgoing;
|
||||
final bool video;
|
||||
final int durationSeconds;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'o': outcome.name,
|
||||
'out': outgoing,
|
||||
'vid': video,
|
||||
if (durationSeconds > 0) 'dur': durationSeconds,
|
||||
};
|
||||
|
||||
/// Neznámý výsledek z novější verze appky = null (řádek se nezobrazí,
|
||||
/// ale zpráva se načte).
|
||||
static CallLog? fromJson(Object? json) {
|
||||
if (json is! Map) return null;
|
||||
final outcome =
|
||||
CallOutcome.values.where((o) => o.name == json['o']).firstOrNull;
|
||||
if (outcome == null) return null;
|
||||
return CallLog(
|
||||
outcome: outcome,
|
||||
outgoing: json['out'] == true,
|
||||
video: json['vid'] == true,
|
||||
durationSeconds: (json['dur'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:sepot/models/avatar_ref.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
|
||||
/// Kontakt — osoba, se kterou můžeme chatovat
|
||||
@@ -14,6 +15,9 @@ class Contact {
|
||||
/// nezobrazí) a odesílání je v UI zablokované.
|
||||
bool blocked;
|
||||
|
||||
/// Profilová fotka, kterou kontakt sdílí (null = iniciály).
|
||||
AvatarRef? avatar;
|
||||
|
||||
Contact({
|
||||
required this.displayName,
|
||||
required this.publicIdentity,
|
||||
@@ -21,6 +25,7 @@ class Contact {
|
||||
this.verified = false,
|
||||
this.notes,
|
||||
this.blocked = false,
|
||||
this.avatar,
|
||||
});
|
||||
|
||||
/// ID kontaktu = fingerprint identity klíče (identita = klíč).
|
||||
@@ -58,6 +63,7 @@ class Contact {
|
||||
'verified': verified,
|
||||
'notes': notes,
|
||||
'blocked': blocked,
|
||||
'avatar': avatar?.toJson(),
|
||||
};
|
||||
|
||||
factory Contact.fromJson(Map<String, dynamic> json) {
|
||||
@@ -70,6 +76,7 @@ class Contact {
|
||||
verified: json['verified'] as bool? ?? false,
|
||||
notes: json['notes'] as String?,
|
||||
blocked: json['blocked'] as bool? ?? false,
|
||||
avatar: AvatarRef.fromJson(json['avatar']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,11 @@ class Conversation {
|
||||
DateTime lastActivity;
|
||||
int? disappearAfterSeconds; // Globální nastavení mizících zpráv
|
||||
|
||||
/// Ztlumení a archivace — čistě LOKÁLNÍ (neopouští zařízení).
|
||||
/// Ztlumení, archivace a připnutí — čistě LOKÁLNÍ (neopouští zařízení).
|
||||
bool muted;
|
||||
DateTime? mutedUntil; // null + muted = navždy; jinak dočasně
|
||||
bool archived;
|
||||
bool pinned; // připnutý nahoře v seznamu chatů (lokální)
|
||||
|
||||
Conversation({
|
||||
required this.contactId,
|
||||
@@ -22,6 +23,7 @@ class Conversation {
|
||||
this.muted = false,
|
||||
this.mutedUntil,
|
||||
this.archived = false,
|
||||
this.pinned = false,
|
||||
}) : messages = messages ?? [],
|
||||
lastActivity = lastActivity ?? DateTime.now();
|
||||
|
||||
@@ -39,10 +41,16 @@ class Conversation {
|
||||
.length;
|
||||
}
|
||||
|
||||
/// Přidej zprávu
|
||||
void addMessage(Message message) {
|
||||
/// Přidej zprávu. Vrací false, když už zpráva s tímhle id v konverzaci je.
|
||||
///
|
||||
/// Id zprávy nese odesílatel (pole `id` na drátě), takže opakované doručení
|
||||
/// téhož obsahu — retry odesílatele po výpadku spojení nebo redelivery
|
||||
/// z fronty relaye — nesmí založit druhou bublinu.
|
||||
bool addMessage(Message message) {
|
||||
if (messages.any((m) => m.id == message.id)) return false;
|
||||
messages.add(message);
|
||||
lastActivity = DateTime.now();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Nahraď zprávu se stejným id (změna stavu přílohy, doručenka…).
|
||||
@@ -69,6 +77,7 @@ class Conversation {
|
||||
'muted': muted,
|
||||
'mutedUntil': mutedUntil?.toIso8601String(),
|
||||
'archived': archived,
|
||||
'pinned': pinned,
|
||||
};
|
||||
|
||||
factory Conversation.fromJson(Map<String, dynamic> json) {
|
||||
@@ -86,6 +95,7 @@ class Conversation {
|
||||
? null
|
||||
: DateTime.parse(json['mutedUntil'] as String),
|
||||
archived: json['archived'] as bool? ?? false,
|
||||
pinned: json['pinned'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+125
-7
@@ -1,8 +1,11 @@
|
||||
/// Skupina — párový fanout MVP.
|
||||
/// Skupina — párový fanout + správci.
|
||||
///
|
||||
/// Drží jen jméno a fingerprinty členů; routing a identity klíče členů
|
||||
/// žijí v tabulce contacts (člen = běžný kontakt založený z rosteru).
|
||||
/// Konverzace skupiny je řádek v conversations s contact_id = [id].
|
||||
///
|
||||
/// Ukládá se jako JSON blob (`groups.json`), takže nová pole v2 nepotřebují
|
||||
/// migraci schématu — chybějící klíče doplní [Group.fromJson] lokálně.
|
||||
class Group {
|
||||
Group({
|
||||
required this.id,
|
||||
@@ -11,7 +14,19 @@ class Group {
|
||||
required this.createdBy,
|
||||
DateTime? createdAt,
|
||||
this.left = false,
|
||||
}) : createdAt = createdAt ?? DateTime.now();
|
||||
List<String>? admins,
|
||||
this.epoch = 0,
|
||||
Set<String>? v2Members,
|
||||
this.needsResync = false,
|
||||
List<PendingGroupOp>? pendingOps,
|
||||
this.rejectedOpAt,
|
||||
this.provisional = false,
|
||||
}) : createdAt = createdAt ?? DateTime.now(),
|
||||
admins = admins ?? [createdBy],
|
||||
v2Members = v2Members ?? <String>{},
|
||||
pendingOps = pendingOps ?? <PendingGroupOp>[] {
|
||||
normalizeAdmins();
|
||||
}
|
||||
|
||||
/// UUID v4 skupiny (= `gid` na drátu, = contact_id konverzace).
|
||||
final String id;
|
||||
@@ -26,15 +41,76 @@ class Group {
|
||||
|
||||
final DateTime createdAt;
|
||||
|
||||
/// Opustil jsem skupinu — historie zůstává, vstup je mrtvý a příchozí
|
||||
/// zprávy skupiny se zahazují.
|
||||
/// Opustil jsem skupinu (nebo mě správce odebral) — historie zůstává,
|
||||
/// vstup je mrtvý a příchozí zprávy skupiny se zahazují.
|
||||
bool left;
|
||||
|
||||
/// Správci — podmnožina [memberIds], u živé skupiny nikdy prázdná.
|
||||
List<String> admins;
|
||||
|
||||
/// Epocha posledního autorizovaného stavu (0 = založení / migrace z v1).
|
||||
int epoch;
|
||||
|
||||
/// Členové, kteří inzerovali podporu správců (`gv: 2`). Jen přibývá:
|
||||
/// kdyby šlo capability odebrat, mohl by kdokoli skupinu vrátit do
|
||||
/// kompatibilního režimu a obejít správce.
|
||||
Set<String> v2Members;
|
||||
|
||||
/// Přišla operace, kterou nešlo navázat (mezera v epoše / rozchod
|
||||
/// historie) — čeká se na stav od správce.
|
||||
bool needsResync;
|
||||
|
||||
/// Podržené operace z budoucích epoch; zkusí se znovu po každém posunu.
|
||||
List<PendingGroupOp> pendingOps;
|
||||
|
||||
/// Kdy naposledy přišla změna skupiny od někoho, kdo k ní nemá právo.
|
||||
DateTime? rejectedOpAt;
|
||||
|
||||
/// Správce jen odhadnutý, ne ověřený: skupina převzatá z verze bez
|
||||
/// správců nebo pozvánka od v1 klienta. `createdBy` tu znamená „od koho
|
||||
/// jsem pozvánku dostal“, ne skutečného zakladatele — člen přidaný
|
||||
/// ne-zakladatelem by jinak natrvalo odmítal pravého správce. První
|
||||
/// podepsaný stav od člena, který je v něm správcem, se proto přijme
|
||||
/// jako kotva (důvěra stejná jako u pozvánky samotné).
|
||||
bool provisional;
|
||||
|
||||
/// Strop členů (doporučení: 128).
|
||||
static const maxMembers = 128;
|
||||
|
||||
/// Víc podržených operací nedává smysl — mezeru stejně zacelí stav
|
||||
/// od správce; strop brání nafouknutí záznamu podvrženými zprávami.
|
||||
static const maxPendingOps = 16;
|
||||
|
||||
bool isMember(String fingerprint) => memberIds.contains(fingerprint);
|
||||
|
||||
bool isAdmin(String fingerprint) => admins.contains(fingerprint);
|
||||
|
||||
/// Zakladatel si správce neodhaduje — `createdBy == me` je jistota.
|
||||
bool isProvisionalFor(String me) => provisional && createdBy != me;
|
||||
|
||||
/// Vynucená skupina: všichni členové kromě [me] inzerují v2, takže
|
||||
/// nikdo nepotřebuje legacy `ginv`/`gmeta` od ne-správce. Dokud je ve
|
||||
/// skupině byť jediný v1 klient, řídí se starým pravidlem „kterýkoli
|
||||
/// člen smí měnit roster" a v2 ho musí tolerovat, jinak by se stav rozešel.
|
||||
bool isEnforced(String me) =>
|
||||
memberIds.every((f) => f == me || v2Members.contains(f));
|
||||
|
||||
/// Drží invariant `admins ⊆ members`, neprázdné. Když správce zmizí
|
||||
/// mimo podepsaný řetěz (v1 `gleave`, migrace skupiny, kterou zakladatel
|
||||
/// už opustil), nastoupí deterministicky nejmenší fingerprint — každý
|
||||
/// klient tak dojde ke stejnému výsledku bez síťové domluvy.
|
||||
void normalizeAdmins() {
|
||||
final kept = {
|
||||
for (final a in admins)
|
||||
if (memberIds.contains(a)) a,
|
||||
}.toList()
|
||||
..sort();
|
||||
if (kept.isEmpty && memberIds.isNotEmpty) {
|
||||
kept.add(([...memberIds]..sort()).first);
|
||||
}
|
||||
admins = kept;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
@@ -42,15 +118,57 @@ class Group {
|
||||
'createdBy': createdBy,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'left': left,
|
||||
'admins': admins,
|
||||
'epoch': epoch,
|
||||
'v2': v2Members.toList()..sort(),
|
||||
if (needsResync) 'resync': true,
|
||||
if (pendingOps.isNotEmpty)
|
||||
'pending': [for (final p in pendingOps) p.toJson()],
|
||||
'rejectedAt': ?rejectedOpAt?.toIso8601String(),
|
||||
if (provisional) 'prov': true,
|
||||
};
|
||||
|
||||
factory Group.fromJson(Map<String, dynamic> json) => Group(
|
||||
/// Skupina uložená verzí bez správců nemá `admins`/`epoch` — dopočítá se
|
||||
/// `admins = [createdBy]`, `epoch = 0`.
|
||||
/// Migrovaná skupina je [provisional] — viz tam proč.
|
||||
factory Group.fromJson(Map<String, dynamic> json) {
|
||||
final rejected = json['rejectedAt'] as String?;
|
||||
final migrated = !json.containsKey('admins');
|
||||
return Group(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
memberIds:
|
||||
(json['memberIds'] as List<dynamic>).cast<String>().toList(),
|
||||
memberIds: (json['memberIds'] as List<dynamic>).cast<String>().toList(),
|
||||
createdBy: json['createdBy'] as String,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
left: json['left'] as bool? ?? false,
|
||||
admins: (json['admins'] as List<dynamic>?)?.cast<String>().toList(),
|
||||
epoch: json['epoch'] as int? ?? 0,
|
||||
v2Members: (json['v2'] as List<dynamic>?)?.cast<String>().toSet(),
|
||||
needsResync: json['resync'] as bool? ?? false,
|
||||
pendingOps: [
|
||||
for (final p in (json['pending'] as List<dynamic>? ?? const []))
|
||||
PendingGroupOp.fromJson((p as Map).cast<String, dynamic>()),
|
||||
],
|
||||
rejectedOpAt: rejected == null ? null : DateTime.parse(rejected),
|
||||
provisional: migrated || (json['prov'] as bool? ?? false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Podržená správcovská operace: drátová podoba + sealed-sender autor, aby
|
||||
/// šla později ověřit úplně stejně, jako kdyby právě přišla.
|
||||
class PendingGroupOp {
|
||||
const PendingGroupOp({required this.senderFp, required this.wire});
|
||||
|
||||
final String senderFp;
|
||||
final Map<String, dynamic> wire;
|
||||
|
||||
int get epoch => wire['epoch'] as int? ?? 0;
|
||||
|
||||
Map<String, dynamic> toJson() => {'s': senderFp, 'op': wire};
|
||||
|
||||
factory PendingGroupOp.fromJson(Map<String, dynamic> json) => PendingGroupOp(
|
||||
senderFp: json['s'] as String,
|
||||
wire: (json['op'] as Map).cast<String, dynamic>(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -146,6 +146,9 @@ class LocalIdentity {
|
||||
/// kontaktu poslat první zpráva. Obě pole jsou náhodná — s identitou je
|
||||
/// spojuje jen sám uživatel tím, že je sdílí ve svém odkazu.
|
||||
class PublicIdentity {
|
||||
/// Nejvyšší délka jména z odkazu/QR — delší odkaz je vadný vstup.
|
||||
static const maxDisplayNameLength = 64;
|
||||
|
||||
PublicIdentity({
|
||||
required this.displayName,
|
||||
required this.identityKey,
|
||||
@@ -214,7 +217,14 @@ class PublicIdentity {
|
||||
as Map<String, dynamic>;
|
||||
if (map['v'] != 2) return null;
|
||||
final identityKey = base64Decode(map['ik'] as String);
|
||||
if (identityKey.length != 33) return null;
|
||||
// 33 B = typový bajt 0x05 + Curve25519; jiný prefix libsignal odmítne
|
||||
// až při první zprávě, tady ho odmítneme hned.
|
||||
if (identityKey.length != 33 || identityKey[0] != 0x05) return null;
|
||||
final aid = map['aid'] as String?;
|
||||
// Relay dává 32 hex znaků; starší testovací odkazy kratší — hex a strop stačí.
|
||||
if (aid != null && !RegExp(r'^[0-9a-f]{8,64}$').hasMatch(aid)) return null;
|
||||
final rawName = (map['n'] as String).trim();
|
||||
if (rawName.length > maxDisplayNameLength) return null;
|
||||
final da = map['da'] as String?;
|
||||
Uint8List? deliveryAddress;
|
||||
if (da != null) {
|
||||
@@ -224,10 +234,10 @@ class PublicIdentity {
|
||||
if (deliveryAddress.length != 32) return null;
|
||||
}
|
||||
return PublicIdentity(
|
||||
displayName: map['n'] as String,
|
||||
displayName: rawName,
|
||||
identityKey: identityKey,
|
||||
createdAt: DateTime.parse(map['ts'] as String),
|
||||
accountId: map['aid'] as String?,
|
||||
accountId: aid,
|
||||
deliveryAddress: deliveryAddress,
|
||||
);
|
||||
} catch (_) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'package:sepot/models/attachment.dart';
|
||||
import 'package:sepot/models/call_log.dart';
|
||||
import 'package:sepot/models/group.dart';
|
||||
import 'package:sepot/models/poll.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Typy zpráv — odpovídá Rust: MessageType
|
||||
@@ -11,8 +14,8 @@ enum MessageType {
|
||||
keyExchange,
|
||||
}
|
||||
|
||||
/// Stav odeslané (mojí) zprávy — poctivá hranice: read receipts zatím
|
||||
/// NEjsou (design: default OFF), takže „přečteno" se nezobrazuje.
|
||||
/// Stav odeslané (mojí) zprávy. Potvrzení přečtení je volitelné (default
|
||||
/// vypnuto) a reciproční — kdo ho nesdílí, „přečteno" neuvidí ani sám.
|
||||
/// sending — lokálně zařazeno, čeká na potvrzení relaye (⏱)
|
||||
/// sent — relay přijal, příjemce byl offline → v jeho frontě (✓)
|
||||
/// delivered — doručeno na zařízení příjemce (✓✓)
|
||||
@@ -43,6 +46,21 @@ class Message {
|
||||
final bool isDeleted; // smazáno pro všechny (tombstone)
|
||||
/// Reakce: fingerprint odesílatele reakce → emoji. Prázdné = žádné.
|
||||
final Map<String, String> reactions;
|
||||
/// Kdy mou zprávu dostal / přečetl který příjemce (fingerprint → čas).
|
||||
/// 1:1 má nejvýš jeden záznam, skupina jeden na člena — z toho se
|
||||
/// skládá „Info o zprávě". Čas je z hodin příjemce (pole `ts` doručenky).
|
||||
final Map<String, DateTime> deliveredTo;
|
||||
final Map<String, DateTime> readBy;
|
||||
|
||||
/// Anketa (textová zpráva, `content` nese záložní text pro starší appky).
|
||||
final Poll? poll;
|
||||
|
||||
/// Fingerprinty lidí zmíněných přes @ ve skupině — zmínka projde i přes
|
||||
/// ztlumení skupiny.
|
||||
final List<String> mentions;
|
||||
|
||||
/// Lokální záznam hovoru — taková zpráva nemá text ani přílohu.
|
||||
final CallLog? callLog;
|
||||
|
||||
const Message({
|
||||
required this.id,
|
||||
@@ -62,6 +80,11 @@ class Message {
|
||||
this.editedAt,
|
||||
this.isDeleted = false,
|
||||
this.reactions = const {},
|
||||
this.deliveredTo = const {},
|
||||
this.readBy = const {},
|
||||
this.poll,
|
||||
this.mentions = const [],
|
||||
this.callLog,
|
||||
});
|
||||
|
||||
/// Vytvoř textovou zprávu
|
||||
@@ -72,6 +95,8 @@ class Message {
|
||||
String? replyToId,
|
||||
int? disappearAfterSeconds,
|
||||
MessageStatus status = MessageStatus.sending,
|
||||
Poll? poll,
|
||||
List<String> mentions = const [],
|
||||
}) {
|
||||
return Message(
|
||||
id: const Uuid().v4(),
|
||||
@@ -83,6 +108,8 @@ class Message {
|
||||
timestamp: DateTime.now(),
|
||||
status: status,
|
||||
disappearAfterSeconds: disappearAfterSeconds,
|
||||
poll: poll,
|
||||
mentions: mentions,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,6 +164,9 @@ class Message {
|
||||
DateTime? editedAt,
|
||||
bool? isDeleted,
|
||||
Map<String, String>? reactions,
|
||||
Map<String, DateTime>? deliveredTo,
|
||||
Map<String, DateTime>? readBy,
|
||||
Poll? poll,
|
||||
}) {
|
||||
return Message(
|
||||
id: id,
|
||||
@@ -156,9 +186,22 @@ class Message {
|
||||
editedAt: editedAt ?? this.editedAt,
|
||||
isDeleted: isDeleted ?? this.isDeleted,
|
||||
reactions: reactions ?? this.reactions,
|
||||
deliveredTo: deliveredTo ?? this.deliveredTo,
|
||||
readBy: readBy ?? this.readBy,
|
||||
poll: poll ?? this.poll,
|
||||
mentions: mentions,
|
||||
callLog: callLog,
|
||||
);
|
||||
}
|
||||
|
||||
/// Nejdřívější doručení / přečtení napříč příjemci (null = zatím nic).
|
||||
DateTime? get firstDeliveredAt => _earliest(deliveredTo.values);
|
||||
DateTime? get firstReadAt => _earliest(readBy.values);
|
||||
|
||||
static DateTime? _earliest(Iterable<DateTime> times) => times.isEmpty
|
||||
? null
|
||||
: times.reduce((a, b) => a.isBefore(b) ? a : b);
|
||||
|
||||
/// Má zpráva mizet?
|
||||
bool get isDisappearing => disappearAfterSeconds != null;
|
||||
|
||||
@@ -183,8 +226,27 @@ class Message {
|
||||
'editedAt': editedAt?.toIso8601String(),
|
||||
'isDeleted': isDeleted,
|
||||
'reactions': reactions,
|
||||
'deliveredTo': _timesToJson(deliveredTo),
|
||||
'readBy': _timesToJson(readBy),
|
||||
'poll': poll?.toJson(),
|
||||
if (mentions.isNotEmpty) 'mentions': mentions,
|
||||
if (callLog != null) 'call': callLog!.toJson(),
|
||||
};
|
||||
|
||||
static Map<String, int> _timesToJson(Map<String, DateTime> times) =>
|
||||
times.map((k, v) => MapEntry(k, v.millisecondsSinceEpoch));
|
||||
|
||||
/// Tolerantní čtení: starší záznamy pole nemají, vadné položky se
|
||||
/// přeskočí — kvůli nim nesmí spadnout načtení celé konverzace.
|
||||
static Map<String, DateTime> _timesFromJson(Object? raw) {
|
||||
if (raw is! Map) return const {};
|
||||
return {
|
||||
for (final e in raw.entries)
|
||||
if (e.key is String && e.value is int)
|
||||
e.key as String: DateTime.fromMillisecondsSinceEpoch(e.value as int),
|
||||
};
|
||||
}
|
||||
|
||||
factory Message.fromJson(Map<String, dynamic> json) {
|
||||
return Message(
|
||||
id: json['id'] as String,
|
||||
@@ -215,6 +277,18 @@ class Message {
|
||||
reactions: (json['reactions'] as Map<String, dynamic>?)
|
||||
?.map((k, v) => MapEntry(k, v as String)) ??
|
||||
const {},
|
||||
deliveredTo: _timesFromJson(json['deliveredTo']),
|
||||
readBy: _timesFromJson(json['readBy']),
|
||||
poll: Poll.fromJson(json['poll']),
|
||||
mentions: mentionsFromWire(json['mentions']),
|
||||
callLog: CallLog.fromJson(json['call']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Zmínky z drátu i z úložiště: jen řetězce a rozumný počet — obálka od
|
||||
/// cizího klienta nesmí rozbít zprávu ani nafouknout úložiště.
|
||||
List<String> mentionsFromWire(Object? raw) {
|
||||
if (raw is! List) return const [];
|
||||
return raw.whereType<String>().take(Group.maxMembers).toList();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/// Anketa uvnitř zprávy.
|
||||
///
|
||||
/// Na drátě jede jako běžná textová zpráva s polem `poll` — starší verze
|
||||
/// appky pole neznají a ukážou záložní text (otázku a možnosti), nové
|
||||
/// z něj vykreslí anketu. Hlasy jsou samostatné řídicí zprávy `vote`;
|
||||
/// klíč hlasu je otisk odesílatele ověřený sealed senderem, nikdy údaj
|
||||
/// z obsahu.
|
||||
class Poll {
|
||||
const Poll({
|
||||
required this.question,
|
||||
required this.options,
|
||||
this.multi = false,
|
||||
this.votes = const {},
|
||||
});
|
||||
|
||||
static const minOptions = 2;
|
||||
static const maxOptions = 12;
|
||||
static const maxQuestionLength = 200;
|
||||
static const maxOptionLength = 100;
|
||||
|
||||
final String question;
|
||||
final List<String> options;
|
||||
|
||||
/// Smí jeden člověk zvolit víc možností.
|
||||
final bool multi;
|
||||
|
||||
/// Otisk hlasujícího → zvolené indexy možností.
|
||||
final Map<String, List<int>> votes;
|
||||
|
||||
/// Kolik lidí zvolilo možnost [index].
|
||||
int countFor(int index) =>
|
||||
votes.values.where((choice) => choice.contains(index)).length;
|
||||
|
||||
/// Otisky lidí, kteří zvolili možnost [index].
|
||||
List<String> votersFor(int index) => [
|
||||
for (final e in votes.entries)
|
||||
if (e.value.contains(index)) e.key,
|
||||
];
|
||||
|
||||
int get voterCount => votes.length;
|
||||
|
||||
/// Nový hlas [voter]. Neplatné indexy se zahodí, duplicity sloučí;
|
||||
/// u ankety s jednou volbou platí jen první. Prázdná volba = hlas zrušen.
|
||||
Poll withVote(String voter, List<int> choice) {
|
||||
final clean = <int>[
|
||||
for (final i in choice.toSet())
|
||||
if (i >= 0 && i < options.length) i,
|
||||
]..sort();
|
||||
final picked = multi ? clean : clean.take(1).toList();
|
||||
final next = Map<String, List<int>>.from(votes);
|
||||
if (picked.isEmpty) {
|
||||
next.remove(voter);
|
||||
} else {
|
||||
next[voter] = picked;
|
||||
}
|
||||
return Poll(question: question, options: options, multi: multi, votes: next);
|
||||
}
|
||||
|
||||
/// Co jde na drát (bez hlasů — ty cestují zvlášť).
|
||||
Map<String, dynamic> toWire() => {
|
||||
'q': question,
|
||||
'o': options,
|
||||
if (multi) 'm': true,
|
||||
};
|
||||
|
||||
/// Anketa od protistrany. Cokoli mimo limity = null (zpráva se pak
|
||||
/// ukáže jako obyčejný text), aby cizí data nemohla rozbít vykreslení.
|
||||
static Poll? fromWire(Object? raw) {
|
||||
if (raw is! Map) return null;
|
||||
final q = raw['q'];
|
||||
final o = raw['o'];
|
||||
if (q is! String || o is! List) return null;
|
||||
final question = q.trim();
|
||||
if (question.isEmpty || question.length > maxQuestionLength) return null;
|
||||
final options = [
|
||||
for (final item in o)
|
||||
if (item is String) item.trim(),
|
||||
];
|
||||
if (options.length != o.length ||
|
||||
options.length < minOptions ||
|
||||
options.length > maxOptions ||
|
||||
options.any((x) => x.isEmpty || x.length > maxOptionLength)) {
|
||||
return null;
|
||||
}
|
||||
return Poll(question: question, options: options, multi: raw['m'] == true);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
...toWire(),
|
||||
'votes': votes,
|
||||
};
|
||||
|
||||
static Poll? fromJson(Object? raw) {
|
||||
final poll = fromWire(raw);
|
||||
if (poll == null || raw is! Map) return poll;
|
||||
final stored = raw['votes'];
|
||||
var result = poll;
|
||||
if (stored is Map) {
|
||||
for (final e in stored.entries) {
|
||||
final choice = e.value;
|
||||
if (e.key is String && choice is List) {
|
||||
result = result.withVote(
|
||||
e.key as String, choice.whereType<int>().toList());
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+179
-2
@@ -650,10 +650,12 @@ class RegisterPush extends $pb.GeneratedMessage {
|
||||
factory RegisterPush({
|
||||
RegisterPush_Platform? platform,
|
||||
$core.List<$core.int>? endpoint,
|
||||
$core.List<$core.int>? voipToken,
|
||||
}) {
|
||||
final result = create();
|
||||
if (platform != null) result.platform = platform;
|
||||
if (endpoint != null) result.endpoint = endpoint;
|
||||
if (voipToken != null) result.voipToken = voipToken;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -674,6 +676,8 @@ class RegisterPush extends $pb.GeneratedMessage {
|
||||
enumValues: RegisterPush_Platform.values)
|
||||
..a<$core.List<$core.int>>(
|
||||
2, _omitFieldNames ? '' : 'endpoint', $pb.PbFieldType.OY)
|
||||
..a<$core.List<$core.int>>(
|
||||
3, _omitFieldNames ? '' : 'voipToken', $pb.PbFieldType.OY)
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
@@ -714,6 +718,141 @@ class RegisterPush extends $pb.GeneratedMessage {
|
||||
$core.bool hasEndpoint() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearEndpoint() => $_clearField(2);
|
||||
|
||||
/// iOS: PushKit VoIP token pro příchozí hovory (vedle běžného APNs tokenu
|
||||
/// v `endpoint`). Prázdné = zařízení VoIP push nemá.
|
||||
@$pb.TagNumber(3)
|
||||
$core.List<$core.int> get voipToken => $_getN(2);
|
||||
@$pb.TagNumber(3)
|
||||
set voipToken($core.List<$core.int> value) => $_setBytes(2, value);
|
||||
@$pb.TagNumber(3)
|
||||
$core.bool hasVoipToken() => $_has(2);
|
||||
@$pb.TagNumber(3)
|
||||
void clearVoipToken() => $_clearField(3);
|
||||
}
|
||||
|
||||
/// Dočasné údaje k TURN serveru pro hovory (REST schéma coturnu). Vydává je
|
||||
/// relay jen přihlášenému zařízení, aby TURN nebyl otevřený pro kohokoli.
|
||||
class TurnRequest extends $pb.GeneratedMessage {
|
||||
factory TurnRequest() => create();
|
||||
|
||||
TurnRequest._();
|
||||
|
||||
factory TurnRequest.fromBuffer($core.List<$core.int> data,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromBuffer(data, registry);
|
||||
factory TurnRequest.fromJson($core.String json,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromJson(json, registry);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||
_omitMessageNames ? '' : 'TurnRequest',
|
||||
package: const $pb.PackageName(_omitMessageNames ? '' : 'sepot.v1'),
|
||||
createEmptyInstance: create)
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
TurnRequest clone() => deepCopy();
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
TurnRequest copyWith(void Function(TurnRequest) updates) =>
|
||||
super.copyWith((message) => updates(message as TurnRequest))
|
||||
as TurnRequest;
|
||||
|
||||
@$core.override
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static TurnRequest create() => TurnRequest._();
|
||||
@$core.override
|
||||
TurnRequest createEmptyInstance() => create();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static TurnRequest getDefault() => _defaultInstance ??=
|
||||
$pb.GeneratedMessage.$_defaultFor<TurnRequest>(create);
|
||||
static TurnRequest? _defaultInstance;
|
||||
}
|
||||
|
||||
class TurnCredentials extends $pb.GeneratedMessage {
|
||||
factory TurnCredentials({
|
||||
$core.Iterable<$core.String>? urls,
|
||||
$core.String? username,
|
||||
$core.String? credential,
|
||||
$core.int? ttlSeconds,
|
||||
}) {
|
||||
final result = create();
|
||||
if (urls != null) result.urls.addAll(urls);
|
||||
if (username != null) result.username = username;
|
||||
if (credential != null) result.credential = credential;
|
||||
if (ttlSeconds != null) result.ttlSeconds = ttlSeconds;
|
||||
return result;
|
||||
}
|
||||
|
||||
TurnCredentials._();
|
||||
|
||||
factory TurnCredentials.fromBuffer($core.List<$core.int> data,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromBuffer(data, registry);
|
||||
factory TurnCredentials.fromJson($core.String json,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromJson(json, registry);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||
_omitMessageNames ? '' : 'TurnCredentials',
|
||||
package: const $pb.PackageName(_omitMessageNames ? '' : 'sepot.v1'),
|
||||
createEmptyInstance: create)
|
||||
..pPS(1, _omitFieldNames ? '' : 'urls')
|
||||
..aOS(2, _omitFieldNames ? '' : 'username')
|
||||
..aOS(3, _omitFieldNames ? '' : 'credential')
|
||||
..aI(4, _omitFieldNames ? '' : 'ttlSeconds', fieldType: $pb.PbFieldType.OU3)
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
TurnCredentials clone() => deepCopy();
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
TurnCredentials copyWith(void Function(TurnCredentials) updates) =>
|
||||
super.copyWith((message) => updates(message as TurnCredentials))
|
||||
as TurnCredentials;
|
||||
|
||||
@$core.override
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static TurnCredentials create() => TurnCredentials._();
|
||||
@$core.override
|
||||
TurnCredentials createEmptyInstance() => create();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static TurnCredentials getDefault() => _defaultInstance ??=
|
||||
$pb.GeneratedMessage.$_defaultFor<TurnCredentials>(create);
|
||||
static TurnCredentials? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$pb.PbList<$core.String> get urls => $_getList(0);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.String get username => $_getSZ(1);
|
||||
@$pb.TagNumber(2)
|
||||
set username($core.String value) => $_setString(1, value);
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasUsername() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearUsername() => $_clearField(2);
|
||||
|
||||
@$pb.TagNumber(3)
|
||||
$core.String get credential => $_getSZ(2);
|
||||
@$pb.TagNumber(3)
|
||||
set credential($core.String value) => $_setString(2, value);
|
||||
@$pb.TagNumber(3)
|
||||
$core.bool hasCredential() => $_has(2);
|
||||
@$pb.TagNumber(3)
|
||||
void clearCredential() => $_clearField(3);
|
||||
|
||||
@$pb.TagNumber(4)
|
||||
$core.int get ttlSeconds => $_getIZ(3);
|
||||
@$pb.TagNumber(4)
|
||||
set ttlSeconds($core.int value) => $_setUnsignedInt32(3, value);
|
||||
@$pb.TagNumber(4)
|
||||
$core.bool hasTtlSeconds() => $_has(3);
|
||||
@$pb.TagNumber(4)
|
||||
void clearTtlSeconds() => $_clearField(4);
|
||||
}
|
||||
|
||||
class SignedPreKey extends $pb.GeneratedMessage {
|
||||
@@ -1137,6 +1276,7 @@ enum ClientFrame_Frame {
|
||||
ack,
|
||||
pong,
|
||||
registerPush,
|
||||
turnRequest,
|
||||
notSet
|
||||
}
|
||||
|
||||
@@ -1148,6 +1288,7 @@ class ClientFrame extends $pb.GeneratedMessage {
|
||||
Ack? ack,
|
||||
Pong? pong,
|
||||
RegisterPush? registerPush,
|
||||
TurnRequest? turnRequest,
|
||||
}) {
|
||||
final result = create();
|
||||
if (auth != null) result.auth = auth;
|
||||
@@ -1156,6 +1297,7 @@ class ClientFrame extends $pb.GeneratedMessage {
|
||||
if (ack != null) result.ack = ack;
|
||||
if (pong != null) result.pong = pong;
|
||||
if (registerPush != null) result.registerPush = registerPush;
|
||||
if (turnRequest != null) result.turnRequest = turnRequest;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1176,13 +1318,14 @@ class ClientFrame extends $pb.GeneratedMessage {
|
||||
4: ClientFrame_Frame.ack,
|
||||
5: ClientFrame_Frame.pong,
|
||||
6: ClientFrame_Frame.registerPush,
|
||||
7: ClientFrame_Frame.turnRequest,
|
||||
0: ClientFrame_Frame.notSet
|
||||
};
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||
_omitMessageNames ? '' : 'ClientFrame',
|
||||
package: const $pb.PackageName(_omitMessageNames ? '' : 'sepot.v1'),
|
||||
createEmptyInstance: create)
|
||||
..oo(0, [1, 2, 3, 4, 5, 6])
|
||||
..oo(0, [1, 2, 3, 4, 5, 6, 7])
|
||||
..aOM<Auth>(1, _omitFieldNames ? '' : 'auth', subBuilder: Auth.create)
|
||||
..aOM<AuthProof>(2, _omitFieldNames ? '' : 'authProof',
|
||||
subBuilder: AuthProof.create)
|
||||
@@ -1192,6 +1335,8 @@ class ClientFrame extends $pb.GeneratedMessage {
|
||||
..aOM<Pong>(5, _omitFieldNames ? '' : 'pong', subBuilder: Pong.create)
|
||||
..aOM<RegisterPush>(6, _omitFieldNames ? '' : 'registerPush',
|
||||
subBuilder: RegisterPush.create)
|
||||
..aOM<TurnRequest>(7, _omitFieldNames ? '' : 'turnRequest',
|
||||
subBuilder: TurnRequest.create)
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
@@ -1219,6 +1364,7 @@ class ClientFrame extends $pb.GeneratedMessage {
|
||||
@$pb.TagNumber(4)
|
||||
@$pb.TagNumber(5)
|
||||
@$pb.TagNumber(6)
|
||||
@$pb.TagNumber(7)
|
||||
ClientFrame_Frame whichFrame() => _ClientFrame_FrameByTag[$_whichOneof(0)]!;
|
||||
@$pb.TagNumber(1)
|
||||
@$pb.TagNumber(2)
|
||||
@@ -1226,6 +1372,7 @@ class ClientFrame extends $pb.GeneratedMessage {
|
||||
@$pb.TagNumber(4)
|
||||
@$pb.TagNumber(5)
|
||||
@$pb.TagNumber(6)
|
||||
@$pb.TagNumber(7)
|
||||
void clearFrame() => $_clearField($_whichOneof(0));
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
@@ -1293,6 +1440,17 @@ class ClientFrame extends $pb.GeneratedMessage {
|
||||
void clearRegisterPush() => $_clearField(6);
|
||||
@$pb.TagNumber(6)
|
||||
RegisterPush ensureRegisterPush() => $_ensure(5);
|
||||
|
||||
@$pb.TagNumber(7)
|
||||
TurnRequest get turnRequest => $_getN(6);
|
||||
@$pb.TagNumber(7)
|
||||
set turnRequest(TurnRequest value) => $_setField(7, value);
|
||||
@$pb.TagNumber(7)
|
||||
$core.bool hasTurnRequest() => $_has(6);
|
||||
@$pb.TagNumber(7)
|
||||
void clearTurnRequest() => $_clearField(7);
|
||||
@$pb.TagNumber(7)
|
||||
TurnRequest ensureTurnRequest() => $_ensure(6);
|
||||
}
|
||||
|
||||
enum ServerFrame_Frame {
|
||||
@@ -1303,6 +1461,7 @@ enum ServerFrame_Frame {
|
||||
ping,
|
||||
error,
|
||||
authOk,
|
||||
turnCredentials,
|
||||
notSet
|
||||
}
|
||||
|
||||
@@ -1315,6 +1474,7 @@ class ServerFrame extends $pb.GeneratedMessage {
|
||||
Ping? ping,
|
||||
Error? error,
|
||||
AuthOk? authOk,
|
||||
TurnCredentials? turnCredentials,
|
||||
}) {
|
||||
final result = create();
|
||||
if (challenge != null) result.challenge = challenge;
|
||||
@@ -1324,6 +1484,7 @@ class ServerFrame extends $pb.GeneratedMessage {
|
||||
if (ping != null) result.ping = ping;
|
||||
if (error != null) result.error = error;
|
||||
if (authOk != null) result.authOk = authOk;
|
||||
if (turnCredentials != null) result.turnCredentials = turnCredentials;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1345,13 +1506,14 @@ class ServerFrame extends $pb.GeneratedMessage {
|
||||
5: ServerFrame_Frame.ping,
|
||||
6: ServerFrame_Frame.error,
|
||||
7: ServerFrame_Frame.authOk,
|
||||
8: ServerFrame_Frame.turnCredentials,
|
||||
0: ServerFrame_Frame.notSet
|
||||
};
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||
_omitMessageNames ? '' : 'ServerFrame',
|
||||
package: const $pb.PackageName(_omitMessageNames ? '' : 'sepot.v1'),
|
||||
createEmptyInstance: create)
|
||||
..oo(0, [1, 2, 3, 4, 5, 6, 7])
|
||||
..oo(0, [1, 2, 3, 4, 5, 6, 7, 8])
|
||||
..aOM<Challenge>(1, _omitFieldNames ? '' : 'challenge',
|
||||
subBuilder: Challenge.create)
|
||||
..aOM<Receipt>(2, _omitFieldNames ? '' : 'receipt',
|
||||
@@ -1363,6 +1525,8 @@ class ServerFrame extends $pb.GeneratedMessage {
|
||||
..aOM<Ping>(5, _omitFieldNames ? '' : 'ping', subBuilder: Ping.create)
|
||||
..aOM<Error>(6, _omitFieldNames ? '' : 'error', subBuilder: Error.create)
|
||||
..aOM<AuthOk>(7, _omitFieldNames ? '' : 'authOk', subBuilder: AuthOk.create)
|
||||
..aOM<TurnCredentials>(8, _omitFieldNames ? '' : 'turnCredentials',
|
||||
subBuilder: TurnCredentials.create)
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
@@ -1391,6 +1555,7 @@ class ServerFrame extends $pb.GeneratedMessage {
|
||||
@$pb.TagNumber(5)
|
||||
@$pb.TagNumber(6)
|
||||
@$pb.TagNumber(7)
|
||||
@$pb.TagNumber(8)
|
||||
ServerFrame_Frame whichFrame() => _ServerFrame_FrameByTag[$_whichOneof(0)]!;
|
||||
@$pb.TagNumber(1)
|
||||
@$pb.TagNumber(2)
|
||||
@@ -1399,6 +1564,7 @@ class ServerFrame extends $pb.GeneratedMessage {
|
||||
@$pb.TagNumber(5)
|
||||
@$pb.TagNumber(6)
|
||||
@$pb.TagNumber(7)
|
||||
@$pb.TagNumber(8)
|
||||
void clearFrame() => $_clearField($_whichOneof(0));
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
@@ -1477,6 +1643,17 @@ class ServerFrame extends $pb.GeneratedMessage {
|
||||
void clearAuthOk() => $_clearField(7);
|
||||
@$pb.TagNumber(7)
|
||||
AuthOk ensureAuthOk() => $_ensure(6);
|
||||
|
||||
@$pb.TagNumber(8)
|
||||
TurnCredentials get turnCredentials => $_getN(7);
|
||||
@$pb.TagNumber(8)
|
||||
set turnCredentials(TurnCredentials value) => $_setField(8, value);
|
||||
@$pb.TagNumber(8)
|
||||
$core.bool hasTurnCredentials() => $_has(7);
|
||||
@$pb.TagNumber(8)
|
||||
void clearTurnCredentials() => $_clearField(8);
|
||||
@$pb.TagNumber(8)
|
||||
TurnCredentials ensureTurnCredentials() => $_ensure(7);
|
||||
}
|
||||
|
||||
const $core.bool _omitFieldNames =
|
||||
|
||||
@@ -22,14 +22,27 @@ class ContentHint extends $pb.ProtobufEnum {
|
||||
static const ContentHint CONTENT_HINT_SUPPLEMENTARY =
|
||||
ContentHint._(2, _omitEnumNames ? '' : 'CONTENT_HINT_SUPPLEMENTARY');
|
||||
|
||||
/// Jen živé doručení (např. „píše…“): offline příjemci se neukládá do
|
||||
/// fronty ani nebudí pushem — zastaralá by po vyzvednutí jen mátla.
|
||||
static const ContentHint CONTENT_HINT_EPHEMERAL =
|
||||
ContentHint._(3, _omitEnumNames ? '' : 'CONTENT_HINT_EPHEMERAL');
|
||||
|
||||
/// Nabídka hovoru: ukládá se jako běžná zpráva, ale offline příjemce se
|
||||
/// budí VoIP pushem (iOS) nebo FCM s krátkou platností — zvonit má hned.
|
||||
/// Relay se tím dozví, že zařízení někdo volá; kdo, dál neví.
|
||||
static const ContentHint CONTENT_HINT_CALL =
|
||||
ContentHint._(4, _omitEnumNames ? '' : 'CONTENT_HINT_CALL');
|
||||
|
||||
static const $core.List<ContentHint> values = <ContentHint>[
|
||||
CONTENT_HINT_DEFAULT,
|
||||
CONTENT_HINT_RESYNC,
|
||||
CONTENT_HINT_SUPPLEMENTARY,
|
||||
CONTENT_HINT_EPHEMERAL,
|
||||
CONTENT_HINT_CALL,
|
||||
];
|
||||
|
||||
static final $core.List<ContentHint?> _byValue =
|
||||
$pb.ProtobufEnum.$_initByValueList(values, 2);
|
||||
$pb.ProtobufEnum.$_initByValueList(values, 4);
|
||||
static ContentHint? valueOf($core.int value) =>
|
||||
value < 0 || value >= _byValue.length ? null : _byValue[value];
|
||||
|
||||
@@ -43,15 +56,18 @@ class Receipt_Status extends $pb.ProtobufEnum {
|
||||
Receipt_Status._(1, _omitEnumNames ? '' : 'STATUS_DELIVERED_ONLINE');
|
||||
static const Receipt_Status STATUS_STORED_OFFLINE =
|
||||
Receipt_Status._(2, _omitEnumNames ? '' : 'STATUS_STORED_OFFLINE');
|
||||
static const Receipt_Status STATUS_DROPPED =
|
||||
Receipt_Status._(3, _omitEnumNames ? '' : 'STATUS_DROPPED');
|
||||
|
||||
static const $core.List<Receipt_Status> values = <Receipt_Status>[
|
||||
STATUS_QUEUED,
|
||||
STATUS_DELIVERED_ONLINE,
|
||||
STATUS_STORED_OFFLINE,
|
||||
STATUS_DROPPED,
|
||||
];
|
||||
|
||||
static final $core.List<Receipt_Status?> _byValue =
|
||||
$pb.ProtobufEnum.$_initByValueList(values, 2);
|
||||
$pb.ProtobufEnum.$_initByValueList(values, 3);
|
||||
static Receipt_Status? valueOf($core.int value) =>
|
||||
value < 0 || value >= _byValue.length ? null : _byValue[value];
|
||||
|
||||
|
||||
@@ -22,13 +22,16 @@ const ContentHint$json = {
|
||||
{'1': 'CONTENT_HINT_DEFAULT', '2': 0},
|
||||
{'1': 'CONTENT_HINT_RESYNC', '2': 1},
|
||||
{'1': 'CONTENT_HINT_SUPPLEMENTARY', '2': 2},
|
||||
{'1': 'CONTENT_HINT_EPHEMERAL', '2': 3},
|
||||
{'1': 'CONTENT_HINT_CALL', '2': 4},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `ContentHint`. Decode as a `google.protobuf.EnumDescriptorProto`.
|
||||
final $typed_data.Uint8List contentHintDescriptor = $convert.base64Decode(
|
||||
'CgtDb250ZW50SGludBIYChRDT05URU5UX0hJTlRfREVGQVVMVBAAEhcKE0NPTlRFTlRfSElOVF'
|
||||
'9SRVNZTkMQARIeChpDT05URU5UX0hJTlRfU1VQUExFTUVOVEFSWRAC');
|
||||
'9SRVNZTkMQARIeChpDT05URU5UX0hJTlRfU1VQUExFTUVOVEFSWRACEhoKFkNPTlRFTlRfSElO'
|
||||
'VF9FUEhFTUVSQUwQAxIVChFDT05URU5UX0hJTlRfQ0FMTBAE');
|
||||
|
||||
@$core.Deprecated('Use envelopeDescriptor instead')
|
||||
const Envelope$json = {
|
||||
@@ -90,15 +93,16 @@ const Receipt_Status$json = {
|
||||
{'1': 'STATUS_QUEUED', '2': 0},
|
||||
{'1': 'STATUS_DELIVERED_ONLINE', '2': 1},
|
||||
{'1': 'STATUS_STORED_OFFLINE', '2': 2},
|
||||
{'1': 'STATUS_DROPPED', '2': 3},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `Receipt`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List receiptDescriptor = $convert.base64Decode(
|
||||
'CgdSZWNlaXB0EhkKCG1zZ191dWlkGAEgASgMUgdtc2dVdWlkEjAKBnN0YXR1cxgCIAEoDjIYLn'
|
||||
'NlcG90LnYxLlJlY2VpcHQuU3RhdHVzUgZzdGF0dXMiUwoGU3RhdHVzEhEKDVNUQVRVU19RVUVV'
|
||||
'NlcG90LnYxLlJlY2VpcHQuU3RhdHVzUgZzdGF0dXMiZwoGU3RhdHVzEhEKDVNUQVRVU19RVUVV'
|
||||
'RUQQABIbChdTVEFUVVNfREVMSVZFUkVEX09OTElORRABEhkKFVNUQVRVU19TVE9SRURfT0ZGTE'
|
||||
'lORRAC');
|
||||
'lORRACEhIKDlNUQVRVU19EUk9QUEVEEAM=');
|
||||
|
||||
@$core.Deprecated('Use authDescriptor instead')
|
||||
const Auth$json = {
|
||||
@@ -201,6 +205,7 @@ const RegisterPush$json = {
|
||||
'10': 'platform'
|
||||
},
|
||||
{'1': 'endpoint', '3': 2, '4': 1, '5': 12, '10': 'endpoint'},
|
||||
{'1': 'voip_token', '3': 3, '4': 1, '5': 12, '10': 'voipToken'},
|
||||
],
|
||||
'4': [RegisterPush_Platform$json],
|
||||
};
|
||||
@@ -220,9 +225,36 @@ const RegisterPush_Platform$json = {
|
||||
/// Descriptor for `RegisterPush`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List registerPushDescriptor = $convert.base64Decode(
|
||||
'CgxSZWdpc3RlclB1c2gSOwoIcGxhdGZvcm0YASABKA4yHy5zZXBvdC52MS5SZWdpc3RlclB1c2'
|
||||
'guUGxhdGZvcm1SCHBsYXRmb3JtEhoKCGVuZHBvaW50GAIgASgMUghlbmRwb2ludCJyCghQbGF0'
|
||||
'Zm9ybRIRCg1QTEFURk9STV9OT05FEAASEAoMUExBVEZPUk1fRkNNEAESEQoNUExBVEZPUk1fQV'
|
||||
'BOUxACEhgKFFBMQVRGT1JNX1VOSUZJRURQVVNIEAMSFAoQUExBVEZPUk1fV0VCUFVTSBAE');
|
||||
'guUGxhdGZvcm1SCHBsYXRmb3JtEhoKCGVuZHBvaW50GAIgASgMUghlbmRwb2ludBIdCgp2b2lw'
|
||||
'X3Rva2VuGAMgASgMUgl2b2lwVG9rZW4icgoIUGxhdGZvcm0SEQoNUExBVEZPUk1fTk9ORRAAEh'
|
||||
'AKDFBMQVRGT1JNX0ZDTRABEhEKDVBMQVRGT1JNX0FQTlMQAhIYChRQTEFURk9STV9VTklGSUVE'
|
||||
'UFVTSBADEhQKEFBMQVRGT1JNX1dFQlBVU0gQBA==');
|
||||
|
||||
@$core.Deprecated('Use turnRequestDescriptor instead')
|
||||
const TurnRequest$json = {
|
||||
'1': 'TurnRequest',
|
||||
};
|
||||
|
||||
/// Descriptor for `TurnRequest`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List turnRequestDescriptor =
|
||||
$convert.base64Decode('CgtUdXJuUmVxdWVzdA==');
|
||||
|
||||
@$core.Deprecated('Use turnCredentialsDescriptor instead')
|
||||
const TurnCredentials$json = {
|
||||
'1': 'TurnCredentials',
|
||||
'2': [
|
||||
{'1': 'urls', '3': 1, '4': 3, '5': 9, '10': 'urls'},
|
||||
{'1': 'username', '3': 2, '4': 1, '5': 9, '10': 'username'},
|
||||
{'1': 'credential', '3': 3, '4': 1, '5': 9, '10': 'credential'},
|
||||
{'1': 'ttl_seconds', '3': 4, '4': 1, '5': 13, '10': 'ttlSeconds'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `TurnCredentials`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List turnCredentialsDescriptor = $convert.base64Decode(
|
||||
'Cg9UdXJuQ3JlZGVudGlhbHMSEgoEdXJscxgBIAMoCVIEdXJscxIaCgh1c2VybmFtZRgCIAEoCV'
|
||||
'IIdXNlcm5hbWUSHgoKY3JlZGVudGlhbBgDIAEoCVIKY3JlZGVudGlhbBIfCgt0dGxfc2Vjb25k'
|
||||
'cxgEIAEoDVIKdHRsU2Vjb25kcw==');
|
||||
|
||||
@$core.Deprecated('Use signedPreKeyDescriptor instead')
|
||||
const SignedPreKey$json = {
|
||||
@@ -391,6 +423,15 @@ const ClientFrame$json = {
|
||||
'9': 0,
|
||||
'10': 'registerPush'
|
||||
},
|
||||
{
|
||||
'1': 'turn_request',
|
||||
'3': 7,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.sepot.v1.TurnRequest',
|
||||
'9': 0,
|
||||
'10': 'turnRequest'
|
||||
},
|
||||
],
|
||||
'8': [
|
||||
{'1': 'frame'},
|
||||
@@ -404,7 +445,8 @@ final $typed_data.Uint8List clientFrameDescriptor = $convert.base64Decode(
|
||||
'dmVsb3BlGAMgASgLMhIuc2Vwb3QudjEuRW52ZWxvcGVIAFIIZW52ZWxvcGUSIQoDYWNrGAQgAS'
|
||||
'gLMg0uc2Vwb3QudjEuQWNrSABSA2FjaxIkCgRwb25nGAUgASgLMg4uc2Vwb3QudjEuUG9uZ0gA'
|
||||
'UgRwb25nEj0KDXJlZ2lzdGVyX3B1c2gYBiABKAsyFi5zZXBvdC52MS5SZWdpc3RlclB1c2hIAF'
|
||||
'IMcmVnaXN0ZXJQdXNoQgcKBWZyYW1l');
|
||||
'IMcmVnaXN0ZXJQdXNoEjoKDHR1cm5fcmVxdWVzdBgHIAEoCzIVLnNlcG90LnYxLlR1cm5SZXF1'
|
||||
'ZXN0SABSC3R1cm5SZXF1ZXN0QgcKBWZyYW1l');
|
||||
|
||||
@$core.Deprecated('Use serverFrameDescriptor instead')
|
||||
const ServerFrame$json = {
|
||||
@@ -473,6 +515,15 @@ const ServerFrame$json = {
|
||||
'9': 0,
|
||||
'10': 'authOk'
|
||||
},
|
||||
{
|
||||
'1': 'turn_credentials',
|
||||
'3': 8,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.sepot.v1.TurnCredentials',
|
||||
'9': 0,
|
||||
'10': 'turnCredentials'
|
||||
},
|
||||
],
|
||||
'8': [
|
||||
{'1': 'frame'},
|
||||
@@ -487,4 +538,5 @@ final $typed_data.Uint8List serverFrameDescriptor = $convert.base64Decode(
|
||||
'Jla2V5c19sb3cYBCABKAsyFC5zZXBvdC52MS5QcmVrZXlzTG93SABSCnByZWtleXNMb3cSJAoE'
|
||||
'cGluZxgFIAEoCzIOLnNlcG90LnYxLlBpbmdIAFIEcGluZxInCgVlcnJvchgGIAEoCzIPLnNlcG'
|
||||
'90LnYxLkVycm9ySABSBWVycm9yEisKB2F1dGhfb2sYByABKAsyEC5zZXBvdC52MS5BdXRoT2tI'
|
||||
'AFIGYXV0aE9rQgcKBWZyYW1l');
|
||||
'AFIGYXV0aE9rEkYKEHR1cm5fY3JlZGVudGlhbHMYCCABKAsyGS5zZXBvdC52MS5UdXJuQ3JlZG'
|
||||
'VudGlhbHNIAFIPdHVybkNyZWRlbnRpYWxzQgcKBWZyYW1l');
|
||||
|
||||
@@ -17,6 +17,15 @@ class ContactsNotifier extends AsyncNotifier<List<Contact>> {
|
||||
final storage = ref.read(storageProvider);
|
||||
final contacts = await future;
|
||||
|
||||
// Odkaz bez routingu (aid+da) = protistrana ho vytvořila dřív, než měla
|
||||
// hotovou registraci. Přidat takový kontakt = časovaná bomba: první
|
||||
// odeslání spadne na „nemá doručovací adresu". Odmítnout hned s
|
||||
// jasnou hláškou. (Nullable routing má smysl jen na interních příjmových
|
||||
// cestách — roster/_findOrCreateContact —, ne při ručním importu.)
|
||||
if (identity.accountId == null || identity.deliveryAddress == null) {
|
||||
throw const ContactLinkIncompleteError();
|
||||
}
|
||||
|
||||
// Kontrola duplicity
|
||||
if (contacts.any((c) => c.publicIdentity.id == identity.id)) {
|
||||
throw const ContactAlreadyExistsError();
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/errors.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/models/group.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
import 'package:sepot/models/poll.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
@@ -20,13 +23,23 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
/// Odešli zprávu — E2E šifrovaně přes relay. `replyToId` = odpověď.
|
||||
/// `contactId` může být i gid skupiny (fanout členům).
|
||||
Future<void> sendMessage(String contactId, String text,
|
||||
{String? replyToId}) async {
|
||||
{String? replyToId, List<String> mentions = const []}) async {
|
||||
if (isNotes(contactId)) {
|
||||
return _addNote((me) => Message.text(
|
||||
senderId: me,
|
||||
recipientId: me,
|
||||
content: text,
|
||||
replyToId: replyToId,
|
||||
status: MessageStatus.sent,
|
||||
));
|
||||
}
|
||||
final storage = ref.read(storageProvider);
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
|
||||
final group = await _groupOrNull(contactId);
|
||||
if (group != null) {
|
||||
await service.sendGroupText(group, text, replyToId: replyToId);
|
||||
await service.sendGroupText(group, text,
|
||||
replyToId: replyToId, mentions: mentions);
|
||||
} else {
|
||||
final contact = await _contact(contactId);
|
||||
// sendText zprávu po odeslání sám uloží (s reálným delivery stavem)
|
||||
@@ -37,6 +50,51 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
state = AsyncData(conversations);
|
||||
}
|
||||
|
||||
/// Pošle anketu. [fallback] je text, který uvidí starší verze appky
|
||||
/// (skládá ho UI, protože zná jazyk uživatele).
|
||||
Future<void> sendPoll(String convId, Poll poll, String fallback) async {
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
final group = await _groupOrNull(convId);
|
||||
if (group != null) {
|
||||
await service.sendGroupText(group, fallback, poll: poll);
|
||||
} else {
|
||||
await service.sendText(await _contact(convId), fallback, poll: poll);
|
||||
}
|
||||
state = AsyncData(await ref.read(storageProvider).loadConversations());
|
||||
}
|
||||
|
||||
/// Hlas v anketě (prázdná volba = zrušit).
|
||||
Future<void> votePoll(String convId, String pollId, List<int> choice) async {
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
final group = await _groupOrNull(convId);
|
||||
await service.votePoll(
|
||||
convId,
|
||||
pollId,
|
||||
choice,
|
||||
group: group,
|
||||
contact: group == null ? await _contact(convId) : null,
|
||||
);
|
||||
}
|
||||
|
||||
static bool isNotes(String id) => id == AppConstants.notesConversationId;
|
||||
|
||||
/// Poznámka pro sebe: jen šifrované úložiště v telefonu, nic do sítě.
|
||||
Future<void> _addNote(Message Function(String myId) build) async {
|
||||
final identity = ref.read(identityProvider).valueOrNull;
|
||||
if (identity == null) return;
|
||||
final storage = ref.read(storageProvider);
|
||||
await storage.addMessage(
|
||||
AppConstants.notesConversationId, build(identity.id));
|
||||
state = AsyncData(await storage.loadConversations());
|
||||
}
|
||||
|
||||
/// Otevře poznámky; prázdné je založí, aby byly vidět i v seznamu chatů.
|
||||
Future<void> ensureNotes() async {
|
||||
final storage = ref.read(storageProvider);
|
||||
await storage.ensureConversation(AppConstants.notesConversationId);
|
||||
state = AsyncData(await storage.loadConversations());
|
||||
}
|
||||
|
||||
Future<Contact> _contact(String contactId) async {
|
||||
final contacts = await ref.read(storageProvider).loadContacts();
|
||||
final contact = contacts.where((c) => c.id == contactId).firstOrNull;
|
||||
@@ -67,6 +125,17 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
/// Úprava textu odeslané zprávy.
|
||||
Future<void> editMessage(
|
||||
String contactId, String messageId, String newText) async {
|
||||
if (isNotes(contactId)) {
|
||||
final storage = ref.read(storageProvider);
|
||||
final conv = await storage.loadConversation(contactId);
|
||||
final old = conv?.messages.where((m) => m.id == messageId).firstOrNull;
|
||||
if (conv == null || old == null) return;
|
||||
conv.updateMessage(
|
||||
old.copyWith(content: newText, editedAt: DateTime.now()));
|
||||
await storage.saveConversation(conv);
|
||||
state = AsyncData(await storage.loadConversations());
|
||||
return;
|
||||
}
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
final group = await _groupOrNull(contactId);
|
||||
if (group != null) {
|
||||
@@ -112,6 +181,18 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
state = AsyncData(await storage.loadConversations());
|
||||
}
|
||||
|
||||
/// Připnout / odepnout konverzaci (lokální). Archivovaná se připnutím
|
||||
/// vrací mezi aktivní — připnutý chat v archivu by nikdo neviděl.
|
||||
Future<void> setPinned(String contactId, bool pinned) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
final conv = await storage.loadConversation(contactId);
|
||||
if (conv == null) return;
|
||||
conv.pinned = pinned;
|
||||
if (pinned) conv.archived = false;
|
||||
await storage.saveConversation(conv);
|
||||
state = AsyncData(await storage.loadConversations());
|
||||
}
|
||||
|
||||
/// Smazání zprávy jen u mě (lokálně) — protistraně se nic neposílá.
|
||||
Future<void> deleteLocalMessage(String contactId, String messageId) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
@@ -134,6 +215,18 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
final storage = ref.read(storageProvider);
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
|
||||
if (isNotes(contactId)) {
|
||||
final meta = await service.prepareNoteAttachment(
|
||||
bytes: bytes, fileName: fileName, mime: mime);
|
||||
return _addNote((me) => Message.attachment(
|
||||
senderId: me,
|
||||
recipientId: me,
|
||||
attachment: meta,
|
||||
caption: caption,
|
||||
status: MessageStatus.sent,
|
||||
));
|
||||
}
|
||||
|
||||
final group = await _groupOrNull(contactId);
|
||||
if (group != null) {
|
||||
await service.sendGroupAttachment(
|
||||
@@ -167,6 +260,19 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
}) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
if (isNotes(contactId)) {
|
||||
final meta = await service.prepareNoteAttachment(
|
||||
bytes: bytes,
|
||||
fileName: 'voice-message.m4a',
|
||||
mime: mime,
|
||||
durationMs: durationMs);
|
||||
return _addNote((me) => Message.attachment(
|
||||
senderId: me,
|
||||
recipientId: me,
|
||||
attachment: meta,
|
||||
status: MessageStatus.sent,
|
||||
));
|
||||
}
|
||||
final group = await _groupOrNull(contactId);
|
||||
if (group != null) {
|
||||
await service.sendGroupAttachment(
|
||||
@@ -188,6 +294,33 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
state = AsyncData(await storage.loadConversations());
|
||||
}
|
||||
|
||||
/// Přepošle zprávu do jiné konverzace (kontakt i skupina). Příloha se
|
||||
/// dešifruje a odešle znovu s novým klíčem: odkaz na původní blob by
|
||||
/// vypršel s ním (relay drží bloby 30 dní) a cíl by ho neměl čím otevřít.
|
||||
Future<void> forwardMessage(Message message, String targetId) async {
|
||||
final meta = message.attachment;
|
||||
if (meta == null) {
|
||||
await sendMessage(targetId, message.content ?? '');
|
||||
return;
|
||||
}
|
||||
final bytes = await ref
|
||||
.read(messagingServiceProvider)
|
||||
.loadDecryptedAttachment(message);
|
||||
if (bytes == null) throw const AttachmentUnavailableError();
|
||||
if (meta.mime.startsWith('audio/') && meta.durationMs != null) {
|
||||
await sendVoiceMessage(targetId,
|
||||
bytes: bytes, durationMs: meta.durationMs!, mime: meta.mime);
|
||||
return;
|
||||
}
|
||||
await sendAttachment(
|
||||
targetId,
|
||||
bytes: bytes,
|
||||
fileName: meta.name ?? 'attachment',
|
||||
mime: meta.mime,
|
||||
caption: message.content,
|
||||
);
|
||||
}
|
||||
|
||||
/// Ruční opakování downloadu blobu (stav failed).
|
||||
Future<void> retryAttachmentDownload(
|
||||
String contactId, Message message) async {
|
||||
@@ -210,23 +343,26 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
// přečtení (sendReadReceipts sám zkontroluje, jestli je funkce zapnutá).
|
||||
final justRead = conv.messages
|
||||
.where((m) => !m.isMine(identity.id) && !m.isRead)
|
||||
.map((m) => m.id)
|
||||
.toList();
|
||||
|
||||
conv.markAllRead(identity.id);
|
||||
await storage.saveConversation(conv);
|
||||
state = AsyncData(await storage.loadConversations());
|
||||
|
||||
// Potvrzení přečtení ve skupině v1 neposíláme (fanout N receipts
|
||||
// za každé otevření chatu; per-member matice až v2).
|
||||
if (justRead.isNotEmpty && await _groupOrNull(contactId) == null) {
|
||||
final contacts = await storage.loadContacts();
|
||||
final contact = contacts.where((c) => c.id == contactId).firstOrNull;
|
||||
if (contact != null) {
|
||||
await ref
|
||||
.read(messagingServiceProvider)
|
||||
.sendReadReceipts(contact, justRead);
|
||||
if (justRead.isEmpty) return;
|
||||
// Ve skupině dostane potvrzení jen autor dané zprávy — ne celý roster.
|
||||
// Otevření chatu tak stojí tolik obálek, kolik lidí mezitím psalo.
|
||||
final group = await _groupOrNull(contactId);
|
||||
final byAuthor = <String, List<String>>{};
|
||||
for (final m in justRead) {
|
||||
byAuthor.putIfAbsent(m.senderId, () => []).add(m.id);
|
||||
}
|
||||
final contacts = await storage.loadContacts();
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
for (final MapEntry(key: author, value: ids) in byAuthor.entries) {
|
||||
final contact = contacts.where((c) => c.id == author).firstOrNull;
|
||||
if (contact == null) continue;
|
||||
await service.sendReadReceipts(contact, ids, gid: group?.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
/// Rozepsané zprávy (contactId → text). Jeden záznam v šifrovaném nastavení,
|
||||
/// ne pole konverzace: konverzace se ukládá celá jako load → změna → save
|
||||
/// a zápis konceptu při psaní by mohl přepsat zprávu, která mezitím dorazila.
|
||||
class DraftsNotifier extends AsyncNotifier<Map<String, String>> {
|
||||
static const key = 'drafts';
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> build() async {
|
||||
final raw =
|
||||
await ref.read(storageProvider).getSetting<Map<String, dynamic>>(key);
|
||||
return {
|
||||
for (final e in (raw ?? const {}).entries)
|
||||
if (e.value is String) e.key: e.value as String,
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> set(String contactId, String text) async {
|
||||
// Počkat na načtení — jinak by zápis před dokončením build() smazal
|
||||
// koncepty ostatních chatů.
|
||||
final current = {...await future};
|
||||
final trimmed = text.trim();
|
||||
if ((current[contactId] ?? '') == trimmed) return;
|
||||
if (trimmed.isEmpty) {
|
||||
current.remove(contactId);
|
||||
} else {
|
||||
current[contactId] = trimmed;
|
||||
}
|
||||
state = AsyncData(current);
|
||||
await ref.read(storageProvider).setSetting(key, current);
|
||||
}
|
||||
}
|
||||
|
||||
final draftsProvider =
|
||||
AsyncNotifierProvider<DraftsNotifier, Map<String, String>>(
|
||||
DraftsNotifier.new);
|
||||
@@ -30,6 +30,20 @@ class GroupsNotifier extends AsyncNotifier<List<Group>> {
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
Future<void> removeMember(Group group, String fingerprint) async {
|
||||
await ref
|
||||
.read(messagingServiceProvider)
|
||||
.removeGroupMember(group, fingerprint);
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
Future<void> setAdmin(Group group, String fingerprint, bool admin) async {
|
||||
await ref
|
||||
.read(messagingServiceProvider)
|
||||
.setGroupAdmin(group, fingerprint, admin);
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
Future<void> leave(Group group) async {
|
||||
await ref.read(messagingServiceProvider).leaveGroup(group);
|
||||
await _refresh();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/core/calls/call_controller.dart';
|
||||
import 'package:sepot/core/network/relay_client.dart';
|
||||
import 'package:sepot/core/push/push_notifier.dart';
|
||||
import 'package:sepot/core/services/messaging_service.dart';
|
||||
@@ -16,6 +19,28 @@ final messagingServiceProvider = Provider<MessagingService>((ref) {
|
||||
return service;
|
||||
});
|
||||
|
||||
/// Kdo právě píše: konverzace → otisky pisatelů. Prázdné, když je
|
||||
/// „píše…“ vypnuté (služba pak nic nepřijímá).
|
||||
final typingProvider = StreamProvider<Map<String, Set<String>>>((ref) async* {
|
||||
final tracker = ref.watch(messagingServiceProvider).typing;
|
||||
yield tracker.current;
|
||||
yield* tracker.changes;
|
||||
});
|
||||
|
||||
/// Profilová fotka člověka podle otisku (null = iniciály). Přepočítá se,
|
||||
/// když se změní kontakty — tam dorazí nový odkaz i stažení fotky.
|
||||
final avatarProvider =
|
||||
FutureProvider.family<Uint8List?, String>((ref, fingerprint) async {
|
||||
final service = ref.watch(messagingServiceProvider);
|
||||
final me = ref.watch(identityProvider).valueOrNull;
|
||||
final contacts = ref.watch(contactsProvider).valueOrNull ?? const [];
|
||||
if (!service.isStarted) return null;
|
||||
final avatar = fingerprint == me?.id
|
||||
? service.myAvatar
|
||||
: contacts.where((c) => c.id == fingerprint).firstOrNull?.avatar;
|
||||
return avatar == null ? null : service.loadAvatar(avatar);
|
||||
});
|
||||
|
||||
/// Nastartuje službu, jakmile existuje identita. Watchovat z první
|
||||
/// obrazovky po odemčení (chat list) — do té doby se nic nespojuje.
|
||||
/// Předpokládá odemčený storage — na /chats se jde vždy až po unlocku.
|
||||
@@ -23,6 +48,17 @@ final messagingStartProvider = FutureProvider<void>((ref) async {
|
||||
final identity = await ref.watch(identityProvider.future);
|
||||
if (identity == null) return;
|
||||
|
||||
// Po auto-locku se tenhle provider eager-rebuildne (drží ho pushInitProvider)
|
||||
// ještě NAD zamčeným storage → service.start() by spadl na „Storage is
|
||||
// locked" a provider by uvázl v AsyncError (žádný příjem/odeslání až do
|
||||
// killu procesu). Nad zamčeným storage tiše nic nestartuj; po odemčení
|
||||
// se provider invaliduje (viz unlock cesty) a spustí start znovu.
|
||||
final storage = ref.read(storageProvider);
|
||||
if (!storage.isUnlocked) return;
|
||||
// Nátlaková relace: žádná síť. Messaging by se registroval skutečným
|
||||
// identity klíčem (409 každých 30 s = důkaz pro pozorovatele sítě).
|
||||
if (storage.isDecoyMode) return;
|
||||
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
if (service.isStarted) return;
|
||||
|
||||
@@ -38,16 +74,21 @@ final messagingStartProvider = FutureProvider<void>((ref) async {
|
||||
|
||||
// Appka běží, ale není v popředí (jiná appka / zhasnutý displej se
|
||||
// živým procesem) → systémová notifikace. Jen jméno, žádný obsah.
|
||||
// Ztlumená konverzace notifikaci potlačí (unread badge běží dál).
|
||||
service.onMessageReceived = (contact, message) async {
|
||||
// Ztlumená konverzace notifikaci potlačí (unread badge běží dál),
|
||||
// zmínka mě ve skupině projde i tak.
|
||||
service.onMessageReceived = (contact, message, convId) async {
|
||||
final visible = WidgetsBinding.instance.lifecycleState ==
|
||||
AppLifecycleState.resumed;
|
||||
if (visible) return;
|
||||
final conv = await ref.read(storageProvider).loadConversation(contact.id);
|
||||
if (conv?.isMuted == true) return;
|
||||
final conv = await ref.read(storageProvider).loadConversation(convId);
|
||||
final mentionedMe = message.mentions.contains(identity.id);
|
||||
if (conv?.isMuted == true && !mentionedMe) return;
|
||||
PushNotifier.showMessageNotification(contact.displayName);
|
||||
};
|
||||
|
||||
service.onCallSignal = (contact, signal) =>
|
||||
ref.read(callProvider.notifier).onSignal(contact, signal);
|
||||
|
||||
await service.start(identity);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/core/push/push_service.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
/// Push vrstva nad messagingem.
|
||||
final pushServiceProvider = Provider<PushService>((ref) {
|
||||
@@ -18,3 +19,31 @@ final pushInitProvider = FutureProvider<void>((ref) async {
|
||||
// re-registruje u relaye po každém startu.
|
||||
await push.restoreMode();
|
||||
});
|
||||
|
||||
/// Stav budíčků pro chat list. Bez funkčního pushe appka na pozadí spí a
|
||||
/// zprávy dorazí, až ji uživatel otevře — o tom má vědět dřív, než mu
|
||||
/// někdo vyčte, že neodpovídá.
|
||||
enum PushHealth {
|
||||
/// Budíčky nastavené a bez chyby.
|
||||
ok,
|
||||
|
||||
/// Push vůbec nezapnutý (výchozí stav po instalaci).
|
||||
off,
|
||||
|
||||
/// Distributor registraci odmítl — zapnuto, ale nefunguje.
|
||||
failed,
|
||||
}
|
||||
|
||||
final pushHealthProvider = FutureProvider<PushHealth>((ref) async {
|
||||
await ref.watch(pushInitProvider.future);
|
||||
final push = ref.read(pushServiceProvider);
|
||||
if (await push.registrationFailed()) return PushHealth.failed;
|
||||
return await push.currentMode() == 'off' ? PushHealth.off : PushHealth.ok;
|
||||
});
|
||||
|
||||
/// Skryl si uživatel upozornění na chat listu? Rozhodnutí se respektuje —
|
||||
/// připomínat donekonečna by bylo otravné.
|
||||
final pushHintDismissedProvider = FutureProvider<bool>((ref) async {
|
||||
final storage = ref.watch(storageProvider);
|
||||
return await storage.getSetting<bool>('push_hint_dismissed') ?? false;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:screen_protector/screen_protector.dart';
|
||||
import 'package:sepot/core/backup/backup_service.dart';
|
||||
import 'package:sepot/core/services/messaging_service.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
@@ -32,15 +34,45 @@ class ReadReceiptsNotifier extends AsyncNotifier<bool> {
|
||||
final readReceiptsProvider =
|
||||
AsyncNotifierProvider<ReadReceiptsNotifier, bool>(ReadReceiptsNotifier.new);
|
||||
|
||||
/// „píše…“ — stejná pravidla jako potvrzení přečtení: výchozí vypnuto,
|
||||
/// reciproční (kdo nesdílí, nevidí).
|
||||
class TypingIndicatorsNotifier extends AsyncNotifier<bool> {
|
||||
@override
|
||||
Future<bool> build() async {
|
||||
final storage = ref.read(storageProvider);
|
||||
return (await storage.getSetting<bool>(
|
||||
MessagingService.typingSettingKey)) ??
|
||||
false;
|
||||
}
|
||||
|
||||
Future<void> set(bool value) async {
|
||||
await ref
|
||||
.read(storageProvider)
|
||||
.setSetting(MessagingService.typingSettingKey, value);
|
||||
state = AsyncData(value);
|
||||
}
|
||||
}
|
||||
|
||||
final typingIndicatorsProvider =
|
||||
AsyncNotifierProvider<TypingIndicatorsNotifier, bool>(
|
||||
TypingIndicatorsNotifier.new);
|
||||
|
||||
/// Zabezpečení obrazovky: blok screenshotů (Android FLAG_SECURE) + skrytí
|
||||
/// obsahu v přepínači appek (iOS). Default VYPNUTO — blokuje i vlastní
|
||||
/// screenshoty, tak ať se uživatel rozhodne sám.
|
||||
/// obsahu v přepínači appek (iOS). Výchozí ZAPNUTO jako u Signalu — stejné
|
||||
/// jako nativní default v MainActivity, takže přepínač už neukazuje
|
||||
/// „vypnuto" u obrazovky, která chráněná je.
|
||||
class ScreenSecurityNotifier extends AsyncNotifier<bool> {
|
||||
static const key = 'screen_security_enabled';
|
||||
// Debug build bez ochrany: QA na emulátoru potřebuje screenshoty.
|
||||
static const vychozi = !kDebugMode;
|
||||
|
||||
@override
|
||||
Future<bool> build() async {
|
||||
final v = (await ref.read(storageProvider).getSetting<bool>(key)) ?? false;
|
||||
final storage = ref.read(storageProvider);
|
||||
// Před odemčením se nastavení přečíst nedá; nic neaplikovat (nativní
|
||||
// default platí) a po unlocku se provider invaliduje.
|
||||
if (!storage.isUnlocked) return vychozi;
|
||||
final v = (await storage.getSetting<bool>(key)) ?? vychozi;
|
||||
await _apply(v);
|
||||
return v;
|
||||
}
|
||||
|
||||
+117
-48
@@ -1,73 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
|
||||
/// Generovaný avatar z iniciál
|
||||
class SepotAvatar extends StatelessWidget {
|
||||
/// Avatar člověka: jeho profilová fotka, jinak iniciály na barevném tónu.
|
||||
///
|
||||
/// Tón se odvozuje ze [seed] (otisk klíče), ne ze jména — přejmenovaný
|
||||
/// kontakt si barvu nechá a dva lidé se stejným jménem se od sebe liší.
|
||||
/// Podle téhož otisku se hledá i fotka.
|
||||
class SepotAvatar extends ConsumerWidget {
|
||||
final String name;
|
||||
final double size;
|
||||
final bool isOnline;
|
||||
|
||||
/// Stabilní identifikátor pro tón (fingerprint). Bez něj se použije jméno.
|
||||
final String? seed;
|
||||
|
||||
/// Zelený odznak ověřeného kontaktu v pravém dolním rohu.
|
||||
final bool verified;
|
||||
|
||||
/// Kroužek kolem avataru (připnutý chat s nepřečtenou zprávou apod.).
|
||||
final Color? ring;
|
||||
|
||||
const SepotAvatar({
|
||||
super.key,
|
||||
required this.name,
|
||||
this.size = 48,
|
||||
this.isOnline = false,
|
||||
this.seed,
|
||||
this.verified = false,
|
||||
this.ring,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final initials = _getInitials(name);
|
||||
final color = _colorFromName(name);
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tint = SepotTint.of(seed ?? name);
|
||||
final badge = (size * 0.34).clamp(12.0, 30.0);
|
||||
final photo =
|
||||
seed == null ? null : ref.watch(avatarProvider(seed!)).valueOrNull;
|
||||
final px = (size * MediaQuery.devicePixelRatioOf(context)).round();
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
Widget circle = Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
// Nokturno: tichý tmavý podklad, barva jen v iniciálách
|
||||
color: SepotColors.surfaceVariant,
|
||||
border: Border.all(color: SepotColors.border),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
initials,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: tint.background),
|
||||
alignment: Alignment.center,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: photo != null
|
||||
? Image.memory(
|
||||
photo,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
// Dekódovat jen v potřebné velikosti — seznam má desítky avatarů.
|
||||
cacheWidth: px,
|
||||
gaplessPlayback: true,
|
||||
)
|
||||
: Text(
|
||||
initialsOf(name),
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: size * 0.35,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: tint.ink,
|
||||
fontSize: size * 0.34,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isOnline)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: size * 0.28,
|
||||
height: size * 0.28,
|
||||
);
|
||||
|
||||
if (ring != null) {
|
||||
circle = Container(
|
||||
padding: const EdgeInsets.all(2.5),
|
||||
decoration: BoxDecoration(
|
||||
color: SepotColors.success,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: SepotColors.background,
|
||||
width: 2,
|
||||
border: Border.all(color: ring!, width: 2),
|
||||
),
|
||||
child: circle,
|
||||
);
|
||||
}
|
||||
if (!verified) return circle;
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
circle,
|
||||
Positioned(
|
||||
right: -1,
|
||||
bottom: -1,
|
||||
child: Container(
|
||||
width: badge,
|
||||
height: badge,
|
||||
decoration: BoxDecoration(
|
||||
color: SepotColors.sage,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: SepotColors.background, width: 2),
|
||||
),
|
||||
child: Icon(Icons.check_rounded,
|
||||
size: badge * 0.62, color: SepotColors.background),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getInitials(String name) {
|
||||
/// Iniciály pro avatar — veřejné, ať je sdílí i kolečka hlasujících apod.
|
||||
static String initialsOf(String name) {
|
||||
// Prázdné díly odfiltrovat — dvojitá mezera ve jméně nesmí shodit render.
|
||||
// Grafémy (characters), ne code units — jméno začínající emoji by se
|
||||
// jinak rozpůlilo na rozbitý surrogate.
|
||||
final parts =
|
||||
name.trim().split(' ').where((p) => p.isNotEmpty).toList();
|
||||
final parts = name.trim().split(' ').where((p) => p.isNotEmpty).toList();
|
||||
if (parts.length >= 2) {
|
||||
return '${parts[0].characters.first}${parts[1].characters.first}'
|
||||
.toUpperCase();
|
||||
@@ -75,16 +112,48 @@ class SepotAvatar extends StatelessWidget {
|
||||
if (parts.isEmpty) return '?';
|
||||
return parts[0].characters.take(2).toString().toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
Color _colorFromName(String name) {
|
||||
// Jen tlumené tóny palety — žádné syté cizí barvy
|
||||
final colors = [
|
||||
SepotColors.ember,
|
||||
SepotColors.mist,
|
||||
SepotColors.sage,
|
||||
SepotColors.warning,
|
||||
SepotColors.textSecondary,
|
||||
];
|
||||
return colors[name.hashCode.abs() % colors.length];
|
||||
/// Avatar skupiny: zaoblený čtverec s ikonou, ať se na první pohled liší
|
||||
/// od člověka.
|
||||
/// Avatar poznámek pro sebe — vlastní tvar i barva, ať se neplete s lidmi.
|
||||
class NotesAvatar extends StatelessWidget {
|
||||
const NotesAvatar({super.key, this.size = 48});
|
||||
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: SepotColors.ember.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(size * 0.32),
|
||||
),
|
||||
child: Icon(Icons.bookmark_rounded,
|
||||
size: size * 0.5, color: SepotColors.ember),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SepotGroupAvatar extends StatelessWidget {
|
||||
const SepotGroupAvatar({super.key, required this.seed, this.size = 48});
|
||||
|
||||
final String seed;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tint = SepotTint.of(seed);
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: tint.background,
|
||||
borderRadius: BorderRadius.circular(size * 0.32),
|
||||
),
|
||||
child: Icon(Icons.groups_rounded, size: size * 0.5, color: tint.ink),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
|
||||
/// Průsvitná plocha s rozmazáním obsahu pod sebou (hlavička chatu, pole pro
|
||||
/// psaní, spodní navigace). Blur je drahý, proto jen na pár plovoucích
|
||||
/// lištách, nikdy na položkách seznamu.
|
||||
class GlassSurface extends StatelessWidget {
|
||||
const GlassSurface({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.borderRadius = BorderRadius.zero,
|
||||
this.padding = EdgeInsets.zero,
|
||||
this.border = true,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final BorderRadius borderRadius;
|
||||
final EdgeInsets padding;
|
||||
final bool border;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
|
||||
child: Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: SepotColors.glass,
|
||||
borderRadius: borderRadius,
|
||||
border: border
|
||||
? Border.all(color: SepotColors.glassBorder)
|
||||
: null,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tečková tapeta pod konverzací — dává průsvitným lištám co rozmazat
|
||||
/// a chatu hloubku, aniž by rušila text.
|
||||
class ChatWallpaper extends StatelessWidget {
|
||||
const ChatWallpaper({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomPaint(
|
||||
painter: const _DotsPainter(),
|
||||
isComplex: true,
|
||||
willChange: false,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DotsPainter extends CustomPainter {
|
||||
const _DotsPainter();
|
||||
|
||||
static const _spacing = 18.0;
|
||||
static const _radius = 1.0;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
canvas.drawRect(
|
||||
Offset.zero & size, Paint()..color = SepotColors.background);
|
||||
final dot = Paint()..color = SepotColors.mist.withValues(alpha: 0.11);
|
||||
for (var y = _spacing / 2; y < size.height; y += _spacing) {
|
||||
for (var x = _spacing / 2; x < size.width; x += _spacing) {
|
||||
canvas.drawCircle(Offset(x, y), _radius, dot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _DotsPainter oldDelegate) => false;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
|
||||
/// Stav mé zprávy: ⏱ odesílá se · ✓ u relaye · ✓✓ doručeno · pilulka
|
||||
/// přečteno · ✕ selhalo.
|
||||
///
|
||||
/// „Přečteno“ se liší TVAREM (plná pilulka), ne jen barvou — dřívější
|
||||
/// modrošedé ✓✓ mělo na korálové bublině kontrast ~1,2 : 1 a nešlo ho
|
||||
/// odlišit od „doručeno“.
|
||||
class MessageStatusIcon extends StatelessWidget {
|
||||
const MessageStatusIcon({
|
||||
super.key,
|
||||
required this.status,
|
||||
this.onEmber = false,
|
||||
});
|
||||
|
||||
final MessageStatus status;
|
||||
|
||||
/// Vykresluje se na korálové bublině (jinak na tmavém pozadí).
|
||||
final bool onEmber;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final soft = onEmber
|
||||
? SepotColors.emberInk.withValues(alpha: 0.6)
|
||||
: SepotColors.textSecondary;
|
||||
return switch (status) {
|
||||
MessageStatus.sending => Icon(Icons.schedule_rounded, size: 14, color: soft),
|
||||
MessageStatus.sent => Icon(Icons.done_rounded, size: 15, color: soft),
|
||||
MessageStatus.delivered =>
|
||||
Icon(Icons.done_all_rounded, size: 15, color: soft),
|
||||
MessageStatus.read => Container(
|
||||
width: 21,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: onEmber ? SepotColors.emberInk : SepotColors.ember,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.done_all_rounded,
|
||||
size: 11,
|
||||
color: onEmber ? SepotColors.ember : SepotColors.emberInk,
|
||||
),
|
||||
),
|
||||
MessageStatus.failed =>
|
||||
const Icon(Icons.error_outline_rounded, size: 15, color: SepotColors.error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,7 @@
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
#include <record_linux/record_linux_plugin.h>
|
||||
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
#include <window_manager/window_manager_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
|
||||
@@ -27,13 +25,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) record_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
|
||||
record_linux_plugin_register_with_registrar(record_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
|
||||
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) window_manager_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
|
||||
window_manager_plugin_register_with_registrar(window_manager_registrar);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
flutter_secure_storage_linux
|
||||
record_linux
|
||||
screen_retriever_linux
|
||||
url_launcher_linux
|
||||
window_manager
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
+58
-98
@@ -193,14 +193,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.3"
|
||||
change_case:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: change_case
|
||||
sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -305,6 +297,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
dart_webrtc:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_webrtc
|
||||
sha256: "078e3c431500147e5cc52b3c6ea41ed538f30c7720cc2467d2186c9251e62716"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.8.2"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -438,6 +438,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_callkit_incoming:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_callkit_incoming
|
||||
sha256: "2d47db2e8d32d333e0352d37b0a4e643f606a54f8a541db17af7c42f18c58318"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
flutter_driver:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -578,6 +586,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_webrtc:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_webrtc
|
||||
sha256: c1d3674f61b14cbe98b60f0cce396835b1483163e644b8cd26fd3fdd55360fb9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.2+hotfix.3"
|
||||
fuchsia_remote_debug_protocol:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -860,6 +876,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.11"
|
||||
logger:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logger
|
||||
sha256: "2a0dc097e7b01d942475bdd552356db2d0f768b05540bd4b2b53f1840f2239a7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.8.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -924,14 +948,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.6"
|
||||
native_toolchain_cmake:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_cmake
|
||||
sha256: "9960e1e926ec210a3ba6fe61720d48e1dd6cbab00d05890a6e147ee45b7e47c5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.6"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -948,6 +964,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
package_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.1"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_platform_interface
|
||||
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1196,46 +1228,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
screen_retriever:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_retriever
|
||||
sha256: "42cc3b402a0f67d2455a0d067553d0f13453f6a008d98eababf8b63958d506bd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
screen_retriever_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_retriever_linux
|
||||
sha256: "2a476f1a5538065bc5badf376cfdc83d6ecf07d77eb2391b9c2bff5a76970048"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
screen_retriever_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_retriever_macos
|
||||
sha256: b5abb900fcb86614ff10b738b34e37b9e1d03b0447280668e2bc8a98bdc7bd59
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
screen_retriever_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_retriever_platform_interface
|
||||
sha256: "3af22d926bedf20c2caa308eea376776451a3af125919ce072e56525fded8901"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
screen_retriever_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_retriever_windows
|
||||
sha256: c44b38a4c4bab34af259180a70a4eee1e29384e7b82e627c9faa68afcdab2e73
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1413,42 +1405,26 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: unifiedpush
|
||||
sha256: "8ed9767f750a1dc6159a77e2171641d0cb825dc87682d1ce1b8618689b79f58e"
|
||||
sha256: b52a0eff848d0446b9d521e0ad2f7bc76c4dda88d0f6ac0a79f1765ccfe37939
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.2.0"
|
||||
version: "6.1.0"
|
||||
unifiedpush_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unifiedpush_android
|
||||
sha256: "2e6684e0a1a39ad8d6c7bc0d197954d50686acb1b1a7614b18ab0e0126f5492a"
|
||||
sha256: "2af6a09a5fb9fdd97b61e704a587e88abcdb5f7ee2aea9a2c35e80159f9d586d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
unifiedpush_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unifiedpush_linux
|
||||
sha256: c062d5eedd1cec70bcd33270cc4e01ae0ff6501f33d471167c06b34a968adfeb
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "3.3.1"
|
||||
unifiedpush_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unifiedpush_platform_interface
|
||||
sha256: "83372bc8d794b8b12ef6993b518d7be907dcfc2191bdf6de0ece5c4445d89880"
|
||||
sha256: "5aa3eefc2699e4d8db6d723f0cfdfb56ac850487549a0b3b83574f4a89f33aab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
unifiedpush_storage_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unifiedpush_storage_interface
|
||||
sha256: b8d423a4695efc616aa21d8ab48fb5ef99d6288c68b56282b8faac1579ceabd9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "3.1.0"
|
||||
universal_io:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1585,14 +1561,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webcrypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webcrypto
|
||||
sha256: "9885ed99a846191542dd6ca820c83a098117df56be57453832233a3fef68f3f2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.1"
|
||||
webdriver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1601,14 +1569,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
webpush_encryption:
|
||||
webrtc_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webpush_encryption
|
||||
sha256: "5b83272b91acda6ae515fcd980c94f06bf413702282497c5a68f5dfc64fed27f"
|
||||
name: webrtc_interface
|
||||
sha256: c6f100eac5057d9a817a60473126f9828c796d42884d498af4f339c97b21014f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
version: "1.5.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1617,14 +1585,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
window_manager:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: window_manager
|
||||
sha256: "7eb6d6c4164ec08e1bf978d6e733f3cebe792e2a23fb07cbca25c2872bfdbdcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+13
-2
@@ -1,7 +1,7 @@
|
||||
name: sepot
|
||||
description: "Šepot — Soukromý messenger s E2E šifrováním"
|
||||
publish_to: 'none'
|
||||
version: 1.1.0+20
|
||||
version: 1.2.1+24
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.8
|
||||
@@ -30,6 +30,9 @@ dependencies:
|
||||
http: ^1.2.2
|
||||
socks5_proxy: ^2.1.1
|
||||
path_provider: ^2.1.5
|
||||
# Verzi appky nikdy nepsat ručně — čte se z nainstalovaného balíčku
|
||||
# (viz lib/core/app_info.dart), aby se nemohla rozejít s `version:` výš.
|
||||
package_info_plus: ^8.3.1
|
||||
convert: ^3.1.2
|
||||
libsignal: ^5.0.9
|
||||
bip39: ^1.0.6
|
||||
@@ -37,7 +40,10 @@ dependencies:
|
||||
pointycastle: ^3.9.1
|
||||
protobuf: ^6.0.0
|
||||
sqlite3: ^3.3.4
|
||||
unifiedpush: ^6.2.0
|
||||
# Pin pod 6.2.0: od 6.2.0 táhne unifiedpush_linux → webpush_encryption →
|
||||
# webcrypto, jehož native framework na iOS chybí LC_ENCRYPTION_INFO (blokuje
|
||||
# upload). Linux desktop neshipujeme, na Android/iOS je 6.1.x funkčně shodná.
|
||||
unifiedpush: ">=6.1.0 <6.2.0"
|
||||
image: ^4.8.0
|
||||
mime: ^2.0.0
|
||||
image_picker: ^1.2.3
|
||||
@@ -47,6 +53,11 @@ dependencies:
|
||||
record: ^6.2.1
|
||||
audioplayers: ^6.7.1
|
||||
screen_protector: ^1.5.2
|
||||
# Hovory: WebRTC média, DTLS-SRTP je v protokolu povinné.
|
||||
flutter_webrtc: ^1.6.2
|
||||
# Zvonění při zavřené appce: CallKit (iOS, z VoIP pushe) a celoobrazovkové
|
||||
# zvonění na Androidu (z FCM budíčku k=call).
|
||||
flutter_callkit_incoming: ^3.1.6
|
||||
|
||||
dev_dependencies:
|
||||
integration_test:
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:sepot/core/app_info.dart';
|
||||
|
||||
/// Verzi appky nesmí nikdo psát ručně. Regrese: konstanta zůstala po releasu
|
||||
/// na „1.1.0", takže všechna hlášení zneužití z produkční 1.1.2+22 byla
|
||||
/// označená špatnou verzí a nešlo podle nich dohledat build.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
PackageInfo mock({required String version, required String buildNumber}) =>
|
||||
PackageInfo(
|
||||
appName: 'Šepot',
|
||||
packageName: 'cz.sepot.app',
|
||||
version: version,
|
||||
buildNumber: buildNumber,
|
||||
buildSignature: '',
|
||||
);
|
||||
|
||||
void nastavBalicek(PackageInfo info) => PackageInfo.setMockInitialValues(
|
||||
appName: info.appName,
|
||||
packageName: info.packageName,
|
||||
version: info.version,
|
||||
buildNumber: info.buildNumber,
|
||||
buildSignature: info.buildSignature,
|
||||
);
|
||||
|
||||
// Pořadí testů záleží: PackageInfo si první načtení nacachuje napořád,
|
||||
// takže stav „ještě se nenačetlo" jde ověřit jen před prvním init().
|
||||
test('před inicializací nepředstírá verzi', () {
|
||||
expect(AppInfo.version, AppInfo.unknownVersion);
|
||||
expect(AppInfo.versionWithBuild, isNull);
|
||||
});
|
||||
|
||||
test('hlášení nese verzi včetně build čísla', () async {
|
||||
nastavBalicek(mock(version: '1.1.2', buildNumber: '22'));
|
||||
await AppInfo.init();
|
||||
|
||||
expect(AppInfo.version, '1.1.2');
|
||||
expect(AppInfo.versionWithBuild, '1.1.2+22');
|
||||
});
|
||||
|
||||
test('iOS bez CFBundleVersion nezdvojí verzi', () async {
|
||||
nastavBalicek(mock(version: '1.1.2', buildNumber: '1.1.2'));
|
||||
await AppInfo.init();
|
||||
|
||||
expect(AppInfo.versionWithBuild, '1.1.2');
|
||||
});
|
||||
|
||||
test('prázdné build číslo → jen verze', () async {
|
||||
nastavBalicek(mock(version: '2.0.0', buildNumber: ''));
|
||||
await AppInfo.init();
|
||||
|
||||
expect(AppInfo.versionWithBuild, '2.0.0');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:sepot/core/services/avatar_image.dart';
|
||||
import 'package:sepot/models/avatar_ref.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
|
||||
/// Odkaz na fotku přichází od protistrany v profilu — nesmí projít nic,
|
||||
/// co by šlo zneužít (cizí cesta k blobu, klíč špatné délky).
|
||||
void main() {
|
||||
final ref = AvatarRef(
|
||||
blobId: 'a' * 32,
|
||||
key: Uint8List(32),
|
||||
nonce: Uint8List(12),
|
||||
digest: Uint8List(32),
|
||||
);
|
||||
|
||||
test('drát tam a zpět', () {
|
||||
final back = AvatarRef.fromWire(ref.toWire())!;
|
||||
expect(back.blobId, ref.blobId);
|
||||
expect(back.key, ref.key);
|
||||
});
|
||||
|
||||
test('podvržený odkaz = null', () {
|
||||
expect(AvatarRef.fromWire(null), isNull);
|
||||
expect(AvatarRef.fromWire({...ref.toWire(), 'b': '../../etc/passwd'}),
|
||||
isNull);
|
||||
expect(AvatarRef.fromWire({...ref.toWire(), 'k': 'AAAA'}), isNull);
|
||||
expect(AvatarRef.fromWire({...ref.toWire(), 'd': 'nebase64!'}), isNull);
|
||||
});
|
||||
|
||||
test('kontakt si fotku pamatuje přes uložení', () {
|
||||
final contact = Contact(
|
||||
displayName: 'Alice',
|
||||
publicIdentity: PublicIdentity(
|
||||
displayName: 'Alice',
|
||||
identityKey: Uint8List.fromList(List.filled(33, 5)),
|
||||
createdAt: DateTime(2026, 9, 1),
|
||||
),
|
||||
addedAt: DateTime(2026, 9, 1),
|
||||
avatar: ref,
|
||||
);
|
||||
final back = Contact.fromJson(contact.toJson());
|
||||
expect(back.avatar?.blobId, ref.blobId);
|
||||
});
|
||||
|
||||
test('fotka se ořízne na čtverec, zmenší a ztratí EXIF', () async {
|
||||
final photo = img.Image(width: 800, height: 400)
|
||||
..exif.imageIfd['Make'] = 'TestPhone';
|
||||
final jpeg = Uint8List.fromList(img.encodeJpg(photo));
|
||||
expect(img.decodeJpgExif(jpeg)?.imageIfd['Make'], isNotNull);
|
||||
|
||||
final out = await prepareAvatarJpeg(jpeg);
|
||||
final decoded = img.decodeJpg(out!)!;
|
||||
expect(decoded.width, avatarSide);
|
||||
expect(decoded.height, avatarSide);
|
||||
expect(img.decodeJpgExif(out)?.imageIfd['Make'], isNull);
|
||||
});
|
||||
|
||||
test('nečitelný obrázek = null', () async {
|
||||
expect(await prepareAvatarJpeg(Uint8List.fromList([1, 2, 3])), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/core/services/messaging_service.dart';
|
||||
|
||||
/// Přijímací cesta zahazuje obsah neznámého typu (ACK + drop, protože po
|
||||
/// upgradu už nejde dešifrovat znovu). Každý typ, který appka sama posílá,
|
||||
/// proto MUSÍ být v množině známých typů. V 1.2.0 tu chyběl `call` — každá
|
||||
/// nabídka hovoru se tiše zahodila a hovory nešly přijmout.
|
||||
void main() {
|
||||
test('všechny odesílané typy obsahu umí přijímací cesta zpracovat', () {
|
||||
const sent = {
|
||||
'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', 'typ', 'vote', 'call',
|
||||
'ginv', 'gleave', 'gmeta', 'gop', 'gsync',
|
||||
};
|
||||
expect(MessagingService.knownContentTypes, containsAll(sent));
|
||||
});
|
||||
|
||||
test('signalizace hovoru je známý typ', () {
|
||||
expect(MessagingService.knownContentTypes, contains('call'));
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user