diff --git a/AUDIT_REPORT.md b/AUDIT_REPORT.md new file mode 100644 index 0000000..3bf820f --- /dev/null +++ b/AUDIT_REPORT.md @@ -0,0 +1,4405 @@ +# Komet — Аудит качества кода + +_Многоагентный аудит: 22 искателя по подсистемам + 6 сквозных охотников за дублированием, каждая находка перепроверена отдельным скептиком по реальному коду. 57 агентов, 231 подтверждённая находка._ + +## 🔧 Прогресс исправлений + +Статусы: ✅ сделано · 🔧 в работе · ⏭️ отложено (крупный рефактор/риск) · ⬜ не начато + +Легенда важна: одной сессии на все 231 не хватит. Здесь отмечается, что уже поправлено, чтобы следующая сессия продолжила с места. + +### Сессия 1 (2026-07-04) + +| # | Находка | Файлы | Статус | +|---|---------|-------|--------| +| H1 | Outbox: per-row catch+log+continue вместо break/swallow | `outbox.dart` | ✅ | +| H2 | switchAccount: не глотать падение connect, сообщать об ошибке | `account.dart` | ✅ | +| H5 | Пароль прокси → secure storage (TokenStorage) | `proxy_config.dart` | ✅ | +| H7 | DebugSessionLog → общий redactForLog | `debug_session_log.dart`, `log_redact.dart` | ✅ | +| H10 | Батч-префетч контактов ensureContactNames | `chat_list_screen.dart` | ✅ | +| H12 | popUntil: назвать роут SecurityScreen | `settings_tab.dart` | ✅ | +| QW | PollView.initState → fetch(force: false) | `poll_view.dart` | ✅ | +| QW | SnackBar → showCustomNotification | `spoof_screen.dart` | ✅ | +| QW | Убрать print()-дебаг из релиза | `push_service.dart`, `chat_list_screen.dart`, `chat_screen.dart` | ✅ | +| QW | tz.initializeTimeZones за одноразовый флаг | `api.dart` | ✅ | +| QW | Guard push-handler dispatch в try/catch+log | `dispatcher.dart` | ✅ | +| QW | calls.dart uuid → Random.secure (общий util) | `calls.dart`, `device_identity.dart`, `spoofing_service.dart`, `utils/ids.dart` | ✅ | +| QW | SelfCheckService pause/resume по lifecycle | `self_check.dart`, `main.dart` | ✅ | +| QW | chat_info: local _formatLastSeen → общий formatLastSeen | `chat_info_screen.dart` | ✅ | +| QW | cloud-storage: слить 4 chatUpdate в один | `cloud_storage.dart` | ✅ | +| QW | _loadForwardedSenderNames → copyWith | `chat_screen.dart` | ✅ | +| QW | WebAppScreen/DigitalIdWebScreen дубль | `web_app_screen.dart`, `digital_id_web_screen.dart` | ⏭️ | +| QW | formatPhone: RegExp в static final | `format.dart` | ✅ | +| H15 | Декомпозиция chat_screen.dart (god-file) | `chat_screen.dart`, `chat/`, `chat/view/` | ✅ (сессии 3–8: **логика** — список изолирован + prank + `ChatController` (message-state + history/pagination + initial-load) + Composer-под-контроллеры **voice/video-note/command-panel/sticker** + **ChatSearchController**; сессия 9: **H15b тонкая композиция** — build-дерево композера/поиска/selection/header вынесено в 7 виджетов под `chat/view/` + `UploadStatus`; **chat_screen 6770→4909 строк**, −27%. send-пути/`_photoUploadProgress`/оркестраторы `_buildAppBar`/`_buildComposerArea` обоснованно в State) | +| H16 | Декомпозиция message_bubble.dart | — | ✅ (сессия 5: `BubbleContext` + 12 per-type бабблов вынесены; `MessageBubble` 2832→1225, тонкий диспетчер) | +| H17 | ChatsModule → instantiable repository | `backend/modules/chats.dart`, `chat_parsing.dart`, +14 вызывателей | ✅ (сессия 10) | +| H3 | Общий HTTP-хелпер для 5 upload-путей | `file_uploader.dart` | ✅ (сессия 3) | +| H4 | PersistedSetting для ~17 config-классов | `core/config/*` | ✅ (сессия 3) | +| H6 | CustomFontService: legacy UA | `custom_font_service.dart` | ⏭️ требует проверки на устройстве | +| H11 | Убрать `as dynamic` в message_bubble | `message_bubble.dart` | ✅ (сессия 4) | +| H14 | Типизированный ContactInfo/ChatInfo | `backend/modules/*`, экраны | ✅ (сессия 4: ContactInfo; сессия 5: ChatInfo) | + +**Сессия 1 итог (2026-07-04):** закрыто 17 пунктов (все локальные high + быстрые победы). `flutter analyze` — **0 ошибок, 0 новых предупреждений**. Изменения в рабочем дереве, не закоммичены. + +Сделано: убрано глотание ошибок в Outbox/switchAccount (+вызыватели показывают уведомление); прокси-креды переведены в secure storage (с миграцией старых plaintext); debug-лог теперь через общий `redactForLog`; батч-префетч контактов; popUntil чинит возврат на SecurityScreen; уведомление-конвенция (SnackBar→showCustomNotification); все `print()`-дебаги вырезаны из релиза; общий `utils/ids.dart` (secure UUID) вместо 3 копий; `SelfCheckService` пауза/резюм по lifecycle; last-seen через общий хелпер; 4 privacy-запроса → 1; tz-инициализация один раз; guard на push-хендлеры; логаут чистит spoof-состояние; `formatPhone` не компилит RegExp каждый вызов. + +Обнаружено попутно: `_showMessageNotification` / `_showCallNotification` в `push_service.dart` — мёртвый Dart-код (живой путь уведомлений — нативный Kotlin FCM). Помечено к удалению отдельной сессией (риск каскада по хелперам `_avatarBytes`/`_appendHistory`/…). + +> ⏭️-пункты — следующие сессии: крупные декомпозиции (chat_screen/message_bubble/ChatsModule), общий HTTP-хелпер аплоада, PersistedSetting, типизированные ContactInfo/ChatInfo, CustomFontService (нужна проверка на устройстве) — требуют отдельного плана и прогонов сборки. + +### Сессия 2 (2026-07-04) — мелкое/среднее + +| Находка | Файлы | Статус | +|---------|-------|--------| +| Общие `formatDurationClock` + `formatFileStamp`; убраны локальные `_fileStamp`×2 и `_fmt` | `format.dart`, `traffic_monitor_screen.dart`, `debug_menu_screen.dart`, `video_player_screen.dart` | ✅ | +| Общий `Debouncer` вместо ручных Timer-дебаунсов | `utils/debouncer.dart`, `search_screen.dart`, `appearance_screen.dart` | ✅ | +| Перф: поиск страны не лоуэркейсит весь список на каждый ввод | `select_country_screen.dart` | ✅ | +| `sendFileMessage`: убран лишний фиксированный 3s-sleep (retry-цикл покрывает) | `messages.dart` | ✅ | +| Дедуп извлечения серверной ошибки (`_throwSendError`) в sendMessage/forwardMessage | `messages.dart` | ✅ | +| Логирование вместо проглатывания ошибок (Polls/CallBridge/resolveContacts) | `polls.dart`, `call_bridge.dart`, `calls.dart` | ✅ | +| Дедуп парсинга arch из `Platform.version` (Linux/Windows) | `api.dart` | ✅ | + +### Сессия 3 (2026-07-04) — крупная декомпозиция chat_screen + +| Находка | Файлы | Статус | +|---------|-------|--------| +| H8/H9: список сообщений больше не ребилдится на изменение высоты композера — высота вынесена в отдельный спейсер-item (index 0), убран из `padding`/гейта | `chat_screen.dart` | ✅ | +| H8/H9: read-receipt больше не ребилдит весь список — статус-галочка `sent→read` реактивна через per-icon `ValueListenableBuilder(_otherReadTime)` внутри бабла (текст + голос) | `chat_screen.dart`, `message_bubble.dart` | ✅ | +| H15 (шаг 1): список вынесен в отдельный виджет `_ChatMessageList` (кэш-инстанс + `_messageListKey`) — 24 `setState` предка больше не каскадят в список; список ребилдится только по `_messagesRev`. Добавлен `_bumpMessages()` в точки смены `chat`/`_isLoadingMore`/визуального стиля | `chat_screen.dart` | ✅ | +| H4: `PersistedSetting`/`PersistedEnum` (`persisted_setting.dart`); мигрированы ~16 простых классов с сохранением статических фасадов; `load()` теперь self-assign; из `main.dart` убран парный блок из 18 присвоений (→ `Future.wait`). Бэспоку: `AppThemeSchedule` (self-assign), `AppIconConfig`/`KometSettings`/`AppAccent` (иной паттерн) | `core/config/*`, `main.dart` | ✅ | +| H3: единый `_sendHttpRequest` (жизненный цикл сокета/заголовки из Map) + `_withProgress` (троттл-прогресс) + единый билдер заголовков; 5 upload-путей переведены на примитивы, поведение сохранено (порядок заголовков, статус-vs-полное чтение, отмена, прогресс) | `file_uploader.dart` | ✅ | + +**Сессия 3 итог (2026-07-04):** H8/H9 закрыты полностью (главная перф-проблема); первый шаг декомпозиции H15 — список изолирован от каскадных ребилдов предка. `flutter analyze` — **0 ошибок, 0 новых предупреждений**. Дальше по H15: вынести ChatController/ChatComposer/ChatSearchOverlay/ChatSelectionBar; H16 — разнести бабблы по типам. + +### Сессия 4 (2026-07-04) — H16/H11/H14 + шаг H15 + +| Находка | Файлы | Статус | +|---------|-------|--------| +| H11: убраны все 10 `as dynamic` — `_buildVideoAttachment`/`_playVideo`/`_buildFileAttachment`/`_downloadFile` типизированы `VideoAttachment`/`FileAttachment`, сужение один раз в диспетчере `_buildGenericAttachment` | `message_bubble.dart` | ✅ | +| H16 (шаг 1): два stateful-плеера вынесены в отдельные файлы — `VoiceMessageBubble`(+`_WaveformPainter`) и `VideoNoteBubble`; реактивность голосового статуса через `otherReadTime` сохранена; `message_bubble.dart` 3521→2836 строк | `message_bubble.dart`, `widgets/attachment/bubbles/voice_bubble.dart`, `widgets/attachment/bubbles/video_note_bubble.dart` | ✅ | +| H14: типизированный `ContactInfo` (+`ContactName`) с каноническим `displayName` (приоритет ONEME → первый непустой label); парсинг один раз на границе `ContactInfoFetch` (`InfoCache`); удалены 5 ручных экстракторов (`_contactName`×2, `_displayName`, `_peerName`, `_nick`), контакт теперь рендерит одно имя на всех экранах | `models/contact_info.dart`, `core/cache/info_cache.dart`, `calls/call_screen.dart`, `contacts/contact_profile_screen.dart`, `contacts/nfc_exchange_sheet.dart`, `chats/chat_info_screen.dart`, `commands/info_command.dart`, `contacts/contacts_tab.dart` | ✅ | +| H15 (шаг 2): prank-пасхалка (state + `checkTrigger`/`pinkTheme`/reveal/cleanup, ~95 строк) вынесена в `ChatPrankController`; fragile message-core (`_messages`/`_messagesRev`/`_isLoadingMore`/`_ChatMessageList`) не тронут | `chat_screen.dart`, `chats/chat/chat_prank_controller.dart` | ✅ | + +**Сессия 4 итог (2026-07-04):** H11 и H14(ContactInfo) закрыты полностью; H16 и H15 продвинуты (плееры + prank вынесены). `flutter analyze` — **0 ошибок, 0 новых предупреждений** (только 2 известных пред-существующих: dead push-код). Изменения в рабочем дереве, не закоммичены. Дальше: H16 — per-type content-бабблы (photo/poll/share/call/location/contact/file) в дисп­етчер по `AttachmentType` (требует продвижения `_BubbleCtx`→общий контекст с `message`/колбэками); H15 — ChatController(ChangeNotifier: история/пагинация/подписки), ChatComposer, ChatSearchOverlay; H14 — типизированный ChatInfo (снять int/String-коэрцию в `chat_info_screen`). + +### Сессия 5 (2026-07-04) — H16 (шаг 2) + H15 (шаг 3) + H14-добивка + +| Находка | Файлы | Статус | +|---------|-------|--------| +| H16 (шаг 2): приватный `_BubbleCtx` продвинут в публичный `BubbleContext` (несёт `message`/`isMe`/`myId`/`chatType`/`overrideStatus`/`otherReadTime`/`uploadProgress`/`onStickerTap` + общие рендер-хелперы `clockText`/`meta`/`caption`/`compactTime`/`statusIcon`/`deletedIcon`). 12 per-type content-бабблов вынесены по `AttachmentType` в `widgets/attachment/bubbles/` (poll, share, call, location, contact, sticker, photo(+грид/тайлы/оверлеи/viewer), video(+плеер), file(+download), forwarded photo/generic/contact). `MessageBubble` теперь тонкий диспетчер (chrome: build/bubble/makeCtx + text/control/reply/keyboard/reactions/sender). `message_bubble.dart` 2832→1225 строк; compact-time-оверлей на фото/видео и `_buildMeta/_buildCaption/_buildCompactTime` сохранены (стали методами `BubbleContext`) | `message_bubble.dart`, `widgets/attachment/bubbles/{bubble_context,poll,share,call,location,contact,sticker,photo,video,file,forwarded}_bubble.dart` | ✅ | +| H15 (шаг 3, часть 1): создан `ChatController extends ChangeNotifier` (`chat/chat_controller.dart`), владеющий message-render-state (`messages`/`messagesRev`/`hasMoreHistory`/`isLoadingMore`/`historyKickedOff`) + чистые операции `bump()`/`prependOlder()`. `_ChatScreenState` делегирует через прозрачные геттеры/сеттеры — все ~118 сайтов (`_messages`/`_messagesRev`/флаги) и `_ChatMessageList` (слушает `host._messagesRev`) не тронуты; `_bumpMessages`-гейт и `_combinedItemsCache`-инвалидация сохранены. Безопасный шов; миграция history/pagination/subscriptions-**логики** в контроллер — часть 2 | `chat_screen.dart`, `chats/chat/chat_controller.dart` | 🔧 | +| H14-добивка: типизированный `ChatInfo` (`models/chat_info.dart`, зеркалит `ContactInfo`: `raw` + типобезопасные `participantIds`/`adminIds`/`owner` + `isAdmin`/`isOwner`/`participantsCount`/`link`/`description`). Парсинг один раз на границе `ChatInfoFetch` (`InfoCache`). Снята int/String-коэрция: `admins.containsKey(id.toString())‖containsKey(id)` → `chatInfo.isAdmin(id)`; `k is int ? k : int.tryParse(...)` × 2 → `participantIds`. `cacheServerChat(chatInfo.raw, …)` в `chats.dart` (единственный бэкенд-риппл) | `models/chat_info.dart`, `core/cache/info_cache.dart`, `chats/chat_info_screen.dart`, `backend/modules/chats.dart` | ✅ | + +**Сессия 5 итог (2026-07-04):** H16 закрыт полностью (декомпозиция `message_bubble` 2832→1225; 12 per-type бабблов в `bubbles/` + публичный `BubbleContext`; экстракция построчно перепроверена скептик-агентом против пред-экстракшн-снапшота — parity OK по всем 11 виджетам, включая photo-radius-логику и call-subtitle-интерполяцию); H14 закрыт полностью (типизированный `ChatInfo`, коэрция снята); H15 продвинут (шаг 3 часть 1 — `ChatController`-шов владеет message-state). Новых файлов: 12 (11 бабблов + `bubble_context`) + `chat_controller.dart` + `models/chat_info.dart`. `flutter analyze` — **0 ошибок, 0 новых предупреждений** (только 2 известных пред-существующих: dead push-код `_showMessageNotification`/`_showCallNotification`). Изменения в рабочем дереве, не закоммичены. Дальше (Сессия 6): H15 шаг 3 часть 2 — миграция history/pagination-**логики** (`_loadHistory`/`_loadMoreHistory`/`_loadOlderFromDb`/`_persistSessionCache`/`_applyMergedMessages`) и подписок в `ChatController` через коллбэки/хуки (onLoadingFinished/onMessagesChanged), затем `ChatComposer`/`ChatSearchOverlay`. + +### Промпт для Сессии 6 + +``` +Продолжаем рефакторинг Komet по AUDIT_REPORT.md (секция «Прогресс исправлений», Сессии 1–5 закрыты). +Работа НЕ закоммичена — пиши код, git-коммиты не делай. Flutter: /home/a/flutter/bin/flutter. +Норма: 0 errors, 0 новых warnings (известны 2 пред-существующих: dead push-код +_showMessageNotification/_showCallNotification в push_service.dart). Утилиты/модели ИСПОЛЬЗУЙ: +core/utils/{format,ids,debouncer,log_redact}.dart, core/config/persisted_setting.dart, +models/{contact_info,chat_info}.dart, widgets/attachment/bubbles/bubble_context.dart, +screens/chats/chat/{chat_controller,chat_prank_controller}.dart. Конвенции: без комментариев; +showCustomNotification (не SnackBar); правильный рефактор вместо хака. Метод: sonnet-агенты на +непересекающихся файлах — точные находки+локации+решение+рамки, потом сам ревьюь диффы и гоняй analyze. +Маленькие шаги. + +Приоритеты: +1. H15 (шаг 3, часть 2) — перенести ЛОГИКУ истории/пагинации в ChatController(ChangeNotifier). + Сейчас контроллер владеет только message-state (messages/messagesRev/hasMoreHistory/isLoadingMore/ + historyKickedOff) + bump()/prependOlder(); State делегирует через геттеры/сеттеры. Перенести чистые + методы: _loadOlderFromDb (DB), _persistSessionCache (нужны myId+chatId), и переработать оркестраторы + _loadHistory/_loadRemainingHistory/_loadMoreHistory/_maybeLoadMoreHistory/_applyMergedMessages так, + чтобы данные-логика жила в контроллере, а UI-сайд-эффекты (setState/_onLoadingFinished/ + _loadForwardedSenderNames/_loadGroupSenderNames/_syncReactionNotifiersFromMessages/scroll) остались + в State и вызывались через коллбэки контроллера (onLoadingFinished/onMessagesChanged/onError). + ВНИМАНИЕ: _bumpMessages чистит _combinedItemsCache (State-концерн) + bump контроллера — не потерять + инвалидацию кэша; _ChatMessageList слушает host._messagesRev; myId резолвится асинхронно в _loadHistory. + Мелкими шагами, analyze после каждого. +2. H15 — вынести подписки (_uploadSub/_pushSub/_messageEventSub/_connSub/_voiceAmpSub) — оценить, + что можно вынести без переноса UI-хендлеров (_onIncomingPush/_onMessageEvent — сильно UI-связаны). +3. H15 — ChatComposer (композер: текст/вложения/запись голоса/высота _composerHeight) и + ChatSearchOverlay (поиск по сообщениям) — если хватит бюджета. + +H16 и H14 закрыты. После каждого пункта обнови AUDIT_REPORT.md (✅/🔧) и добавь строку в «Сессию 6». +В конце — промпт для Сессии 7. +``` + +### Сессия 6 (2026-07-04) — H15 (шаг 3, часть 2): history/pagination-логика в контроллер + +| Находка | Файлы | Статус | +|---------|-------|--------| +| H15 (шаг 3, часть 2): history/pagination-**логика** перенесена из `_ChatScreenState` в `ChatController`. Контроллер теперь владеет `chatId`/`myId` (State-геттеры `_myId`/`chatId` делегируют) + чистыми данными-методами: `loadInitialFromDb`/`loadOlderFromDb` (DB-страницы), `mergeMessages`+`_sameMessage` (dedup/merge/sort), `persistSessionCache`, и полным оркестратором пагинации `loadMoreHistory(onLoadingStarted/onLoaded/onError)`. UI-сайд-эффекты остались в State и вызываются через коллбэки: `_bumpMessages` (инвалидация `_combinedItemsCache` + rev), `_syncReactionNotifiersFromMessages`/`_pruneReactionNotifiers`, `_loadForwardedSenderNames`/`_loadGroupSenderNames`, `setState`/`_isLoading`/`_onLoadingFinished`. `_loadRemainingHistory`/`_loadHistory` остались тонкими State-оркестраторами (владеют `_previewChat`/`_isLoading`/preview-flow — виджет-концерны), делегируя данные-операции контроллеру. Кэш-инвалидация сохранена (rev-driven: `mergeMessages`/`prependOlder` бампят `messagesRev`, ключ `_buildCombinedItems` = hash(rev,length)). Константы `_historyPageSize`/`_historyInitialLimit` переехали в контроллер | `chat_screen.dart` (−~90 строк логики), `chats/chat/chat_controller.dart` (35→167 строк) | 🔧 | +| H15: оценка подписок (приоритет 2) — `_pushSub`→`_onIncomingPush`, `_messageEventSub`→`_onMessageEvent`, `_connSub`→`_recomputeHeaderStatus` неотделимы от сильно-UI-связанных хендлеров (mark/typing/delayed, header-статус); `_uploadSub`/`_voiceAmpSub` принадлежат будущему `ChatComposer` (upload-прогресс/запись голоса). Standalone-вынос `.listen()` без хендлеров — низкоценный churn; отложено к Composer-экстракции | `chat_screen.dart` | ⏭️ (оценено) | + +**Сессия 6 итог (2026-07-04):** H15 продвинут (шаг 3 часть 2 — history/pagination-логика в `ChatController`; пагинация/merge/DB-загрузки/session-cache теперь тестируемы без виджета). Подписки оценены и отложены (неотделимы от UI-хендлеров или принадлежат Composer). Parity перепроверена скептик-агентом против **HEAD-бейзлайна** (`git show HEAD:…`, не рабочего дерева): регрессий нет по всем путям — guard/`mounted`-гейты сохранены, rev-driven инвалидация кэша достаточна, `logger.e` не дублируется, `fullDecoded.isNotEmpty`≡`fullRows.isNotEmpty` (`fromDbRowsAsync` мапит 1:1), `messagesRev` не диспозится дважды, геттер/сеттер-шимы не могут рассинхронить `_myId`/флаги/`_messages`. `flutter analyze` (полный проект) — **0 ошибок, 0 новых предупреждений** (только пред-существующие: 2 dead-push-warning + SDK-deprecation `cacheExtent`/`axisAlignment`, всё вне зоны правок). Изменения в рабочем дереве, не закоммичены. Дальше (Сессия 7): `ChatComposer` (текст/вложения/запись голоса/`_uploadSub`/`_voiceAmpSub`/`_composerHeight`) — самый крупный оставшийся кусок; затем `ChatSearchOverlay` (поиск — но `_searchAnim` вплетён в интерполяцию высоты хедера, `_openSearchResult` оркестрирует scroll+пагинацию — распутывать аккуратно). + +### Сессия 7 (2026-07-04) — H15: декомпозиция ChatComposer по под-кускам + +| Находка | Файлы | Статус | +|---------|-------|--------| +| H15 (ChatComposer, под-кусок 1 — запись голоса): state-машина записи голосовых вынесена из `_ChatScreenState` в `VoiceRecordController` (`chat/voice_record_controller.dart`, образец `ChatPrankController`: callbacks `contextOf`/`isMounted`/`myId`/`onRecorded`). Контроллер владеет `AudioRecorder`, 7 UI-нотифаерами (`isRecording`/`elapsedMs`/`cancelDrag`/`amplitude`/`waveRev`/`locked`/`lockDrag` — экспонированы как `ValueListenable`-геттеры + сырой `amps`), `Stopwatch`/`Timer`/`_ampSub`/`_path`/флаги, методами `start`/`handleDrag`/`handleEnd`/`stop`/`dispose` и энкодингом `_transcodeWavToOgg`. Пороги `minMs`/`cancelThreshold` — публичные статик-консты (переиспользуются note-хендлерами), `_lockThreshold` приватный. Send-путь (`_sendVoice`/`_buildWave`) остался в State (сильно связан с `_messages`/outbox/`_photoUploadProgress`/`_bumpMessages`) — контроллер отдаёт готовый файл через `onRecorded`. UI-методы (`_recordingButtonVisual`/`_voiceLockChip`/`_buildVoiceRecordingIndicator`) остались в State, читают нотифаеры через `_voiceRec.*`. Убраны неиспользуемые импорты `record`/`opus_ogg_encoder`/`path_provider` из `chat_screen`. Parity перепроверена скептик-агентом против HEAD — PARITY OK | `chat_screen.dart` (−~180 строк), `chats/chat/voice_record_controller.dart` (новый, 268 строк) | ✅ | +| H15 (ChatComposer, под-кусок 2 — видео-кружки): state-машина видео-заметок вынесена в `VideoNoteController` (`chat/video_note_controller.dart`; callbacks `contextOf`/`isMounted`/`onRecorded`/`formatElapsed`). Контроллер владеет `NativeVideoNoteRecorder`, 6 нотифаерами (`videoNoteMode`/`textureId`/`camReady`/`isRecording`/`elapsedMs`/`cancelDrag` — `videoNoteMode`/`camReady`/`isRecording` экспонированы как `ValueListenable`), `Stopwatch`/`Timer`/флаги/`OverlayEntry`, методами `toggleMode`/`_initCamera`/`_disposeCamera`/`start`/`handleDrag`/`handleEnd`/`stop`/`dispose` + оверлеем предпросмотра камеры (`_showOverlay`/`_hideOverlay`, `Texture` в `ClipOval`, вставка через `Overlay.of(rootOverlay)`). Пороги переиспользуют `VoiceRecordController.cancelThreshold`/`.minMs`. Send-путь `_sendVideoNote` остался в State (`_myId==0`-guard/`_messages`/outbox) — контроллер зовёт через `onRecorded`; `_formatVoiceElapsed` остался в State (общий с voice-индикатором), прокинут коллбэком `formatElapsed`. Убран неиспользуемый импорт `native_video_note_recorder` из `chat_screen`. Parity перепроверена скептик-агентом против HEAD — PARITY OK | `chat_screen.dart` (−~190 строк), `chats/chat/video_note_controller.dart` (новый, 243 строки) | ✅ | +| H15 (ChatComposer, под-кусок 3 — панель слэш-команд): логика саджест-панели команд вынесена в `CommandPanelController` (`chat/command_panel_controller.dart`; callbacks `vsync`/`textOf`/`onSelected`). Контроллер владеет `AnimationController anim` (200ms, экспонирован), `ValueNotifier> matches` (экспонирован), приватным `_visible`, чистым матчером `_matching` (== `_matchingCommands`: `/`-префикс, no-whitespace, exact-name→скрыть, startsWith-фильтр) и `update()` (== `_updateCommandPanel`: listEquals-гейт + visible-гейт → forward/reverse). Сам подписывается на `AppCommands.current` в конструкторе и отписывается в `dispose` (создаётся ЭАГЕРНО — `late final` без инициализатора, присваивается в `initState` на месте старого `addListener`, чтобы регистрация слушателя не отложилась). `_onCommandSelected` (мутация `_messageController`/focus) остался в State, зовётся через `onSelected`; `_buildCommandPanel` читает `_commandPanel.anim`/`.matches`/`.select`. Parity перепроверена скептик-агентом против HEAD — PARITY OK | `chat_screen.dart` (−~35 строк), `chats/chat/command_panel_controller.dart` (новый, 65 строк) | ✅ | +| H15 (ChatComposer): оценка оставшихся под-кусков — **attachment-панель** (`_showAttachmentPanel`/`_attachAnim`) — `_attachAnim` вплетён в композер-build в ~10 местах (интерливленные `AnimatedBuilder`/`_ButtonClipper`/`_HistoryStrip`), контроллер владел бы только `AnimationController`, который build всё равно читает → тонкий вынос, много точек касания, низкая ценность (как отложенные подписки в С6); **стикер-панель** (`_showStickerPanel`/`_stickerAnim`) — связана с typing-индикатором (`_stickerTypingTimer`/`_sendStickerTyping`/ghostMode), focus-интерплеем (`_onComposerFocusChanged`) и send-путём (`_sendSticker`→`_sendAttachMessage`); **upload** (`_uploadSub`/`fileUploader`/`_uploadStatus`/`_photoUploadProgress`) — `_photoUploadProgress` разделяется всеми send-путями (voice/photo/video/note) и завязан на outbox/`_bumpMessages` → принадлежит State. Отложено к отдельной оценке | `chat_screen.dart` | ⏭️ (оценено) | + +**Сессия 7 итог (2026-07-04):** ChatComposer декомпозирован по трём автономным под-кускам: запись голоса (`VoiceRecordController`, 268 строк), видео-кружки (`VideoNoteController`, 243 строки), панель слэш-команд (`CommandPanelController`, 65 строк). Все три — по образцу `ChatPrankController` (callback-швы, send-путь остаётся в State). Каждый под-кусок: `flutter analyze` после экстракшна + parity скептик-агентом против **HEAD** (не рабочего дерева) — все три **PARITY OK** (методы verbatim, нотифаеры диспозятся ровно раз, порядок forward/reverse и listEquals-гейты сохранены, AppCommands-слушатель эагерный, send-пути байт-идентичны). Оставшиеся под-куски (attach/sticker-панели, upload) оценены как тонко-анимационный churn либо send/outbox-связанные → отложены. `flutter analyze` (весь проект) — **0 ошибок, 0 новых предупреждений** (только пред-существующие: 2 dead-push-warning + SDK-deprecation `cacheExtent`/`axisAlignment`, вне зоны). Изменения в рабочем дереве, не закоммичены. Новых файлов: 3 (`voice_record_controller`/`video_note_controller`/`command_panel_controller`). Дальше (Сессия 8): при желании — StickerPanelController (с typing-таймером через коллбэк), либо переход к приоритету 2 (`_loadHistory`/`_loadRemainingHistory` → контроллер) или 3 (`ChatSearchOverlay`, осторожно — `_searchAnim` в интерполяции высоты хедера). + +### Промпт для Сессии 8 + +``` +Продолжаем рефакторинг Komet по AUDIT_REPORT.md (секция «Прогресс исправлений», Сессии 1–7 закрыты). +Работа НЕ закоммичена — пиши код, git-коммиты не делай. Flutter: /home/a/flutter/bin/flutter. +Норма: 0 errors, 0 новых warnings (известны пред-существующие: dead push-код +_showMessageNotification/_showCallNotification в push_service.dart; SDK-deprecation cacheExtent/ +axisAlignment в chat_screen.dart — НЕ трогать, вне зоны). Утилиты/модели ИСПОЛЬЗУЙ: +core/utils/{format,ids,debouncer,log_redact}.dart, core/config/persisted_setting.dart, +models/{contact_info,chat_info}.dart, widgets/attachment/bubbles/bubble_context.dart, +screens/chats/chat/{chat_controller,chat_prank_controller,voice_record_controller, +video_note_controller,command_panel_controller}.dart. Конвенции: без комментариев; +showCustomNotification (не SnackBar); правильный рефактор вместо хака. Метод: sonnet-агенты на +непересекающихся файлах — точные находки+локации+решение+рамки, потом сам ревьюь диффы и гоняй analyze. +Маленькие шаги, analyze после КАЖДОГО под-куска. Пиши минимум текста — только код и мысли. + +Контекст: С7 вынесла из chat_screen три автономных под-контроллера композера (voice/video-note/ +command-panel) по образцу ChatPrankController. send-пути (_sendVoice/_sendVideoNote/_sendSticker/ +_sendMessage) и _photoUploadProgress ОСТАЛИСЬ в State (завязаны на _messages/outbox/_bumpMessages). + +Приоритеты (по одному, analyze+parity-скептик против HEAD после каждого): +1. H15 — StickerPanelController: _showStickerPanel/_stickerAnim + typing-таймер (_stickerTypingTimer/ + _sendStickerTyping, ghostMode-гейт) + toggle-логика (_onStickerPanelToggle, focus-интерплей в + _onComposerFocusChanged). Callbacks: vsync + onSendTyping (messagesModule.sendTyping через State). + Экспонировать anim+showPanel для _buildStickerPanel. _sendSticker (send→_sendAttachMessage) ОСТАВИТЬ + в State. ОЦЕНИ сначала focus-интерплей: _onComposerFocusChanged читает _showStickerPanel — если + расплывётся в callback-суп, оставь панель в State и переходи к п.2/3. +2. H15 — attachment-панель: _showAttachmentPanel/_attachAnim + _onAttachPanelToggle. ВНИМАНИЕ: _attachAnim + читается build-ом в ~10 местах (AnimatedBuilder/_ButtonClipper/_HistoryStrip/_recordingButtonVisual + область) — контроллер владел бы только AnimationController, который build всё равно читает. Оценено в + С7 как тонкий churn; делай ТОЛЬКО если найдёшь реальную логику сверх toggle (иначе пропусти). +3. H15 — ChatController: перенос _loadHistory/_loadRemainingHistory (тонкие State-оркестраторы; мешает + владение _previewChat/_isLoading — отдать через applyMerged + onPreview/onLoadingFinished, ТОЛЬКО если + не разрастётся callback-суп). +4. H15 — ChatSearchOverlay: _searchController/_searchResults/_runSearch/_openSearchResult. ВНИМАНИЕ: + _searchAnim вплетён в интерполяцию высоты хедера (lerp _glossyHeaderHeight↔_glossySearchHeight) и + _openSearchResult оркестрирует scroll+_loadMoreHistory. Распутывать осторожно или отложить. + +H16 и H14 закрыты. После каждого под-куска обнови AUDIT_REPORT.md (✅/🔧) и добавь строку в «Сессию 8». +Parity перепроверяй скептик-агентом против HEAD (git show HEAD:…), а не рабочего дерева. В конце — промпт +для Сессии 9. +``` + +### Сессия 8 (2026-07-04) — H15: StickerPanelController + закрытие H15-логики + +| Находка | Файлы | Статус | +|---------|-------|--------| +| H15 (ChatComposer, под-кусок 4 — стикер-панель): sticker-панель вынесена в `StickerPanelController` (`chat/sticker_panel_controller.dart`; callbacks `vsync`/`onSendTyping`). Контроллер владеет `AnimationController anim` (240/200, экспонирован), `ValueNotifier showPanel` (экспонирован), `double panelHeight` (300), `Timer? _typingTimer`. Сам подписывается на `showPanel` в конструкторе → `_onToggle` (== `_onStickerPanelToggle`: forward+`_sendTyping`+periodic(4s); else reverse+cancel). `_sendTyping` (== `_sendStickerTyping`: ghostMode-гейт → `onSendTyping`, прокинут `messagesModule.sendTyping(chatId,'STICKER')`) и `hide()` инкапсулированы. Focus-интерплей **оценён и оставлен тонким в State**: `_toggleStickerPanel` (keyboard-инсет/`FocusManager`/`requestFocus`) и `_onComposerFocusChanged` (читают `_stickers.showPanel.value`) — фокус-операции = виджет-концерн, не расплылись в callback-суп. `_sendSticker` (send→`_sendAttachMessage`) остался в State, зовёт `_stickers.hide()`. `_buildStickerPanel` читает `_stickers.anim`/`.panelHeight`. Parity перепроверена построчно против HEAD (`git show HEAD:…`) — PARITY OK (поля/длительности/toggle/focus/typing/send/build эквивалентны; teardown-шаги все по разу) | `chat_screen.dart` (−~40 строк), `chats/chat/sticker_panel_controller.dart` (новый, 54 строки) | ✅ | +| H15 (приоритет 2 — initial-load-оркестратор): `_loadRemainingHistory` (58 строк реальной оркестрации: DB-инит-загрузка → `wasHistoryFetched`-shortcut → preview/`ensureChatCached`/`subscribeChat`/`fetchHistory`/`markHistoryFetched`/`reconcileDeleted`/re-load/`reconcileLastMessage` + обработка ошибок) перенесён в `ChatController.loadRemainingHistory` — сиблинг уже-мигрированного `loadMoreHistory`, тот же callback-шов. 4 коллбэка (по образцу `loadMoreHistory`×3): `onApplyMerged` (→ State `_applyMergedMessages`: setState+reaction-нотифаеры+`persistSessionCache` — UI-концерн), `onLoadingFinished` (→ `setState(_isLoading=false; _onLoadingFinished())`), `onPreview` (→ `_previewChat=true`), `onSenderNames` (→ `_loadForwardedSenderNames`+`_loadGroupSenderNames`). `mounted`-гейты → `isMounted()`. `_loadHistory` оставлен тонким State-оркестратором (myId-резолв + presence(DIALOG) + scheduled-count — виджет-flow), делегирует данные. Parity против HEAD — PARITY OK (каждая ветка 1:1; `fullDecoded.isNotEmpty≡fullRows.isNotEmpty` через 1:1 `fromDbRowsAsync`; `historyInitialLimit`==50; `onApplyMerged`-tearoff сохраняет дефолт `markLoaded:false`) | `chat_screen.dart` (−~50 строк), `chats/chat/chat_controller.dart` (+~52 строки) | ✅ | +| H15 (приоритет 3 — ChatSearchOverlay, слой (а): чистая логика поиска): данные-логика поиска вынесена в `ChatSearchController` (`chat/chat_search_controller.dart`; callbacks `chatId`/`isMounted`). Контроллер владеет `searchController`(TextEditingController)/`searchMode`/`results`/`loading`/`performed`(нотифаеры, экспонированы)/`_debounce`/`_seq`, сам подписывается на `searchController`→`_onTextChanged` (== `_onSearchTextChanged`: trim/cancel/empty→seq++reset/else debounce 300ms→runSearch), `runSearch` (== `_runSearch`: seq-guard `isMounted()&&seq==_seq`, try/catch/`logger.e`, map→`MessageSearchResult`), `submit` (cancel+run, для onSubmitted/search-кнопки), `reset` (close-time сбросы). `_MessageSearchResult`→публичный `MessageSearchResult` (`chat/message_search_result.dart`, `fromRaw` байт-идентичен). **Слой (б): `_searchAnim`/`_openSearchResult` ОСТАВЛЕНЫ в State** — `_searchAnim` вплетён в lerp высоты хедера (`_glossyHeaderHeight`↔`_glossySearchHeight`, `_buildAppBar`) + ещё ~6 build-сайтов; `_openSearchResult` оркеструет scroll+`_loadMoreHistory`+`_messages`-guard-цикл. `_openSearch`/`_closeSearch` тоже в State (владеют `_searchAnim`+`_searchFocusNode`), делегируют данные (`_search.searchMode`/`.reset()`). Parity против HEAD — PARITY OK (`_closeSearch`-reorder: `unfocus`/`anim.reverse` подняты выше сбросов — нет data-зависимости; `searchController.clear()` re-entry в живой листенер сохранён на той же относительной позиции; `_seq` инкрементится ровно 2× как в HEAD; teardown-шаги все по разу). `logger`-импорт удалён из `chat_screen` (последние два `logger.e` уехали в контроллеры) | `chat_screen.dart` (−~75 строк), `chats/chat/chat_search_controller.dart` (новый, 92 строки), `chats/chat/message_search_result.dart` (новый, 35 строк) | ✅ | +| H15 (приоритет 4 — attach-панель + upload): **оценено, оставлено в State** (закрывает H15-**логику**). `_onAttachPanelToggle` — чистый forward/reverse `_attachAnim` без сайд-эффектов (в отличие от sticker: нет typing-таймера/ghostMode), `_attachAnim` читается build-ом в ~8 местах (интерливленные `AnimatedBuilder`/клипперы) — контроллер владел бы только `AnimationController`, логики сверх toggle НЕТ → вынос = чистый churn. Upload: `_photoUploadProgress` (Map по outbox-`tempId`) пишется ВСЕМИ send-путями (photo/voice/video-note), читается message-list, чистится на send-complete/dispose; `_uploadSub`/`_uploadStatus` привязаны к photo-send-flow → send/outbox-связаны, принадлежат State | `chat_screen.dart` | ⏭️ (оценено, оставить) | + +**Сессия 8 итог (2026-07-04):** **H15 по ЛОГИКЕ закрыт.** Вынесены последние два автономных под-контроллера композера/поиска: `StickerPanelController` (54 строки — anim+showPanel+typing-таймер с ghostMode-гейтом; focus-интерплей оставлен тонким в State) и `ChatSearchController` (92 строки — вся данные-логика поиска: debounce/seq/notifiers/runSearch/submit/reset; `_searchAnim`+`_openSearchResult`+`_openSearch`/`_closeSearch` оставлены в State по слою (б), т.к. `_searchAnim` вплетён в lerp высоты хедера, а `_openSearchResult` оркеструет scroll+пагинацию). `_MessageSearchResult`→публичный `MessageSearchResult` (отдельный файл). Плюс `ChatController.loadRemainingHistory` (initial-load-оркестратор, сиблинг `loadMoreHistory`). attach-панель (чистый toggle-anim, логики сверх toggle нет) и upload (`_photoUploadProgress`/`_uploadSub` — send/outbox-связаны) **оценены и обоснованно оставлены в State**. Вся отделимая логика H15 либо вынесена, либо обоснованно оставлена в State → **H15-логика завершена**. Каждый под-кусок: `flutter analyze` после экстракшна; parity скептик-агентом против **HEAD** (`git show HEAD:…`) — все PARITY OK (sticker: поля/длительности/teardown; initial-load: каждая ветка 1:1, `fullDecoded.isNotEmpty≡fullRows.isNotEmpty`; search: `_closeSearch`-reorder без data-зависимости, `clear()`-re-entry сохранён, `_seq` 2× как в HEAD). `flutter analyze` (весь проект) — **0 ошибок, 0 новых предупреждений** (только пред-существующие: 2 dead-push + SDK-deprecation cacheExtent/axisAlignment + unnecessary_underscores/token_storage вне зоны). Изменения в рабочем дереве, не закоммичены. Новых файлов: 3 (`sticker_panel_controller`/`chat_search_controller`/`message_search_result`). H16/H14 закрыты ранее. **Остаётся только H15b (низкий приоритет, косметика размера):** «тонкая композиция» — перенос build-дерева (композер/поиск/selection/call-UI) из `_ChatScreenState` (~6940 строк) в виджет-файлы; это UI-build, не логика, и send-пути (`_sendVoice`/`_sendVideoNote`/`_sendSticker`/`_sendMessage`) с `_photoUploadProgress` остаются в State. + +### Промпт для Сессии 9 + +``` +Продолжаем рефакторинг Komet по AUDIT_REPORT.md (секция «Прогресс исправлений», Сессии 1–8 закрыты). +Работа НЕ закоммичена — пиши код, git-коммиты не делай. Flutter: /home/a/flutter/bin/flutter. +Пиши АБСОЛЮТНЫЙ МИНИМУМ текста — только код и мысли. +Норма: 0 errors, 0 новых warnings (известны пред-существующие, НЕ трогать: dead push-код +_showMessageNotification/_showCallNotification в push_service.dart; SDK-deprecation cacheExtent/ +axisAlignment в chat_screen.dart и connection_status.dart; unnecessary_underscores в +registration_screen/login_success_screen/theme_reveal; token_storage encryptedSharedPreferences). +Утилиты/модели/контроллеры ИСПОЛЬЗУЙ (не дублируй): core/utils/{format,ids,debouncer,log_redact}.dart, +core/config/persisted_setting.dart, models/{contact_info,chat_info,message_search_result}.dart, +widgets/attachment/bubbles/bubble_context.dart, screens/chats/chat/{chat_controller, +chat_prank_controller,voice_record_controller,video_note_controller,command_panel_controller, +sticker_panel_controller,chat_search_controller,message_search_result}.dart. Конвенции: без +комментариев; showCustomNotification (не SnackBar); правильный рефактор вместо хака. +Метод (ВАЖНО — эта сессия БОЛЬШАЯ, работай агрессивно и параллельно): активно используй sonnet-агентов +на НЕПЕРЕСЕКАЮЩИХСЯ регионах/файлах, запускай их ПАЧКАМИ в одном сообщении (несколько под-виджетов сразу). +Каждому агенту: точные находки+локации+решение+рамки. Потом САМ ревьюй все диффы и гоняй analyze. +Маленькие атомарные под-куски, analyze после КАЖДОГО. Parity перепроверяй скептик-агентом против HEAD +(git show HEAD:…), а не рабочего дерева. ЦЕЛЬ СЕССИИ: закрыть H15b ПОЛНОСТЬЮ (не «если захочешь»), и если +останется контекст — начать H17. Не мельчи и не откладывай: план на всю сессию сразу, потом исполняй. + +СТАТУС H15: ЛОГИКА ЗАКРЫТА (Сессии 3–8). chat_screen ещё ~6940 строк — это UI-build-дерево. send-пути +(_sendVoice/_sendVideoNote/_sendSticker/_sendMessage/_sendAttachMessage) и _photoUploadProgress/_uploadSub +ОСТАЮТСЯ в State (завязаны на _messages/outbox/_bumpMessages). Контроллеры уже готовы (см. список выше) — +виджеты принимают их как параметры + сайд-эффекты через колбэки. Ничего из ЛОГИКИ больше не двигаем. + +════════ ГЛАВНАЯ ЦЕЛЬ: H15b — «тонкая композиция» build-дерева chat_screen → виджет-файлы ════════ +Задача: снять с _ChatScreenState тысячи строк build-кода, вынеся крупные под-деревья в отдельные +StatelessWidget/StatefulWidget в screens/chats/chat/view/ (новая папка). Логика/state НЕ переезжает — +переезжает ТОЛЬКО build. Виджет получает: нужные контроллеры/нотифаеры/анимации как параметры + колбэки +на действия (send/open/close/tap). Риск parity низкий, но churn высокий → строгая дисциплина: атомарно, +analyze+parity-скептик против HEAD после КАЖДОГО под-виджета (скептик сверяет build verbatim: те же +виджеты/параметры/порядок/условия/ключи, те же нотифаеры-инстансы). + +Порядок (от изолированного к связанному; параллель агентов ВНУТРИ пункта где регионы не пересекаются): +1. SEARCH-VIEW: _searchTopBar/_buildSearchResultsContent/_buildSearchResultTile → SearchView-виджет. + Принимает: ChatSearchController _search, ColorScheme, Animation _searchAnim (как Listenable-параметр, + он остаётся в State — lerp хедера), колбэки onOpenResult(_openSearchResult)/onClose(_closeSearch). + Самый изолированный (данные уже в контроллере). ОЦЕНИ header-lerp: если _searchAnim переплетётся с + _buildAppBar так, что не расщепляется — вынеси только results-content, top-bar оставь. Делай ПЕРВЫМ. +2. COMPOSER-VIEW: _buildComposer + под-панели (_buildAttachmentPanel/_buildStickerPanel/_buildCommandPanel/ + voice-recording-indicator/video-note-кнопка). Дели на ПОД-ВИДЖЕТЫ (attachment-panel/sticker-panel/ + command-panel/composer-bar — отдельные файлы, параллельные агенты). Каждый принимает готовый контроллер + (_stickers/_commandPanel/_voiceRec/_videoNote) + _attachAnim (Listenable-параметр, остаётся в State, + вплетён в ~8 build-сайтов) + send-колбэки (onSendText/onSendSticker/onSendVoice/onSendVideoNote/onAttach*). + Самый крупный кусок — большинство строк тут. +3. SELECTION-BAR: selection-topbar/действия (reply/forward/delete/copy/...) → SelectionBar-виджет. + Принимает _selectedIds/_selectionAnim + колбэки действий. State держит сами действия. +4. CALL/HEADER-UI: _buildAppBar (осторожно — там _searchAnim/_selectionAnim/glossy-lerp высоты) + + header-статус/presence + call-кнопки. Оцени переплетение анимаций; если _buildAppBar не расщепляется + чисто — оставь его в State, вынеси только автономные под-виджеты хедера. +После каждого: обнови «Сводную таблицу» (H15b-строка) и добавь строку в «Сессию 9» с parity-вердиктом. +Веди счётчик: сколько строк ушло из chat_screen (цель — существенно ниже 6940; фиксируй факт). + +════════ ЕСЛИ ОСТАЛСЯ КОНТЕКСТ ПОСЛЕ H15b: начни H17 ════════ +H17: ChatsModule (статический god-модуль) → инстанцируемый repository. Крупный. СНАЧАЛА разведка +агентом (карта: все статические поля/методы ChatsModule, все вызыватели, глобальное состояние/кэши), +потом план миграции (интерфейс репозитория + инстанс в main.dart + постепенная замена вызовов), потом +по слоям. НЕ начинай кодить H17 без карты и плана в отчёте. + +ЗАПАСНОЙ ФРОНТ (если H15b упрётся или для параллельных лёгких побед): «Топ-10 приоритетов» и «Все +находки по темам» в AUDIT_REPORT.md — H6 CustomFontService legacy UA (нужна проверка на устройстве — +пометь и спроси), прочие medium/low дубли/костыли. Сначала перечитай статистику и топ-приоритеты. + +После каждого под-куска обнови AUDIT_REPORT.md (✅/🔧/⏭️) + строка в «Сессию 9». В конце — промпт для Сессии 10. +``` + +### Сессия 9 (2026-07-04) — H15b: тонкая композиция build-дерева chat_screen → виджет-файлы + +Новая папка `screens/chats/chat/view/`. Логика/state НЕ переезжали — только build-деревья; виджеты +принимают контроллеры/нотифаеры/анимации как параметры + сайд-эффекты через колбэки (send/open/close/tap). +Каждый под-виджет: `flutter analyze` после экстракшна + parity-скептик против HEAD. + +| Находка | Файлы | Статус | +|---------|-------|--------| +| H15b (1 — SEARCH-VIEW): `_searchTopBar`+`_buildSearchOverlay`+`_buildSearchResultsContent`+`_buildSearchResultTile`+`_buildHighlightedText` → `SearchTopBar`+`SearchOverlay` (`view/search_view.dart`). `SearchTopBar` (search/focusNode/glossy/onClose) остаётся под-виджетом внутри `_buildAppBar` (header-lerp `_searchAnim` не тронут — вынесен только контент top-bar). `SearchOverlay` (search/searchAnim/onOpenResult/senderName/senderAvatar). Нотифаеры-инстансы те же (`_search.*`/`_searchAnim`); `_searchSenderName`/`_searchSenderAvatar`/`_openSearchResult`/`_closeSearch` остались в State, прокинуты колбэками. Убраны 3 неиспользуемых импорта (`animated_lottie_icon`/`app_animations`/`komet_avatar`). Parity скептиком против HEAD — **PARITY OK** (5 деревьев byte-identical modulo renames; `search.submit`≡HEAD inline cancel+run) | `chat_screen.dart` (−226), `view/search_view.dart` (333) | ✅ | +| H15b (2 — COMPOSER): `_buildInputArea`+`_buildReplyPreview`+`_buildVoiceRecordingIndicator`+`_recordingButtonVisual`+`_voiceLockChip` → `ComposerInputBar` (`view/composer_input.dart`, 19 параметров: контроллеры `_voiceRec`/`_note`, нотифаеры `_replyTo`/`_hasText`/`_uploadStatus`, `_attachAnim`, `_messageController`/`_messageFocusNode`, send-колбэки `onSendText`/`onScheduleMessage`/`onOpenAttach*`/`onSendHistory`/`onToggleStickerPanel`/`onCancelReply`, `formatElapsed`/`contextMenuBuilder`). Приватные виджеты `_AttachButton`/`_HistoryStrip`/`_ButtonClipper`/`_RecordingDot`/`_LiveWavePainter` + top-level `_labelForEntry`/`_iconForFilename` переехали в тот же файл. `_UploadStatus`→публичный `UploadStatus` (`chat/upload_status.dart`) — нужен обеим сторонам (`_AttachButton` + State-хендлеры upload). `_formatContextMenu`/`_sendMessage`/send-пути остались в State. Убраны импорты `sticker_panel`/`rich_message_controller`… (см. ниже) | `chat_screen.dart`, `view/composer_input.dart` (1042), `chat/upload_status.dart` (11) | ✅ | +| H15b (3 — SELECTION-BAR): `_selectionTopBar` → `SelectionTopBar` (copyMsg/editMsg вычисляются в State через `_singleCopyableText`/`_singleEditable` и прокидываются параметрами — вычисление только внутри `if (t>0)`); `_buildSelectionBottomBar`+`_selectionActionPill` → `SelectionBottomBar`+`_pill` (`view/selection_bar.dart`). Действия (`_clearSelection`/`_copySelected`/`_editSelected`/`_deleteSelected`/`_replySelected`/`_forwardSelected`) остались в State, прокинуты колбэками | `chat_screen.dart`, `view/selection_bar.dart` (238) | ✅ | +| H15b (4 — HEADER): `_glossyHeaderRow`+`_materialHeaderRow`+`_withOnlineDot`+`_backWithBadge`+`_backUnreadBadge`+`_RollingCount` → `ChatHeaderRow` (`view/chat_header.dart`, `build => glossy ? _glossyRow : _materialRow`). `_buildAppBar` ОСТАЛСЯ в State (переплетение `_searchAnim`/`_selectionAnim`/glossy-lerp высоты не расщепляется) — заменён только вызов `glossy ? _glossyHeaderRow : _materialHeaderRow` на `ChatHeaderRow(...)`. Навигация (`ChatInfoScreen`/`Navigator.pop`/`_openChatMenu`/`_openScheduledMessages`/`_startCall`) прокинута колбэками; `isOfficial`=`chat?.isOfficial??false`, статус/счётчики — `ValueListenable`-параметры | `chat_screen.dart`, `view/chat_header.dart` (475) | ✅ | +| H15b (5 — command/sticker панели): `_buildCommandPanel` → `CommandPanelView(commandPanel)` (`view/command_panel_view.dart`); `_buildStickerPanel` → `StickerPanelView(stickers, onStickerTap)` (`view/sticker_panel_view.dart`). Тривиальные обёртки над `_commandPanel`/`_stickers`-контроллерами; `_sendSticker` остался в State | `chat_screen.dart`, `view/command_panel_view.dart` (36), `view/sticker_panel_view.dart` (40) | ✅ | +| H15b (6 — shimmer-заглушка): `_buildShimmerLoading` (93 строки placeholder-списка на `_shimmerController`) → `ShimmerLoading(shimmer)` (`view/shimmer_loading.dart`). Полностью автономна (только `_shimmerController` + `Theme.of`); 2 сайта `_isLoading && _messages.isEmpty ? … : _buildMessagesList()` | `chat_screen.dart`, `view/shimmer_loading.dart` (105) | ✅ | + +**Сессия 9 итог (2026-07-04):** **H15b закрыт.** С `_ChatScreenState` снято build-дерево композера/поиска/ +selection/header/shimmer в 8 виджет-файлов под `screens/chats/chat/view/` + `UploadStatus`-модель. `chat_screen.dart` +**6770 → 4815 строк** (−1955, −29%; от исходных ~6940 — −31%). Вынесены только build-деревья: логика/state/ +send-пути (`_sendVoice`/`_sendVideoNote`/`_sendSticker`/`_sendMessage`/`_sendAttachMessage`/`_photoUploadProgress`/ +`_uploadSub`) остались в State, виджеты получают контроллеры/нотифаеры/анимации параметрами + сайд-эффекты +колбэками. `_buildAppBar` и `_buildComposerArea` (оркестраторы анимаций хедера/композера) обоснованно оставлены +в State — только их крупные под-деревья вынесены; message-list уже изолирован (`_ChatMessageList` host-виджет). +`flutter analyze` (весь проект) — **0 ошибок, 0 новых предупреждений** (пред-существующие: 2 dead-push + +cacheExtent/axisAlignment + unnecessary_underscores/token_storage + 2 curly_braces в нетронутом +`_resolveCurrentPosition`). Изменения в рабочем дереве, не закоммичены. Новых файлов: 9 — +`view/{search,composer_input,selection_bar,chat_header,command_panel,sticker_panel,shimmer_loading}.dart` +(вернее `*_view.dart`/`chat_header.dart`/`composer_input.dart`/`shimmer_loading.dart`/`selection_bar.dart`) ++ `chat/upload_status.dart`. **Parity — все скептик-проверки против HEAD (с учётом session-1–8 renames) +PARITY OK:** SearchView (5 деревьев byte-identical; `search.submit`≡inline cancel+run); composer (все 19 +аргументов + внутренние деревья byte-consistent, ни одной опечатки-константы, все методы/классы удалены из +chat_screen); header+selection (обе byte-faithful к HEAD-деревьям под rename-map; `SelectionTopBar`-хелперы +`_single*` вычисляются только внутри `if (t>0)`; RollingCount/badge/avatar-константы совпадают). Метод: 2 файла +(selection_bar/chat_header) авторились параллельными sonnet-агентами по verbatim-спеке + ревью диффа + скептик; +остальные — вручную. dart format прогнан. + +**H17 — разведка проведена в конце С9 (карта + план ниже).** Кодинг H17 — на С10. + +### Сессия 10 (2026-07-04) — H17: ChatsModule → инстанцируемый репозиторий (закрыт) + +| Находка | Файлы | Статус | +|---------|-------|--------| +| H17 Шаг 1 (додел): чистый parse-кластер вынесен из `ChatsModule` в top-level-функции нового файла `chat_parsing.dart` — `parseChatRow`(`_parseChat`)/`buildContactsMap`/`parseSearchResult`/`parseMessageResult`/`sameChatContent`(`_sameContent`) + приватные `_otherParticipantId`/`_nameFromContact`. `_parseParticipants`→публичная `parseParticipants` осталась в chats.dart (нужна `CachedChat.fromDbRow` + `parseChatRow`). 6 внутренних сайтов переименованы; 0 внешних вызывателей. Циклический импорт chats↔chat_parsing (нужен `CachedChat`) — легален в Dart. Parity: тела byte-identical к HEAD (скептик). | `backend/modules/chat_parsing.dart` (254), `chats.dart` (−≈195) | ✅ | +| H17 Шаги 2–5 (ядро) ОДНИМ координированным разрезом: `ChatsModule` → инстанцируемый класс с приватным ctor `ChatsModule._()` и единственным глобалом `final chats = ChatsModule._()` (в chats.dart, рядом-по-смыслу с `api`; НЕ в main.dart — иначе backend импортировал бы entry-point = нарушение слоёв + цикл). Все `static`-члены (методы + реактивное состояние `chatsChanged`/`_messageEventsController`/`_historyFetched`/contact-flush/push-subs) стали ИНСТАНС-членами одного синглтона; константы `muteOff`/`muteForever`/`lastMsgPlaceholder`/`_contactFlushDelay` остались `static const`. Введён реальный `dispose()` (cancel subs/timer, close controller, dispose notifier — раньше отсутствовал). Все 14 файлов-вызывателей переведены `ChatsModule.` → `chats.` (кроме 3 констант в chat_list). Локальные переменные `chats` переименованы во избежание shadow: cloud_storage_screen→`cachedChats`, search_screen→`localChats`, chat_list_screen→`loadedChats`. | `chats.dart`, `main.dart`, `chat_controller.dart`, `chat_screen.dart`, `chat_list_screen.dart`, `search_screen.dart`, `cloud_storage_screen.dart`, `create_group_flow.dart`, `max_link_handler.dart`, `debug_menu_screen.dart`, `settings_tab.dart`, `account.dart`, `outbox.dart`, `messages.dart`, `cloud_storage.dart` | ✅ | + +**Отклонение от буквы плана (обосновано):** план С9 предполагал переходный «статик-фасад» `static X => chats.X` внутри `ChatsModule`. В Dart это НЕВОЗМОЖНО — класс не может иметь одноимённые static- и instance-члены (коллизия). Поэтому вместо промежуточного фасада сделан прямой координированный разрез в один заход (внутренняя инстанс-конверсия + миграция всех вызывателей + оба реактивных подписчика), что и есть целевое состояние Шага 5 без выбрасываемых делегаторов. Риск silent-break реактивного ядра снят тем, что синглтон РОВНО один: все писатели (`_bump`/`_messageEventsController.add` на инстансе) и читатели (`chats.chatsChanged`/`chats.messageEvents`) ссылаются на один объект. + +**Сессия 10 итог (2026-07-04):** **H17 закрыт полностью.** `ChatsModule` из статического god-модуля стал инстанцируемым репозиторием `chats` (единственный приватно-сконструированный синглтон) + чистый parse-кластер вынесен в `chat_parsing.dart`. `flutter analyze` — **0 ошибок, 19 issues = бейзлайн** (без новых). `dart format` прогнан по всем 16 затронутым файлам. Изменения в рабочем дереве, не закоммичены. **Parity — скептик-агент против `HEAD` (все 6 проверок PASS):** тела parse-кластера line-by-line ≡ HEAD; `parseParticipants` ≡ HEAD; ровно один `ChatsModule._()`, ноль вторых конструкций/мёртвых статик-вызовов; add/remove подписчиков (`chat_list`/`chat_screen`) на одном и том же инстансе (симметрия listener'ов); 3 локальных ренейма полны; `dispose()` нигде не вызывается (не рвёт живой синглтон). Осталось из крупного: H6 (CustomFontService legacy UA — нужна проверка на устройстве) + medium/low дубли. + +### H17 — карта ChatsModule + план миграции (разведка, С9) + +`lib/backend/modules/chats.dart` (1739 строк, класс `ChatsModule` L249–1739; помимо него в файле — модели +`CachedChat`/`ChatSearchHit`/`MessageSearchHit` и sealed `MessageEvent`, они мигрируют/остаются независимо). + +**Ключевой вывод:** `ChatsModule` — почти **stateless-фасад над `AppDatabase` (SQLite)** + один реактивный +`ValueNotifier chatsChanged` + один broadcast-`Stream messageEvents`. Реальное состояние чатов живёт в SQLite, +не в модуле. Поэтому большинство методов инстансируются тривиально; вся запутанность сосредоточена в **4 стат. +полях**: `chatsChanged`, `_messageEventsController`, push-подписки (`_globalPushSub`/`_globalStateSub`+`_pushQueue`), +contact-flush (`_pendingContactUpdates`/`_contactFlushTimer`/`_contactFlushFuture`) + мягко `_historyFetched`. + +**Глобальное мутабельное состояние (8 полей):** +- `chatsChanged` (**public** `ValueNotifier`, L455) — реактивный клей; `_bump()` зовут ~18 мутирующих методов + + косвенно внешние (`outbox.applyOutgoing`, `account.syncFromLoginPayload`, push). Слушают `chat_list_screen` + (L543) и `chat_screen` (L333). **Писатели и подписчики в разных файлах — встречаются только через статик-синглтон.** +- `_messageEventsController` (broadcast, L358) — эмитят push-хендлеры + `outbox.emitMessageSent`; слушают + `chat_list_screen` (L549, typing) и `chat_screen` (L381). Никогда не закрывается (нет `dispose`). +- `_historyFetched` (`Set`, L462) — пишет/читает `chat_controller`; чистят `resetForAccountSwitch` + (`account.dart`/`settings_tab.dart`) и `_handleSessionState` (disconnect). +- `_globalPushSub`/`_globalStateSub` (L458/459) — создаются в `attachGlobalPushHandlers(api)` (идемпотентно), + вызов единожды `main.dart:123`; живут весь app-lifetime, не отменяются. +- `_pushQueue` (`Future`-цепочка, L460) — сериализация push-обработки. +- `_pendingContactUpdates`/`_contactFlushTimer`/`_contactFlushFuture` (L901–903) — дебаунс контакт-апдейтов; + кормится извне из `messages.applyContactUpdate` (L1833/1895). + +**Классификация ~59 методов:** ~12 `[PURE]` (preview/parse-хелперы + константы `muteOff`/`muteForever`/ +`lastMsgPlaceholder`), ~17 чисто `[IO]` (DB/пакет, без `_bump`), остальные `[STATE]`/`[SUB]` (пишут +`chatsChanged`/`_messageEventsController` или владеют подписками/таймером). `Api` **не глобален внутри** — передаётся +параметром в каждый IO-метод (только `attachGlobalPushHandlers` захватывает `api.pushStream/stateStream`). + +**Вызыватели (15 файлов):** backend — `main.dart` (attach, 1×), `account.dart` (reset/sync), `outbox.dart` +(emitMessageSent/applyOutgoing), `messages.dart` (applyContactUpdate), `cloud_storage.dart` (setChatOptions/ +createGroupChat/setChatTitle); frontend — `chat_controller.dart` (reconcile*/wasHistoryFetched/ensureChatCached/ +subscribeChat/markHistoryFetched), `chat_screen.dart` (chatsChanged/messageEvents/getChatInfo/getChat/markRead/ +markUnread/subscribeChat/applyOutgoing×8/ensureChatCached/reconcileLastMessage/clearHistory/deleteChat/ +reconcileDeletedFromFetch/refreshChats), `chat_list_screen.dart` (togglePin/mute-const/setChatMute/refreshChats/ +deleteChat/chatsChanged/messageEvents/getChats/lastMsgPlaceholder), `create_group_flow.dart` (createGroupChat/ +requestChatPhotoUploadUrl/setChatPhoto), `search_screen.dart` (searchMessages/searchPublic), `max_link_handler.dart` +(cacheServerChat), `cloud_storage_screen.dart` (getChat/getChats/deleteChat/leaveChat), `settings_tab.dart` +(resetForAccountSwitch), `debug_menu_screen.dart` (searchById). + +**Соседние статик-модули** (тот же паттерн): Contacts/Folders/WebApp/DigitalId/Calls/Messages/Complaints/ +Stickers/CloudStorage/Account — все статические. `ChatsModule` можно инстансировать **в одиночку**: единственный +исходящий модуль-вызов — в `FoldersModule` (leaf, обратно не зовёт). `messages`/`outbox`/`account`/`cloud_storage` +зовут В `ChatsModule`, но не наоборот → это вызыватели-на-миграцию, не co-dependencies. Сегодня класс НИКОГДА не +конструируется (`grep "ChatsModule("` пусто), нет `dispose`. + +**План миграции (strangler-фасад, порядок от низкого риска):** +1. **Pure-хелперы + константы** (механически, 0 churn у вызывателей): 12 `[PURE]` → инстанс-методы/свободные + функции; `muteOff`/`muteForever`/`lastMsgPlaceholder` оставить статик-const (нужны `CachedChat.isMuted` L110 + + `chat_list_screen`). +2. **Инстанс + статик-фасад:** создать инстансируемый класс с 4 стат-полями; один инстанс `chats` рядом с `api` + в `main.dart`; переписать `ChatsModule.` как `static => chats.` (делегаторы) → все ~15 файлов + компилируются без правок, поведение неизменно. +3. **Чистые IO-методы** (без shared state): getChats/getChat/clearCache/getChatInfo/searchById/searchMessages/ + searchPublic/subscribeChat/requestChatPhotoUploadUrl/setChatPhoto/setChatOptions/reconcileDeletedFromFetch/ + _reconcileLastMessage → перевести вызывателей (search_screen/cloud_storage*/create_group_flow/debug_menu/ + chat_controller) на `chats.` без порядковых забот. +4. **DB-mutate-and-bump + ДВА экрана-подписчика ОДНИМ шагом:** applyOutgoing/markRead/markUnread/cacheServerChat/ + reconcileLastMessage*/setChatTitle/Mute/togglePin/deleteChat/clearHistory/leaveChat/refreshChats/ensureChatCached/ + createGroupChat/syncFromLoginPayload — все через `chatsChanged`/`_bump`. Т.к. `chat_list_screen`/`chat_screen` + делают `ChatsModule.chatsChanged.addListener`+`messageEvents.listen`, нотифаер+стрим становятся инстанс-членами, + и оба экрана перенаправляются на `chats.chatsChanged`/`chats.messageEvents` **в том же шаге**, что и писатели + (`outbox`/`account`/`messages.applyContactUpdate`). Единственный «координированный разрез» — делать целиком. +5. **Подписки/lifecycle последними + добавить `dispose()`:** attachGlobalPushHandlers + `_globalPushSub`/ + `_globalStateSub` + все `_handle*` + `_pushQueue` + contact-flush-таймер. Заменить `main.dart:123` на инстанс-init. + Ввести реальный `dispose()` (закрыть контроллер, отменить subs/timer — сейчас его НЕТ); текущий + `resetForAccountSwitch` свернуть в `reset()`. + +**Итог risk:** ~30 из ~59 методов (`[PURE]`+чистые `[IO]`) двигаются тривиально; реальный риск — только 4 поля +(`chatsChanged`/`messageEvents`/push-subs/flush-timer) + их писатели + 2 экрана-подписчика в одном коммите. + +**H17 Шаг 1 (частично) выполнен в С9:** preview-кластер (`attachPreviewLabel`/`_controlPreviewLabel`/ +`messagePreviewText`/`_bodyPreviewText`/`messagePreviewElements`, 5 функций, 0 внешних вызывателей, зависимость +только `jsonEncode`) вынесен из `ChatsModule` в top-level-функции `lib/backend/modules/chat_preview.dart`. +Внутренние сайты (`_reconcileLastMessage`/`_handleNotifMessage`/`_parseChat`, строки 689/690/724/1195/1196) не +менялись — вызовы top-level безымянные. `chats.dart` 1739→1688. `flutter analyze` — 0 errors, 0 новых (19 = +бейзлайн). Остаток Шага 1 (parse-кластер `_parseChat`/`_buildContactsMap`/`_otherParticipantId`/`_nameFromContact`/ +`_parseSearchResult`/`_parseMessageResult`/`_sameContent` — крупнее, тянут модели `CachedChat`/presence/contacts) и +Шаги 2–5 — на С10. + +**✅ ВСЁ ВЫШЕ ЗАКРЫТО в С10** (см. раздел «Сессия 10» выше): parse-кластер → `chat_parsing.dart`; Шаги 2–5 +сделаны одним координированным разрезом (статик-фасад в Dart невозможен из-за коллизии имён static/instance — +вместо него прямая миграция на единственный синглтон `chats = ChatsModule._()`). Parity — скептик против HEAD, 6/6 PASS. + +### Сессия 11 (2026-07-04) — большой батч medium/low: ФРОНТ A (цвета) + ФРОНТ D (изолир. виджеты) + ФРОНТ F (проверка) + +**Карта OPEN vs DONE** (сверено с кодом + С1–10): +- **ФРОНТ A** «Hardcoded theming/color literals» (7): ✅ #1 бренд/статус-хексы (007AFF/4FC3F7/34C759/2F8FFF), ✅ #2 mutedText α0.6 ×8, ✅ #5 avatar-thumb 144 ×4; ⏭️ #3 hairline, #4 frosted-pill alphaBlend, #6 drop-shadows, #7 bubble tint/opacity (риск виз-сдвига / chat_screen-heavy → С12). +- **ФРОНТ D** «Duplicated UI widgets» (25): ✅ PersistedSetting (ранее), ✅ ErrorView ×3, ✅ showTextInputDialog (devices/font), ✅ SmallSpinner/BusyOverlay ×4, ✅ confirm-dialog chat_screen→shared, ✅ KometAvatar create_group; ⏭️ WebApp/DigitalId-dup(HIGH), settings-rows/cards ×3, DebugToggleTile ×11, RadioTile ×3, PrimaryLoadingButton ×5, reconnect-helper, LabeledField, upload-flows ×5, header-row, contact-card, kSheetShape-инлайны, messageStatusVisual, overlay-popup, swipe-dedup, edit-sheet, avatar_hero, edit_profile-avatar, password_entry/photo_editor prompt (→ С12). +- **ФРОНТ F** «formatters18/l10n6/snackbar4»: ✅ SnackBar (spoof) и debug-логи (VOICE/FLIP) закрыты ранее; ✅ ядро-форматтеров (С1–2); ⏭️ хардкод-l10n строки (крупно, нужны ARB-ключи), остаток форматтеров (→ С12). +- **ФРОНТ B** (backend parsing 22): ✅ только arch-parse (ранее); ⏭️ остальное (→ С12). +- **ФРОНТ C** (layering 7): ⏭️ всё (→ С12). **ФРОНТ E** (lifecycle 14): ⏭️ всё (→ С12). + +**Новые файлы:** `core/config/app_colors.dart` (extension `ColorScheme.mutedText`; consts `kAvatarThumbSize/kReadReceiptBlue/kOnlineGreen/kEditorAccent`); `widgets/error_view.dart` (`ErrorView`); `widgets/prompt_dialog.dart` (`showTextInputDialog`, слот `description`, владеет controller+dispose); `widgets/small_spinner.dart` (`SmallSpinner`+`BusyOverlay`). +**Адаптация (17 файлов, disjoint):** цвета — chat_info(007AFF→`cs.primary` ×5, акцент пропагируется), chat_list(4FC3F7→kReadReceiptBlue + 144→kAvatarThumbSize ×4), sticker/voice/bubble_context(4FC3F7), settings_tab/traffic_monitor/photo_editor(34C759→kOnlineGreen), photo_editor+media_preview(2F8FFF→kEditorAccent, локальные `_kAccent` удалены), mutedText→`cs.mutedText` (settings_tab/security/devices/server/proxy/composer_input/chat_screen); виджеты — ErrorView(web_app/digital_id_web/digital_id, локальные `_ErrorView` удалены), showTextInputDialog(devices/font_settings — **+закрыт leak контроллера** в font), SmallSpinner/BusyOverlay(sticker_panel/sticker_pack_sheet/photo_editor ×2), confirm-dialog(chat_screen `_showConfirmDialog` удалён→`showConfirmDialog`), KometAvatar(create_group `_Avatar` удалён + memCache-фикс + снят неисп. cached_network_image import). +**Метод:** 9 sonnet-агентов ПАРАЛЛЕЛЬНО на непересекающихся файлах (verbatim-спеки: точные строки+интерфейс+рамки); общие файлы (chat_screen/composer_input/security_screen) — серийно сам. Все свопы value-equal (нулевой виз-сдвиг), кроме двух намеренных: `007AFF→cs.primary` (link/action теперь следует акценту) и confirm-dialog→shared (FilledButton.tonal errorContainer вместо TextButton cs.error). Поведенческие сайты (chat_info ×5; create_group avatar — KometAvatar теряет initials-во-время-загрузки и w600→bold, inherent) перепроверены построчно. `dart format` по всем затронутым. Residual-grep после: 007AFF/4FC3F7/34C759/2F8FFF/`α0.6` = **0**. `flutter analyze` — **0 ошибок, 19 issues = бейзлайн** (dart format однажды перенёс пред-существующий однострочный `if` в chat_info:202 на 2 строки → curly-braces-lint; починил скобками → снова 19). Изменения не закоммичены. + +**Сессия 11 итог:** ✅ ФРОНТ A (цветовые токены — 3/7 находок; ВСЕ бренд-хексы+mutedText+avatar-thumb закрыты, residual=0), ✅ ФРОНТ D изолированные виджеты (ErrorView/prompt/spinner+overlay/confirm/KometAvatar — 6 находок), ✅ ФРОНТ F проверен-закрыт (SnackBar/debug/ядро-форматтеров уже были готовы). B/C/E, тяжёлый хвост D, l10n и остаток A → **Сессия 12** (промпт ниже). + +### Промпт для Сессии 12 (продолжение хвоста medium/low) + +``` +Продолжаем рефакторинг Komet по AUDIT_REPORT.md (секция «Прогресс исправлений», Сессии 1–11 закрыты). +Работа НЕ закоммичена — пиши код, git-коммиты не делай. Flutter: /home/a/flutter/bin/flutter. +Пиши АБСОЛЮТНЫЙ МИНИМУМ текста — только код и мысли. + +Норма: 0 errors, 0 новых warnings, ровно 19 issues бейзлайна (те же, что в С11: 2 dead-push, cacheExtent×1 ++ axisAlignment×2 chat_screen/connection_status, curly×2 chat_screen `_resolveCurrentPosition`, +unnecessary_underscores registration×7/login_success×3/theme_reveal×1, token_storage encryptedSharedPreferences). +После правок счётчик != 19 → внёс новое, чини. ВНИМАНИЕ: `dart format` может перенести пред-существующий +однострочный `if`/`for` без скобок на 2 строки → curly_braces-lint; если счётчик вырос из-за этого — оберни в {}. + +ИСПОЛЬЗУЙ уже созданное (не дублируй): core/config/{persisted_setting,app_colors}.dart (extension `cs.mutedText`, +consts kAvatarThumbSize/kReadReceiptBlue/kOnlineGreen/kEditorAccent), core/utils/{format,ids,debouncer,log_redact}, +widgets/{error_view(ErrorView),prompt_dialog(showTextInputDialog + description-слот),small_spinner(SmallSpinner, +BusyOverlay),confirm_dialog(showConfirmDialog),komet_avatar,sheet_helpers(kSheetShape)}.dart, инстанс `chats`, +models/{contact_info,chat_info,message_search_result}, bubbles/bubble_context, chat_preview/chat_parsing. +Конвенции: без комментариев; showCustomNotification; правильный рефактор; quality over quantity. + +Метод (как в С11, сработал чисто): sonnet-агенты ПАРАЛЛЕЛЬНО на непересекающихся НОВЫХ+consumer файлах (partition +по файлам, чтобы 0 конфликтов; verbatim-спеки), foundation-файлы и общие (chat_screen/messages/chats/account/main/ +message_bubble) — серийно сам. После каждого куска: САМ ревью diff + `flutter analyze` (сверь 19) + `dart format`. +Value-equal свопы → analyze==19 достаточно; поведенческие/extractions → доп. построчная сверка. Маленькие атомарные куски. + +ОСТАВШИЕСЯ ФРОНТЫ (бери столько, сколько влезет; крупные декомпозиции все позади — это чистый хвост): + ФРОНТ B — Backend parsing/model дубли (22, @«Backend parsing and model duplication»): CachedMessage attach/ + FORWARD/CONTROL хелпер (messages.dart 3 пути, СЕРИЙНО сам); CachedChat.copyWith + _updateChat (chats.dart ~6 + методов, СЕРИЙНО); _decodePayload + messagePreviewElements реюз (chats.dart); folders _parseIntList/_parseFolderList; + PerChatJsonStore (draft_store+chat_wallpaper_store); countries ru-blob усушка; rasterPictureToJpegFile + (photo_editor ×3); dispatcher _PendingRequest merge; calls _parseCallerEndpoint; stickers _fetchAndCache; + api _handleConnectFailure; cloud_storage _toCloudFile; file_uploader _syntheticFilename/_multipartBoundary/UA-const; + enumFromName (6 settings); rich_message _toFormatRanges; message_bubble _multiPhotoCornerRadius; CountryName.displayName; + Poll._merge. Многие — по 1 независимому файлу → агенты. + ФРОНТ C — Layering (7, @«Layering violations…»): типизир. push-сеттеры account.dart + notifications_screen(50-70/ + 132-205); AccountModule.logout() (settings_tab дублирует teardown + теряет spoof-clear); push_service _handleReply→ + login()/_buildLoginPayload; (СОМНИТ: read-mark в ChatsModule, spoof-login-метод, ContactCache-ChangeNotifier — оценить). + ФРОНТ E — Lifecycle/dispose/caches (14, @«Lifecycle, dispose, and unbounded caches»): media_cache _inFlight-dedup; + video_player _loadGeneration-guard; MessageSessionCache LRU; ContactCache→per-row (или bound); temp-файлы + attachment_sheet(track+cleanup); UploadManager subscription API; recording-stop на pause (chat_screen); + _highlightTimer cancelable (chat_screen); complaints.clear(); MediaDownloadProgress.release; _messageKeys prune; + performance_screen mounted-guard. + ФРОНТ D-хвост — крупные виджет-дубли (@«Duplicated UI widgets»): SettingsCard/SettingsToggleTile/SettingsNavTile + (settings_tab/notifications/komet_settings), SettingsRow (security ×4 builders), _DebugToggleTile (debug_menu ×11), + SettingsRadioTile (theme/message_actions/app_icon ×3), PrimaryLoadingButton (password_entry ×5), ErrorView уже + есть — но WebApp/DigitalIdWeb-dup(HIGH) hooks-параметризация; reconnect-helper+LabeledField (proxy/server sheets); + kSheetShape-инлайны (~10 сайтов); messageStatusVisual (sticker_bubble+voice_bubble); contact-card (message_bubble + 2 метода); optimistic-upload flow ×5 (chat_screen, СЕРИЙНО); overlay-popup mixin; edit_profile→KometAvatar. + ФРОНТ A-остаток: hairline `cs.hairline` getter (α-разнобой, но виз-сдвиг — реши каноничную α), frosted-pill helper, + drop-shadow→GlossyDecor, message_bubble tint-геттеры. Все затрагивают chat_screen/security — серийно, аккуратно. + ФРОНТ F-l10n: вынести хардкод-RU строки в ARB (message_bubble/message_actions_overlay, password_2fa/web_qr_login, + packet.dart typed-error, chats preview-kind enum) — крупно, дели по экранам; auth describeAuthError-хелпер. + +H6 (CustomFontService legacy UA) — ЕДИНСТВЕННЫЙ незакрытый high; нужна проверка шрифта на устройстве (woff2 vs +sfnt/TTF). САМ НЕ ТРОГАЙ вслепую — СПРОСИ пользователя, готов ли проверить после правки UA. + +После каждого фронта — строка в «Сессию 12» + ✅/🔧/⏭️. В конце — промпт для Сессии 13. +``` + +### Сессия 12 (2026-07-05) — большой батч B/C/E/D-хвост (2 волны параллельных агентов + серийные foundation-файлы) + +**ФРОНТ B (backend parsing/model, 22) — почти весь закрыт:** +- ✅ B1 `CachedMessage.parseAttachments(map)→(attachments, isControl)` — 3 конструктора (`fromDbRow`/`_parseMessage`/`fromPushPayload`) унифицированы; FORWARD и control-детект теперь везде (fix: push раньше не обрабатывал FORWARD/не ставил isControl; fromDbRow получил `whereType`-guard). empty attaches→`[]` в push (доказано безопасно — все консьюмеры трактуют null≡[]). +- ✅ B2 `CachedChat.copyWith` (sentinel `_keep` для nullable) + `_updateChat(accountId,chatId,mutate)` → `markUnread`/`applyOutgoing`/`setChatTitle`/`setChatMute`. Overlay `Map.from(row)..addAll(toDbRow())` сохраняет `in_list` (saveChats — partial ON CONFLICT UPDATE). `markRead`/`_handleNotifMessage`/`_reconcileLastMessage` обоснованно оставлены (interleaved API / multi-save / принимают chatRow). +- ✅ B3 `_decodePayload(raw)` ×3 (EDITED-merge/`_reconcileLastMessage`/reactions) + `messagePreviewElements(payload)` реюз в `_reconcileLastMessage` (guard `payload['text']` провабли-недостижим: text≡payload['text'] на всех write-путях). +- ✅ B4 `_parseIntList`/`_parseFolderList(json,{lenient})` (folders/chat_folder; loadFolders swallow-all vs applyPayload skip-bad сохранены через lenient-флаг). +- ✅ B5 `PerChatJsonStore` (draft_store/chat_wallpaper_store тонкие сабклассы; `_deleteImage`→`onBeforeWrite`). +- ✅ B7 `rasterPictureToJpegFile(...,{prefix})` (core/media/raster.dart; 3× photo_editor `_bake`; `onPictureDisposed`-хук для adjust-editor's curved.dispose ordering). +- ✅ B8 dispatcher `_PendingRequest{completer,sentAt}` (2 карты→1). +- ✅ B10 calls `_parseCallerEndpoint` (joinByLink external-id намеренно НЕ тронут — пред-существующая дивергенция). +- ✅ B11 stickers `_fetchAndCache`. ✅ B12 api `_handleConnectFailure({phase,disconnectSocket})` (B13 arch-хелпер уже был). ✅ B14 cloud_storage `_cloudFilesFrom` (sync*). ✅ B15 file_uploader `_syntheticFilename`/`_multipartBoundary` (UA-const уже был; `_okCdnRequest` UA намеренно не тронут). ✅ B16 `enumFromName` (persisted_setting + 6 settings, все свопы value-equal). ✅ B17 rich_message `_toFormatRanges`. ✅ B18 photo_bubble `_multiPhotoCornerRadius` (single-photo не тронут — иная формула). ✅ B19 `CountryName.displayName`. ✅ B20 Poll `_buildFromState` (без Map round-trip). +- ⏭️ B6 countries ru-blob (риск данных), B9 opcode enum (крупно), B21 `_MessageKind` (SOMNIT). + +**ФРОНТ C (layering, 7):** ✅ C1 типизир. push-сеттеры (`setChatsPushNotification`/`setMessagePreview`/`setNotificationSound`/`setCallNotifications`/`setNewContacts`) в account.dart; notifications_screen `_apply(v, action, assign)` через thunk (wire-ключи ушли из UI). ✅ C2 `AccountModule.logout()` (disconnect+removeAccount+cache-clear); settings_tab `_doLogout` делегирует (spoof-clear уже был; снято 4 неисп. импорта). ✅ C3 push_service `_handleReply` → `AccountModule(api).buildLoginPayload(token, interactive:false)` (магbytes/fingerprint дедуп; interactive:false сохранён). ⏭️ read-mark в ChatsModule / spoof-login-метод / ContactCache-ChangeNotifier (SOMNIT/крупно). + +**ФРОНТ E (lifecycle, 14):** ✅ E1 media_cache `_inFlight`-dedup; ✅ E2 video_player `_loadGeneration`-guard; ✅ E3 MessageSessionCache LRU(24, LinkedHashMap); ✅ E5 attachment_sheet temp-track (`_tempFiles`/`_sentFiles`, dispose-cleanup, снята prefix-связка); ✅ E7 recording-stop на pause (chat_screen didChangeAppLifecycleState); ✅ E8 `_highlightTimer` cancelable + mounted-guard; ✅ E9 `ComplaintsModule.clear()` в 4 cache-reset-сайтах; ✅ E11 `_messageKeys` prune (в `_pruneReactionNotifiers`) + clear в dispose; ✅ E12 performance_screen mounted-guard ×2. ⏭️ E4 ContactCache→per-row (крупно), E6 UploadManager subscription (SOMNIT/risk), E10 MediaDownloadProgress.release (нет безопасной точки — listener активен на set-null), gallery-cache (SOMNIT). + +**ФРОНТ D-хвост:** ✅ `_DebugToggleTile` ×11; ✅ `SettingsRadioTile` ×3 (theme reveal onTapDown сохранён); ✅ `PrimaryLoadingButton` ×5 (+`foreground` для remove-2fa; `_promptPassword` НЕ делегирован — trim-дивергенция); ✅ WebApp/DigitalIdWeb hooks (**HIGH** — DigitalIdWebScreen→тонкий StatelessWidget поверх WebAppScreen); ✅ `LabeledSettingsField`; ✅ security_screen `_settingsRow` (4 билдера→1) + `_showHiddenStatusSheet`→`_showOptionSheet` + 20→24 kSheetShape; ✅ kSheetShape-инлайны ×6 (2 BoxDecoration-случая оставлены); ✅ `SettingsCard`/`SettingsToggleTile`/`SettingsNavTile` ×3 (komet_settings получил disabled-state; notifications thunks сохранены); ✅ `messageStatusVisual` ×2 (voice НЕ унифицирован — разный dim: white54 vs theme-alpha); ✅ contact-card `buildContactCard` + memCache-фикс forwarded; ✅ edit_profile avatar→KometAvatar; ✅ `AnimatedOverlayPopup`-mixin (account_switcher/chat_menu). ⏭️ optimistic-upload flow ×5 (chat_screen, СЕРИЙНО, крупно), edit-message-sheet, reconnect-helper (viz/behavior-change), swipe-dedup, avatar_hero. + +**ФРОНТ A-остаток / F-l10n / H6:** ⏭️ A (frosted-pill literal исчез пост-рефактора; hairline — нужна каноничная α / viz-shift; drop-shadow; systemTint — spread-thin, LOW); F-l10n (крупно, ARB-ключи; describeAuthError; message_bubble/overlay RU); H6 (CustomFontService UA — **спросить пользователя**, нужна проверка шрифта на устройстве). + +**Метод/верификация:** 2 волны sonnet-агентов ПАРАЛЛЕЛЬНО (16 + 11) на непересекающихся файлах (partition, verbatim-спеки) + foundation (messages/chats/account/push_service/chat_screen/notifications_screen/settings_tab) серийно сам. **Parity: skeptic-агент против HEAD — все OK** (in_list сохранён; copyWith-sentinel корректен; B3-guard недостижим; PerChatJsonStore API-совместим; video-gen chain-of-custody; LRU/folders-lenient/media-cache верны). Агенты приняли безопасные решения (не делегировали при реальной дивергенции): `_promptPassword`(trim), voice-status(dim), `_okCdnRequest` UA, joinByLink external-id, 2 BoxDecoration kSheetShape. `flutter analyze` — **0 ошибок, 19 issues = бейзлайн** (случайный `dart format lib/` перенёс пред-существующие однострочные `if` → +2 curly; откатил 23 format-only-файла token-identical к HEAD). Не закоммичено. **Новые файлы:** `core/storage/per_chat_json_store.dart`, `core/media/raster.dart`, `widgets/{settings_radio_tile,primary_loading_button,labeled_settings_field,settings_card,animated_overlay_popup}.dart`. + +### Промпт для Сессии 13 + +``` +Продолжаем рефакторинг Komet по AUDIT_REPORT.md (секция «Прогресс исправлений», Сессии 1–12 закрыты). +Работа НЕ закоммичена — пиши код, git-коммиты не делай. Flutter: /home/a/flutter/bin/flutter, dart: /home/a/flutter/bin/dart. +Пиши АБСОЛЮТНЫЙ МИНИМУМ текста — только код и мысли. + +НОРМА: 0 errors, 0 новых warnings, ровно 19 issues бейзлайна (2 dead-push push_service, cacheExtent×1 ++ curly×2 chat_screen, axisAlignment chat_screen/connection_status, unnecessary_underscores registration×7/ +login_success×3/theme_reveal×1, token_storage encryptedSharedPreferences). Счётчик !=19 → чини. +ВНИМАНИЕ: НЕ запускай `dart format lib/` (весь дерево) — только `dart format <затронутые файлы>`; блэнкет-формат +переносит пред-существующие однострочные `if`/`for` без скобок на 2 строки → curly-lint и тащит format-churn в +несвязанные файлы. Если curly вырос — оберни в {} ИЛИ откати format-only файлы (token-identical к HEAD). + +ИСПОЛЬЗУЙ созданное (не дублируй): core/config/{persisted_setting(+`enumFromName`),app_colors}, core/storage/ +per_chat_json_store, core/media/raster(`rasterPictureToJpegFile`), core/utils/{format,ids,debouncer,log_redact}, +widgets/{error_view,prompt_dialog(`showTextInputDialog`),small_spinner,confirm_dialog,komet_avatar,sheet_helpers +(kSheetShape),settings_radio_tile,primary_loading_button,labeled_settings_field,settings_card(`SettingsCard`/ +`SettingsToggleTile`/`SettingsNavTile`),animated_overlay_popup(mixin)}, bubbles/{bubble_context(`messageStatusVisual`), +contact_bubble(`buildContactCard`)}, инстанс `chats`, `CachedChat.copyWith`+`_updateChat`, `CachedMessage.parseAttachments`, +`AccountModule.{logout,buildLoginPayload,setChatsPushNotification/...}`, `ComplaintsModule.clear`. +Конвенции: без комментариев; showCustomNotification; правильный рефактор; quality over quantity. + +Метод (сработал в С10–12): sonnet-агенты ПАРАЛЛЕЛЬНО на непересекающихся НОВЫХ+consumer файлах (partition, +verbatim-спеки, каждый агент сам `dart format`+`dart analyze` СВОИХ файлов до 0 issues); foundation/god-файлы +(chat_screen/messages/chats/account/message_bubble/main) — серийно сам. После — skeptic-агент parity против HEAD +(`git show HEAD:…`). ШАГ 0: сверь карту OPEN/DONE выше — НЕ переделывай С1–12. + +ПРИОРИТЕТ: сначала настоящие БАГИ (G), потом дедупы бэкенда (H — легко параллелятся), логгинг (I), +затем крупные декомпозиции (J), потом cosmetic/l10n/SOMNIT-хвост (A/F/K). Бери сколько влезет; НЕ обязан всё. +Многие находки в аудите ПОВТОРЯЮТСЯ между разделами и часть уже сделана в С1–12 — на ШАГЕ 0 сверь каждую с кодом. + +═══ ФРОНТ G — НАСТОЯЩИЕ БАГИ / dead-affordances (@«Broken or dead UI», @«Robustness», @«Silent failures») ═══ + G1 [HIGH, real] popUntil route-name никогда не совпадает → выкидывает в корень вместо SecurityScreen. password_entry_ + screen ×4 (~633/1001/1187/1359: `route.settings.name=='SecurityScreen'` — имя нигде не задано). Фикс: задать + `settings: const RouteSettings(name:'SecurityScreen')` в settings_tab (~388 push) + `ModalRoute.withName`, ЛИБО + захватить Navigator до push и попать по callback. Изолированно (password_entry+settings_tab). + G2 [MED, data-loss] `_loadForwardedSenderNames` (chat_screen ~3718) ручная реконструкция CachedMessage теряет + isControl/deleted/editHistory → `msg.copyWith(attachments: newAttaches)` (copyWith уже всё несёт). chat_screen СЕРИЙНО. + G3 [MED, mislabel] ForwardedMessageAttachment/UnknownAttachment хардкодят `type:photo` → в previewText(messages + ~315) и _attachLabel(scheduled_messages ~287) forward/unknown = «Фото». Фикс: добавить `AttachmentType.{forward, + unknown}`, конструировать ими, обработать в 2 switch (forward→text/«Переслано», unknown→«Вложение»). attachment.dart + + messages + scheduled_messages (СЕРИЙНО — messages foundation). + G4 [MED, robustness] dispatcher.dispatch push-хендлер без try/catch (~119) → один бросивший хендлер роняет весь батч + пакетов из одного read (api ~394 loop). Обернуть вызов в try/catch с `logger.w(Opcode.name+error)`, continue. dispatcher. + G5 [MED, robustness] receiver overflow (`_maxBufferSize`=2MB, ~13/25/29): при переполнении reset()+`const []` молча, + сокет жив → десинк. Пробросить hard-error в transport-owner (dispatcher error-stream) → форс reconnect. receiver+api. + G6 [MED] outbox.flush (~44) `pending.payload != null → continue` навсегда: attachment/poll/location pending-строки + не восстанавливаются. Либо ре-инвок типизир. сендера по payload-типу, либо помечать 'failed' по таймауту. СНАЧАЛА + проверь, создаются ли вообще 'pending'-строки для аттачей (если нет — ⏭️/downgrade). outbox. + G7 [MED] calls_tab (~277/470) delete-анимация: parent `Future.delayed(260ms)` + отдельная `Duration(260ms)` в + контроллере — рассинхрон-magic. Убрать parent-delay, `onDismissed`-callback из `AnimationStatus.dismissed`. calls_tab. + G8 [MED, latency] sendFileMessage слепой `Future.delayed(3s)` до первой попытки (messages ~1171) — избыточно с + not.ready-retry-loop. Убрать (сделать в связке с H2). messages СЕРИЙНО. + G9 [MED] chat_screen dead-UI no-op `onTap:(){}`: пункт «Уведомления»/«Видеозвонок» в `_openChatMenu` (~2875) и + полноширинный «Отключить уведомления» GlossyPill в CHANNEL-композере (~5620). Либо реализовать (setChatMute уже есть!), + либо скрыть. chat_screen СЕРИЙНО. + G10 [MED] `_parseChat` (chats ~1146-1278) один catch-all на ~130 строк молча дропает чат при любой ошибке. Разбить на + `_resolveTitleAndIcon/_resolveLastMessage/_resolveMuteAndFavorite/_resolvePresence/_resolveAdmins`, каждый defensive. + chats СЕРИЙНО, аккуратно (login-sync + push путь). + G11 [MED, race] `_refreshAfterCall` (chat_screen ~3093) `Future.delayed(700ms)` перед рефетчем — заменить на + событие call-ended/history-updated из ChatsModule-стрима. chat_screen СЕРИЙНО (LOW-приоритет внутри G). + +═══ ФРОНТ H — ДЕДУП send/request-boilerplate (@«Duplicated request/response and send-path», 12) — легко параллелится ═══ + H1 [HIGH] file_uploader `_sendHttpRequest(uri,method,headers,body,{onProgress})` + `withProgress`-трансформер: 5 + upload-путей (~71/163/259/312/386) дублируют socket+headers+progress. ⚠️ЧАСТИЧНО СДЕЛАНО в пред-сессии + (`_sendHttpRequest`/`_buildUploadHeaders`/`_buildMultipartHeaders` уже есть) — СВЕРЬ, доделай остаток/`_okCdnRequest`. file_uploader. + H2 [MED] messages `_sendWithNotReadyRetry({payload,maxAttempts,retryDelay,initialDelay,onOk})`: 5-7 send*Message + (~1199/1249/1326/1416/1499 + location/poll/sticker) байт-идентичные not.ready-retry-циклы. + `_sendAttachedMessage` + обёртка (payload-construct + retry + unwrap). Учти G8 (убрать 3s). messages СЕРИЙНО, parity построчно (sendFileMessage + возвращает bool — адаптировать). + H3 [MED] account `_requireMapPayload(packet,method)`→PacketError (не голый Exception): ~15 сайтов + (~474/496/628/669/723/876/1153/1462) `_checkPacketError`+`if(data is! Map) throw`. Плюс `sendRequestOrThrow` на Api + (унифицировать `_checkPacketError` account+folders — folders ~199/241 роняет SessionExpired-кейс). account+folders+api СЕРИЙНО. + H4 [MED] account `_applyProfileResponse(packet)`: updateProfileName/Avatar/removeProfilePhoto (~551/572/608) идентичный + хвост profile→contact→ProfileData→saveProfile. Реюз и в `_processProfileUpdate`/`_processLoginResponse`. account СЕРИЙНО. + H5 [MED] Api-хелперы (api.dart ~316): `sendRequestMap(op,payload)→Map?` (~50 сайтов guard `!isOk||payload is!Map`), + `sendRequestOk(op,payload)→bool` (7 toggle-методов calls/messages/chats). Раздать call-сайты агентам ПО МОДУЛЯМ + (calls/stickers/folders изолированы; messages/chats/account — серийно). api foundation. + H6d [MED] messages `_sendAndExtractMessageId(payload,defaultError)` + типизир. `MessageSendException`: sendMessage/ + forwardMessage (~798/847) дублируют error-extract+id-extract (различие лишь fallback-строка). messages СЕРИЙНО. + +═══ ФРОНТ I — ЛОГГИНГ проглоченных ошибок (@«Silent failures», 16) — тривиально, параллельно по файлам ═══ + I1 bare `catch(_){}` → `catch(e){logger.w('...: $e');}` (logger уже есть/добавь import): app_database `_migrateLegacyDb` + (~183); chat_wallpaper_store (~114/186); draft_store (~32); spoofing_service (~187); polls fetch/vote (~55/87); + CallBridge-методы (call bridge — см. раздел). Каждый файл изолирован → раздать агентам. + I2 message_bubble/bubbles playback/transcription catch без лога (voice_bubble `_togglePlay`, `_requestTranscription`, + video_note_bubble `_toggle`) — залогировать (release=obfuscate → иначе недиагностируемо). bubbles/ изолированы. + I3 main.dart reconnect-login `catch(_){}` (~297-307) — различить «нет аккаунта/токена» (return) от неожид. (лог). main СЕРИЙНО. + +═══ ФРОНТ J — ДЕДУП утилит/форматтеров (@«Duplicated formatting and shared-utility gaps», 18) — параллельно ═══ + J1 [MED, bug] `formatLastSeen` (core/utils/format.dart уже есть, с «Был(-а)»-префиксом): chat_info_screen локальный + `_formatLastSeen` (~1157) без глагола → member-tile (~818) кажет «N мин назад» без «Был(-а)». Удалить локальный, + звать shared, снять ручной «был(-а) »-префикс на ~307 (иначе дубль). chat_info изолирован. + J2 [MED, bug] `parseIntOrNull(v)`/`parseIntList(v)` в core/utils (list: tryParse+whereType, БЕЗ `?? 0`): attachment.dart + ×5 (~361/451/452/480/525/613) — сейчас `?? 0` фабрикует фейковый id 0 в userIds/contactIds. Фикс дропает невалидные. attachment. + J3 [MED] `pluralRu(n,one,few,many)` в format.dart: chat_info `_pluralCount` (~1167), call_screen (~719), sticker_pack + (~337), poll_view (~373) — 4 копии mod10/mod100. Раздать. (ЛИБО ICU-plural в ARB — но это F.) + J4 [MED] mm:ss: `formatDurationHms` (hour-aware) + `formatVoiceElapsed(ms)` (децисекунда) в format.dart: video_player + `_fmt` (~101), chat_screen `_formatVoiceElapsed` (~5407, сайты 5372/5535). video_player изолирован; chat_screen серийно. + J5 [LOW] экспонировать `_two`→public `pad2` в format.dart; убрать локальные копии/inline padLeft: schedule_time_picker + (~17), traffic_monitor (~83/479), debug_menu (~111), info_screen (~279), app_theme_schedule (~59). Параллельно. + J6 [LOW] `randomUuidV4()`/`randomHex(n)` (Random.secure) в core/utils/random_id.dart: device_identity (~33-48), + spoofing_service (~244-259) — байт-идентичны; calls `_uuidV4` (~182) юзает НЕ-secure Random() → фикс. Изолированно. + J7 [LOW] `int? intFrom(Object?)` (+wrapper для u123/g456 composite) в core/utils: call_controller `_asInt` (~142), + call_session `_participantIdFrom`/`_externalId` (~290/414), nfc_exchange `_decodeEvent` (~63). Изолированно (core/calls+nfc). + J8 [LOW] `displayName(first,last,{fallback})` в core/utils: search_screen `_contactName` (~127, fallback +phone), + create_group `_displayName` (~180, без fallback). J9 `_fileStamp(DateTime)` дубль: debug_menu (~110)+traffic_monitor + (~82) → core/utils. J10 info_screen `_w`/`_d` (~283) мёртвые plural-ветки (все возвращают одно) → убрать ветвление. + +═══ ФРОНТ K — magic/stringly-typed + мелкие дедупы (@«Robustness», @«Uncategorized») ═══ + K1 [LOW] poll settings bitmask (messages ~1562 `(anon?4:0)|(mult?1:0)`) → именованные `_pollAnonymousFlag=4/_pollMultipleFlag=1`. + K2 [MED] DIALOG peer-id `chatId ^ _myId` инлайн ×5 (chat_screen ~2158/2170/2180/3065/3119) → уже есть `_resolveOtherId` + (~3440); заменить все. chat_screen СЕРИЙНО. + K3 [MED] session-stale миксин: code_confirmation + password_2fa (~47/24) дублируют `_epoch/_recovering/_sessionStale/ + _stateSub/_recoverStaleSession`. Вынести миксин с per-screen recovery-callback. Изолированно (2 auth-экрана). + K4 [LOW] lastMsgPlaceholder magic-строка (chats ~255/748/767) — типизировать состояние «нет last-msg» (низкий; ⏭️ ок). + K5 [MED, WebRTC-риск] VP8 SDP-regex `_forceVp8` (call_session ~857/874) → `getCapabilities`+`setCodecPreferences`. + ⚠️НЕ вслепую — WebRTC, нужна проверка звонка; вероятно ⏭️/спросить. + K6 [MED, migration-риск] participants LIKE-scan (`findDialogChatByParticipant`, app_database ~578) → нормализ. таблица + `chat_participants(chat_id,account_id,user_id,role)` + индекс + schema-migration (v16→17). ⚠️Крупно/рискованно — ⏭️/оцени. + +═══ ФРОНТ J2/no-comments (@«No-comments», 5) — тривиально, параллельно ═══ + L1 снять комменты (конвенция «без комментариев»): call_screen (~867 garbled TODO), calls_tab (~151), contacts_tab + (~107), chat_screen (~4350 TODO Локализация/Склонения, ~1332), chat_list_screen (~207/1182/1555), chat_info_screen + (~67/75/222/298/323 banner-комменты), settings_tab `_SpoilerPainter` (~868/877/880). chat_screen серийно; остальное параллельно. + +═══ ФРОНТ M — крупные декомпозиции god-файлов (@«God-files», СОМНИТ, серийно/осторожно) ═══ + M1 dead Dart push-код (~260 строк, push_service ~41-293): `_showMessageNotification/_showCallNotification/_backgroundHandler/ + _appendHistory/_isActive/_avatarBytes/_initialsAvatar/_downloadBytes/_initialsOf/_avatarPalette/_NotifMessage` — мёртвые + (живёт нативный KometFcmService.kt). Удалить (ОСТАВИТЬ `_clearHistory/_onNotificationResponse/_handleReply/_handleCall + Decline/clearChatNotification`). ⚠️ЭТО УБИРАЕТ 2 dead-push из бейзлайна → НОРМА станет 17! Обнови счётчик-норму. + M2 AccountModule facade-split → backend/modules/account/{auth,profile,privacy,two_factor,sessions}_module.dart + модели + рядом. Крупно, серийно, осторожно (facade сохранить). ⏭️/оцени. + M3 chat_list_screen (2888стр): StoriesBar(promote inline `_StoriesUi`→widgets/stories_bar.dart)/FolderTabsView/ + PinnedChatsHeader/ChatListTile/DockedBottomNav. Также K7 identityHashCode-memoization (`_getChatsBody`/`_chatsForPageIndex` + ~207/810) → реальные виджеты + Flutter-diffing. Крупно, серийно. + M4 debug_menu (~1300стр build) → секции в lib/frontend/debug/ (DebugNetworkSection/DebugCacheSection/... по образцу + `_SyncProbeCard`). Изолированно (debug_menu). M5 CachedMessage/CachedChat → lib/models/{message,chat}.dart (+типизир. + ReactionInfo вместо `payload['reactionInfo']`-индексинга в bubbles/). Крупно, много импортов — серийно. + +═══ УНАСЛЕДОВАННЫЙ ХВОСТ из С12 (дожать при желании) ═══ + D-серийно: optimistic-upload flow ×5 (chat_screen `_sendVoice/_sendVideoNote/_sendPhotos/_sendVideo/_sendScheduledPhotos` + → расширить `_sendAttachMessage` upload-шагом; покрыть upload+progress-dispose+temp-cleanup). Разбить на атомы, parity. + A-остаток (cosmetic, ЕСТЬ viz-shift — согласуй α): `cs.hairline` getter (outlineVariant α 0.3/0.35/0.4/0.5 → одна); + `systemTint` getter на BubbleContext (onPrimaryContainer α0.12 ×7 в bubbles/); drop-shadow→GlossyDecor.dropShadow. + F-l10n (крупно, ARB app_en/app_ru + gen-l10n, ⚠️gen-l10n может добавить issues — маленькими порциями по экрану): + message_bubble/message_actions_overlay RU-строки; packet.dart typed-error (`isSessionStateError` по коду не substring); + chats preview-kind enum (attachPreviewLabel/_controlPreviewLabel → тег+резолв в UI); auth `describeAuthError(e,l10n)`+AuthException. + C-SOMNIT: read-mark→ChatsModule (K2-related); ContactCache→ChangeNotifier (reactive contact-name, крупно); token_login + spoof-login-метод `loginWithTokenAndSpoof` на account. + E-SOMNIT: UploadManager subscription-API (E6, risk); ContactCache→per-row SQLite (E4, крупно); MediaDownloadProgress.release + (E10 — НЕТ безопасной точки, listener активен → вероятно ⏭️); attachment_sheet gallery-cache→GallerySource (SOMNIT). + B-остаток LOW: B6 countries ru-blob усушка (риск данных — скриптом+сверка); B9 opcode enhanced-enum (~140 сайтов, крупно); + B21 `_MessageKind` (message_bubble, SOMNIT). + D-остаток: edit-message-sheet (chat_screen+scheduled_messages); reconnect-helper (proxy/server sheets — behavior-change: + unify на stateStream+timeout); swipe-dedup (swipe_route/swipe_to_pop threshold-хелпер); avatar_hero shared-content. + H6-font (CustomFontService legacy UA, custom_font_service ~11/113): Chrome/120 UA → Google отдаёт woff2, а код ждёт ttf + (regex+`_isSfnt`) → добавление шрифта молча no-op. Фикс: старый UA (Google отдаст ttf) ЛИБО woff2→sfnt-декод (FontLoader + только sfnt). ⚠️СПРОСИ пользователя — нужна проверка шрифта на устройстве ПОСЛЕ правки. САМ НЕ ТРОГАЙ вслепую. + +РЕКОМЕНД. ПОРЯДОК ВОЛН: (в1) параллельно G1/G4/G5/G7 + I1/I2 + J1/J2/J5/J6/J7/J8/J9 + K3 + L(параллельная часть) + +M4 — все изолированные; (серийно сам, между волнами) messages-кластер (G3/G8/H2/H6d/H5-messages), account-кластер +(H3/H4/H5-account), chats (G10/K4), chat_screen (G2/G9/G11/K2/J4-chat/D-upload/L-chat), M1(push)/I3(main). (в2) H1 file_uploader ++ J3/J4-video + K5?/K6? по решению. После каждого куска: САМ diff-ревью + `dart analyze`(сверь норму) + `dart format` +ТОЛЬКО затронутых. В конце — skeptic-агент parity против HEAD по foundation-файлам. + +После каждого фронта — строка в «Сессию 13» + ✅/🔧/⏭️. В конце — промпт для Сессии 14. +``` + +### Сессия 13 (2026-07-05) — большой батч G/H/I/J/K/L/M1 (11+2 параллельных агентов + серийные foundation) + +**⚠️ НОРМА ИЗМЕНИЛАСЬ: 19 → 17 issues.** M1 удалил мёртвый Dart push-код (push_service 552→273 строк), сняв 2 бейзлайн-варнинга `unused_element` (`_showMessageNotification`/`_showCallNotification`). Новый бейзлайн = **17 issues** (chat_screen: cacheExtent×1 + curly×2 + axisAlignment×1; connection_status axisAlignment×1; registration unnecessary_underscores×7; login_success×3; theme_reveal×1; token_storage encryptedSharedPreferences×1). + +**⚠️ НОВАЯ КОНВЕНЦИЯ (запрос пользователя): БЕЗ КОММЕНТАРИЕВ ВООБЩЕ** — не только «self-documenting», а физически удалять комменты в затрагиваемых файлах и никогда не писать новые (в т.ч. `///` doc-комменты). Осторожно с `// ignore:`-директивами анализатора — их НЕ удалять (в затронутых файлах их не было). Полностью очищены: format/parse/names/messages/attachment/file_uploader; агенты чистили свои файлы. Полный tree-purge НЕ делался (риск сноса `// ignore:` и format-churn) — делать точечно по мере касания. + +**Созданные утилиты (core/utils):** `format.dart`+`pad2`/`pluralRu`/`formatVoiceElapsed`; `parse.dart` (`parseIntOrNull`/`parseIntList` — без `?? 0`-фабрикации); `names.dart` (`displayName`). `ids.dart` (`uuidV4`/`randomHex`, secure) уже был. + +**ФРОНТ G (баги):** ✅ G1 (popUntil→`ModalRoute.withName('SecurityScreen')`; RouteSettings.name уже был в settings_tab — предикат был реальным фиксом; SecurityScreen пушится из 1 места, всегда в стеке под PasswordEntry). ✅ G2 (уже сделано — `_loadForwardedSenderNames` уже юзал `copyWith`). ✅ G3 (`AttachmentType.{forward,unknown}`; Forwarded/Unknown super `photo`→`forward`/`unknown`; previewText+`_attachLabel`→«Переслано»/«Вложение»; skeptic: рендер не ломается — диспатч по `is`, фото-грид по `is PhotoAttachment`). ✅ G4 (dispatcher push-хендлер try/catch — уже был, выровнен на `logger.w`). ✅ G7 (calls_tab delete-анимация: убран parent `Future.delayed`, removal через `AnimationStatus.dismissed` status-listener). ⏭️ G8 (3s-delay в sendFileMessage НЕ существует в текущем коде — уже убран/стале-реф). ⬜ G5 (receiver+api hard-error), G6 (outbox pending), G9 (dead-UI no-op), G10 (`_parseChat` split), G11 (`_refreshAfterCall`) — Сессия 14. + +**ФРОНТ H (дедуп send/request):** ✅ H1 (file_uploader — уже завершён в пред-сессии; агент подтвердил, `_okCdnRequest` UA-дивергенция намеренно оставлена). ✅ H2 (`_sendWithNotReadyRetry` + `_sentMessageMap` — 5 retry-циклов File/Photo/Video/Audio/VideoNote + 3 single-shot Location/Poll/Sticker; skeptic PARITY OK, дефолты maxAttempts/retryDelay не поплыли). ✅ H4 (`_applyProfileResponse(Packet)` — updateProfileName/Avatar/removeProfilePhoto; `_processProfileUpdate` НЕ тронут — иной non-throwing контракт). ✅ H6d (`_sendAndExtractMessageId(payload, defaultError)` — sendMessage/forwardMessage; typed `MessageSendException` НЕ вводил — риск toString-парити ради маргинального выигрыша). ✅ H3 (`_requireMapPayload(packet, method)` — 12 сайтов через precise regex, method-name в `_checkPacketError`≡throw-message; варианты `payload != null && is! Map` и `data['error']` НЕ тронуты). ⬜ H5 (Api `sendRequestMap`/`sendRequestOk` + унификация `_checkPacketError` account+folders) — Сессия 14. + +**ФРОНТ I (логгинг):** ✅ I1 (app_database/chat_wallpaper_store/spoofing_service — 3 silent catch залогированы; draft_store/polls уже логировали). ✅ I2 (voice_bubble `_togglePlay`/`_requestTranscription`, video_note_bubble `_toggle`). ✅ I3 (main reconnect-login `catch(_){}` → `logger.w`; null-account/token уже гейтились `if`). + +**ФРОНТ J (дедуп утилит):** ✅ J1 (уже сделано — chat_info уже юзал shared `formatLastSeen`, дубль-префикса нет). ✅ J2 (attachment userIds/contactIds → `parseIntList`, дропает невалидные вместо id 0). ✅ J3 (`pluralRu`×4: chat_info участник/подписчик, call_screen участник, sticker_pack стикер, poll_view голос). ✅ J4 (chat_screen `_formatVoiceElapsed`→shared `formatVoiceElapsed`). ✅ J5 (`pad2`: schedule_time_picker/traffic_monitor/info_screen/app_theme_schedule). ✅ J6 (уже сделано — device_identity/calls уже на shared `ids.dart`). ✅ J7 (`parseIntOrNull`: call_controller `_asInt`, call_session `_externalId`, nfc; `_participantIdFrom` composite оставлен). ✅ J8 (`displayName`: search_screen+phone-fallback, create_group). ✅ J9 (уже сделано — traffic_monitor на `formatFileStamp`). ✅ J10 (info_screen мёртвые plural-ветки `_w`/`_d` схлопнуты). ⬜ J4-video (video_player_screen `_fmt`) — Сессия 14. + +**ФРОНТ K:** ✅ K1 (poll bitmask → `_pollAnonymousFlag=4`/`_pollMultipleFlag=1`). 🔧 K2 (2 из 4 `chatId ^ _myId` → `_resolveOtherId()`: `_onPresenceChanged`+`_seedPresenceFromChat` — семантика идентична; `_loadOtherPresence`+call-path НЕ тронуты — там нет DIALOG-гейта, замена = behavior-change). ✅ K3 (миксин `SessionStaleRecovery` — code_confirmation+password_2fa; drop-текст стал 2-м хуком `connectionDroppedMessage`, разошёлся между экранами). ⬜ K4/K5/K6. + +**ФРОНТ L:** ✅ комменты сняты: calls_tab, contacts_tab, chat_list_screen, chat_info_screen (12 баннеров), call_screen (garbled TODO), settings_tab (`_SpoilerPainter`). + вычищены полностью messages/attachment/file_uploader/format/parse/names. + +**ФРОНТ M:** ✅ M1 (dead push-код удалён, −2 варнинга, норма→17; оставлены `_clearHistory`/`_onNotificationResponse`/`_handleReply`/`_handleCallDecline`/`clearChatNotification`; снесены 3 осиротевших импорта). ⬜ M2/M3/M4/M5. + +**Метод/верификация:** 11 sonnet-агентов волной 1 (изолированные файлы, partition, каждый сам format+analyze до 0) + 2 агента волны 2 (M1/H1) + foundation серийно сам (messages/attachment/account/main/chat_screen/scheduled). Skeptic-агент против HEAD: messages send-path/G3/J2 — **PARITY OK** (дефолты не поплыли, рендер не ломается; J2 caveat: `num`→toInt даёт `2` вместо `0` для дробных — неактуально для msgpack-int id). `dart analyze` — **0 errors, 17 issues = новый бейзлайн**. `dart format` только затронутых. Не закоммичено. + +### Промпт для Сессии 14 + +``` +Продолжаем рефакторинг Komet по AUDIT_REPORT.md (секция «Прогресс исправлений», Сессии 1–13 закрыты). +Работа НЕ закоммичена — пиши код, git-коммиты не делай. Flutter: /home/a/flutter/bin/flutter, dart: /home/a/flutter/bin/dart. +Пиши АБСОЛЮТНЫЙ МИНИМУМ текста — только код и мысли. + +НОРМА: 0 errors, 0 новых warnings, ровно 17 issues бейзлайна (cacheExtent×1 + curly×2 + axisAlignment×1 chat_screen; +axisAlignment×1 connection_status; unnecessary_underscores registration×7/login_success×3/theme_reveal×1; +token_storage encryptedSharedPreferences×1). Счётчик !=17 → чини. (Было 19 до С13; M1 снёс 2 dead-push.) +ВНИМАНИЕ: НЕ `dart format lib/` (весь tree) — только затронутые файлы (иначе curly-churn в несвязанных). + +⚠️ КОНВЕНЦИЯ (С13): БЕЗ КОММЕНТАРИЕВ — физически удалять в затрагиваемых файлах, не писать новые (вкл. `///`). +НО: `// ignore:`-директивы анализатора НЕ удалять. Полный tree-purge НЕ делать разом (риск format-churn + сноса ignore). + +ИСПОЛЬЗУЙ созданное (не дублируй): core/utils/{format(pad2/pluralRu/formatVoiceElapsed/formatFileStamp/formatLastSeen/ +formatDurationClock),parse(parseIntOrNull/parseIntList),names(displayName),ids(uuidV4/randomHex)}, + всё из С1–12 +(persisted_setting/app_colors/per_chat_json_store/raster/debouncer/ids/log_redact, widgets/*, bubbles/*). В messages: +`_sendWithNotReadyRetry`/`_sentMessageMap`/`_sendAndExtractMessageId`. В account: `_requireMapPayload`/`_applyProfileResponse`. + +ШАГ 0: сверь карту С1–13 (НЕ переделывай сделанное — многие находки уже ✅, часть «уже сделано в пред-сессии»). + +ОБЪЁМ: бери БОЛЬШОЙ кусок — цель закрыть ВСЁ из G + H5 + J4 + M4 + F-l10n(первые экраны) и продвинуть M2/M3/M5 +хотя бы на шаг. НЕ останавливайся на 3–4 пунктах: гони волнами по 10–16 агентов, между волнами сам делай foundation. +Токен-бюджет не жалей (пользователь просил heavy multi-agent). Это ~3–4 волны, а не одна. + +═══ ВОЛНА 1 (параллельно, изолированные файлы — раздать ~12–16 sonnet-агентам, партиция без пересечений) ═══ + • J4-video: video_player_screen `_fmt` → shared `formatDurationClock` (h-aware) / `formatVoiceElapsed`. Изолир. + • M4: debug_menu ~1300стр build → секции в lib/frontend/debug/ (DebugNetworkSection/DebugCacheSection/DebugSyncSection/ + DebugStorageSection/... по образцу существующего `_SyncProbeCard`). Изолир., ОДИН агент целиком владеет debug_menu. + Каждая секция — отдельный виджет-файл; debug_menu становится тонким композитором. Parity: build-дерево 1:1. + • H5-модули (ПОСЛЕ ШАГА 1): агент-A calls.dart, агент-B stickers.dart, агент-C folders.dart — каждый переводит свои + guard-сайты `!isOk||payload is!Map` на НОВЫЕ Api-хелперы `sendRequestMap`/`sendRequestOk` (уже добавленные в api.dart). + • F-l10n порциями: агент на ОДИН экран за раз (message_bubble RU-строки; message_actions_overlay RU) — вынести хардкод- + строки в app_en.arb/app_ru.arb, прогнать gen-l10n, заменить на AppLocalizations. ⚠️gen-l10n может добавить issues — + если >0 новых, откати порцию. Маленькими порциями (1 экран = 1 агент), НЕ весь UI разом. + • A-остаток (косметика, изолир.): `cs.hairline` getter (outlineVariant α разнобой→одна); `systemTint` getter на + BubbleContext (onPrimaryContainer α0.12 ×7 в bubbles/). ⚠️есть viz-shift — выбери каноничную α, отметь. + • G11 `_refreshAfterCall`: если событие call-ended уже есть в ChatsModule-стриме — замени 700ms-delay; иначе ⏭️. + +═══ ШАГ 1 (foundation, СЕРИЙНО сам, ДО раздачи H5-модулей) ═══ + H5-ядро в api.dart: добавь `Future?> sendRequestMap(int op, Map payload)` (guard `!isOk||payload + is!Map`→null) и `Future sendRequestOk(int op, Map payload)` (→isOk). Плюс `sendRequestOrThrow`/унификация + `_checkPacketError` для account+folders (folders ~199/241 сейчас роняет SessionExpired-кейс — почини по образцу account). + Потом переведи messages/chats/account toggle-сайты сам (серийно), а calls/stickers/folders отдай агентам (волна 1). + +═══ ВОЛНА 2 (foundation, СЕРИЙНО сам — между/после агентов) ═══ + • G5 receiver overflow (2MB reset молча, сокет жив → десинк): пробрось hard-error в transport-owner (dispatcher + error-stream) → форс reconnect. receiver+api+dispatcher. ⚠️behavior-change — аккуратно, парити по happy-path. + • G6 outbox.flush `pending.payload!=null continue` навсегда: СНАЧАЛА grep insert-путей outbox — создаются ли + 'pending'-строки для аттачей. Если да — ре-инвок типизир. сендера по payload-типу ИЛИ 'failed'-по-таймауту. + Если 'pending' для аттачей не создаётся — ⏭️/downgrade, отметь. + • G9 chat_screen dead-UI: no-op `onTap:(){}` «Уведомления»/«Видеозвонок» в `_openChatMenu` + полноширинный + «Отключить уведомления» GlossyPill в CHANNEL-композере. setChatMute уже есть → реализуй ИЛИ скрой. + • G10 chats `_parseChat` ~130-строчный catch-all → split на `_resolveTitleAndIcon/_resolveLastMessage/ + _resolveMuteAndFavorite/_resolvePresence/_resolveAdmins`, каждый defensive. ⚠️login-sync + push путь — парити. + • H5-хвост: messages/chats/account toggle-сайты на sendRequestOk/sendRequestMap. + +═══ ВОЛНА 3 (крупные декомпозиции — серийно, атомизируй + skeptic после каждой) ═══ + • M4 добить если агент не закрыл. • M3 chat_list_screen 2888стр → StoriesBar(promote `_StoriesUi`)/FolderTabsView/ + PinnedChatsHeader/ChatListTile/DockedBottomNav + K7 identityHashCode-memoization→реальные виджеты+Flutter-diffing. + Разбей на атомы, parity каждого. • M5 CachedMessage/CachedChat → lib/models/{message,chat}.dart + типизир. + `ReactionInfo` (вместо `payload['reactionInfo']`-индексинга в bubbles/). Много импортов — серийно, по одному классу. + • M2 AccountModule facade-split → backend/modules/account/{auth,profile,privacy,two_factor,sessions}_module.dart + (facade `AccountModule` сохранить как делегатор). Крупно — если не влезает, продвинь частично (1–2 под-модуля). + +═══ СПРОСИ ПЕРЕД (не трогай вслепую) ═══ + K5 (VP8 SDP-regex `_forceVp8`→getCapabilities+setCodecPreferences — WebRTC, проверка звонка на устройстве); + K6 (participants LIKE-scan→таблица chat_participants+миграция v16→17 — риск данных); H6-font (custom_font_service UA — + проверка шрифта на устройстве). По каждой: краткий план + вопрос пользователю, реализуй только после «да». + +═══ ХВОСТ (если останется бюджет) ═══ + D-остаток: optimistic-upload flow ×5 chat_screen (`_sendVoice/_sendVideoNote/_sendPhotos/_sendVideo/_sendScheduledPhotos` + → расширить `_sendAttachMessage` upload-шагом; атомы, parity); edit-message-sheet (chat_screen+scheduled); reconnect- + helper (proxy/server sheets — behavior-change); swipe-dedup; avatar_hero. B-остаток: B9 opcode enhanced-enum (~140 + сайтов, крупно); K4 lastMsgPlaceholder типизация. + +МЕТОД: волнами по 10–16 sonnet-агентов ПАРАЛЛЕЛЬНО на непересекающихся файлах (строгая партиция — каждый файл ровно +одному агенту; foundation-файлы что правишь сам НЕ отдавай; verbatim-спеки с номерами строк + сигнатуры shared-функций; +каждый агент сам `dart format`+`dart analyze` СВОИХ файлов до 0 issues и репортит parity). Foundation/god (chat_screen/ +messages/chats/account/api/message_bubble/main/dispatcher/receiver) — серийно сам. После КАЖДОЙ foundation-правки: +сам diff-ревью + `dart analyze`(норма 17) + `dart format` ТОЛЬКО затронутых. В конце волны — skeptic-агент parity +против HEAD (`git show HEAD:…`) по всем foundation-файлам. Проверяй норму 17 после каждой волны; !=17 → чини сразу. + +После каждого фронта — строка в «Сессию 14» + ✅/🔧/⏭️. В конце — промпт для Сессии 15 (столь же подробный). +``` + +### Сессия 14 (2026-07-05) — G-баги + H5 + M4 + F-l10n(1 экран) + A-systemTint (Волна 1: 5 агентов + серийные foundation) + +**ШАГ 1 foundation (H5-ядро):** ✅ `packet.dart`: `isSessionExpiredPayload(payload)` + `throwIfPacketError(packet)` (SessionExpired→PacketError). ✅ `api.dart`: `sendRequestMap(op,payload)` (→null при `!isOk||payload is!Map`), `sendRequestOk` (→isOk), `sendRequestOrThrow`. `_onDataReceived` SessionExpired-детект через `isSessionExpiredPayload` (parity). ✅ account `_checkPacketError`→делегатор `throwIfPacketError` (унификация; `method` param оставлен для 8 call-сайтов, `SessionExpiredException` больше не юзается в account но не unused-import). ✅ folders 2 сайта (`setFolderFavorites`/`syncFromServer`) `if(isError)throw PacketError`→`throwIfPacketError` — **FIX: теперь ловит SessionExpired-кейс** (был баг ~199/241). + +**ФРОНТ H5:** ✅ calls.dart (агент): 5 сайтов (videoChatStartActive/linkInfo/videoChatJoinByLink/videoChatHistory→sendRequestMap, videoChatDeleteHistory→sendRequestOk). ✅ stickers.dart (агент): 7 сайтов (assetsUpdate×2/assetsGet/assetsGetByIds/linkInfo/assetsAdd/assetsRemove→sendRequestMap). ✅ messages toggle (сам): 4×`return _api.sendRequestOk` (msgSend/msgEdit×2/msgDelete). ✅ chats toggle (сам): 2×`api.sendRequestOk` (setChatPhoto/setChatOptions). Оба агента + skeptic: PARITY OK (sendRequestMap null ⟺ `!isOk||payload is!Map`; error-ветки/return-значения byte-identical). + +**ФРОНТ G (баги):** ✅ **G5** (receiver overflow): `ReceiverOverflowException` вместо молчаливого reset(); `api._onDataReceived` ловит → `_forceReconnect()` (happy-path не тронут, throw только при >2MB pending). ✅ **G6** (outbox pending навсегда): grep показал — 'pending' с payload это ТЕКСТ с reply/elements (НЕ аттачи; аттачи 'pending'-строк не создают). Снят `payload!=null continue`; `_replyIdFromPayload`/`_elementsFromPayload` реконструируют reply/elements из payload → `sendMessage(replyToMessageId,elements)`; `sent`+`applyOutgoing` сохраняют payload/elements. ✅ **G9** (dead-UI): «Уведомления» no-op→`_toggleChatMute` (mute/unmute через `chats.setChatMute`, лейбл+иконка по `chat.isMuted`); «Видеозвонок» no-op **удалён** (call-инфра+device-test вне скоупа); CHANNEL-композер `composer_input.dart` полноширинный pill `onTap:(){}`→`onToggleMute` (+`isMuted`/`onToggleMute` params, проброшены из chat_screen). ✅ **G10** (`parseChatRow` ~130стр): split на `_resolveTitleAndIcon/_resolveLastMessage/_resolveMuteAndFavorite/_resolvePresence/_resolveAdmins` (record-возвраты, каждый defensive, outer try/catch сохранён; `otherId` поднят в тело для title+presence). 🔧 **G11** (`_refreshAfterCall`): 700ms-delay-своп → ⏭️ (`CallController.callEnded` фаерится на teardown, НЕ на server-summary-ready → преждевременный fetch = регрессия; нет подходящего события). Хвостовой `catch(_){}`→`logger.w` (silent-failure закрыт). + +**ФРОНТ M:** ✅ **M4** (агент): debug_menu_screen 1670→302стр, build декомпозирован на 8 файлов `lib/frontend/debug/` (DebugHeader/QuickActions/Network/FeatureToggles/Cache/Previews/IdSearch/SyncProbe + shared DebugToggleTile); 2 ренейма типов для visibility (`_SearchHit`→`SearchHit`, `_HitKind`→`HitKind`); parity 1:1 render-order, 0 issues. 🔧 **M5-шаг** (сам): типизир. `ReactionInfo`+`ReactionCounter` в `lib/models/reaction_info.dart` (`fromMap`); message_bubble `_buildReactionChipsFor(Map?)`→`(ReactionInfo?)`, 2 сайта оборачивают `ReactionInfo.fromMap(...)` (BubbleContext.reactionInfo остаётся Map? — без ripple; убран `info['counters']`/`c['reaction']`-индексинг). Полный M5 (CachedMessage/CachedChat→models) → С15. 🔧 **M3-шаг** (сам): chat_list_screen 2897→2805стр, shimmer-билдеры (`_buildChatShimmer`/`_buildFolderStripShimmer`/`_folderShimmerPill`) → `chat/view/chat_list_shimmer.dart` (`ChatShimmerTile`/`FolderStripShimmer`, param `Animation shimmer`); 2 call-сайта. Остальная декомпозиция (StoriesBar/FolderTabs/ChatListTile/DockedNav+K7) → С15. 🔧 **M2-шаг** (сам): account.dart 1510→1084стр — 13 дата-классов (PrivacyConfig/BlockedContact/TwoFactorDetails/SessionInfo/LoginResult/LoginSyncParams/… + enums) → `account/account_models.dart` (423стр), account.dart `import`+`export` (re-export → ноль import-churn у внешних потребителей, ноль behavior-change). Facade-split методов (auth/profile/privacy/2fa/sessions) → С15 (coupling `_ensureOnline`/`_checkPacketError`/profile-хелперов). + +**ФРОНТ F-l10n (5 экранов):** ✅ message_actions_overlay (13 ключей `msgActions*`, вкл. интерполяцию `currentVersionWithDate({date})`), ✅ notifications_screen (18 `notifications*`), ✅ devices_screen (15 `devices*`; skip 4×`'Unknown'` ip-api — English), ✅ theme_settings_screen (14 `themeSettings*`), ✅ appearance_screen (29 `appearance*`; агент переписал `static const`-списки лейблов на `_labelFor/_messagesFor(l10n)`). Все — отдельными агентами (арб — single-owner на волну). gen-l10n чисто; whole-project analyze=17 после каждого, 0 новых, без rollback. Остаток l10n (chat_info/security/password_entry/call_screen/… + `AppThemeMode.label()`/`AppBubbleShape.label()` дубли-энумы) → С15. + +**ФРОНТ K/H6 (ask-before, одобрено пользователем «делать»):** ✅ **H6-font** (custom_font_service) — **фикс уточнён после теста на Linux** (первая попытка MSIE6 не сработала): эмпирически проверил ответы Google Fonts css2 — Chrome/120→woff2 (regex `.ttf` мимо); MSIE6→`url(.../l/font?kit=...)` TTF-контент но БЕЗ `.ttf`-суффикса (regex тоже мимо); **Android 4.4 UA→чистый `url(...v51/....ttf)`** (magic `00010000`, валидный TrueType). Итог: (1) UA→Android 4.4; (2) regex `url((https://[^)]+))` + `_isSfnt`-валидация (робастно к любому формату, woff2 отсеивается); (3) варианты `?family=X` regular-first; (4) **`addFamily` больше НЕ бросает** (весь I/O в try/catch→null — чинит красный экран); (5) кэш-файл ревалидируется `_isSfnt`; (6) response-таймауты 20/30с (чинит зависание/«не реагирует»); (7) UI `_adding` сбрасывается в `finally` (чинит застревание кнопки «Загрузка…»). Ввод URL `fonts.google.com/specimen/X` уже парсился (`familyFromInput`). ✅ проверено: "Yuyu" — реальный шрифт, качается. ✅ **K5** (call_session VP8): SDP-munging `_forceVp8` (regex, только offer, desktop) удалён → `_preferVp8Codecs(pc)` через `getRtpSenderCapabilities('video')`+`setCodecPreferences([vp8,rtx])` на всех transceiver'ах (audio бросают→catch→skip), desktop-gated. ⚠️behavior: теперь префает VP8 и в offer, И в answer (было только offer); mobile-путь byte-identical; нужна проверка десктоп-звонком. ✅ **K6** (app_database): `participants LIKE '%"id":%'` full-scan → нормализ. таблица `chat_participants`(PK+FK CASCADE, индекс account_id/participant_id/chat_id) + миграция **v16→17** (create+index+backfill из JSON) + sync в `saveChats` (delete+reinsert в той же txn после upsert, только для строк с ключом participants — все идут через full toDbRow, drift невозможен); `findDialogChatByParticipant`→JOIN (индекс). chat_participants ведётся ТОЛЬКО для DIALOG (единственный потребитель фильтрует `type='DIALOG'` → устранён write-amp на больших группах, lookup идентичен). **Skeptic #2 PASS (эмпирически): sqlite3-симуляция query-equivalence (вкл. коллизию `"2"`/`"12"` — JSON-кавычки исключают ложное совпадение), FK-cascade, migration-atomicity (s.qflite onUpgrade в транзакции → version bump только при успехе → отсутствие IF NOT EXISTS безопасно).** ⚠️миграция данных — device-verify. + +**ФРОНТ A:** ✅ systemTint (агент): `BubbleContext.systemTint` getter (`onPrimaryContainer @0.12`); 5 сайтов bubbles/ (contact/location/call/file×2) → `ctx.systemTint` (pure dedup, 0 viz-shift). A-hairline (`cs.hairline`, outlineVariant α разнобой) → отложен (viz-shift + partition-hostile, много файлов). + +**ФРОНТ J:** ✅ J4-video (уже сделано в пред-сессии — video_player_screen на `formatDurationClock`, `_fmt` отсутствует). + +**Метод/верификация:** 3 волны агентов. Волна1 = 5 sonnet-агентов ПАРАЛЛЕЛЬНО (M4/H5-calls/H5-stickers/A-systemTint/l10n-actions). Волна2 = 2 (l10n notif+devices; skeptic-parity). Волна3 = 1 (l10n theme+appearance) + K6/K5-skeptic. Foundation серийно сам (packet/api/account/folders/messages/chats/receiver/outbox/chat_parsing/chat_screen/composer_input/message_bubble/chat_list_screen/app_database/call_session/custom_font_service + M5/M2/M3-steps + H6/K5/K6). **Skeptic #1 (read-only, `git show HEAD:…`): G5/G6/G10/H5 — ВСЕ 4 PASS, регрессий нет.** **Skeptic #2: K6-миграция/K5-codec — оба PASS** (K6 эмпирически sqlite3; K5 mobile byte-identical, API компилится на flutter_webrtc 1.5.2, behavior-note: VP8-pref теперь и в answer — обосновано). `dart analyze` (весь проект) — **0 errors, 17 issues = бейзлайн** (без новых) после каждой волны и каждой foundation-правки. `dart format` только затронутых. Не закоммичено. + +**Итог С14:** ✅ ВСЕ primary-цели (M4, F-l10n первые экраны×5, весь фронт G кроме G11-delay-⏭️, H5 полностью). ✅ M2/M3/M5 продвинуты на шаг каждый. ✅ Все 3 ask-before (H6/K5/K6) реализованы после «да» пользователя (нужна device-проверка шрифта+десктоп-звонка). Открыто для С15: полный M2 (facade-split методов) / M3 (StoriesBar/FolderTabs/ChatListTile/DockedNav+K7) / M5 (CachedMessage/CachedChat→models); A-hairline; остаток l10n; B9 opcode-enum; K4; D-остаток; J-хвост. **⚠️ device-verify: H6 (загрузка custom-шрифта), K5 (десктоп видеозвонок VP8), K6 (миграция БД v16→17 на реальных данных).** + +### Сессия 15 (2026-07-05) — багфиксы по фидбеку + продолжение M2/K4 + l10n + +**Багфиксы (по тесту пользователя на Linux):** +- ✅ **H6-font добит** (после провала MSIE6): эмпирически — Google Fonts css2 отдаёт woff2 (Chrome UA) / `/l/font?kit=` без `.ttf` (MSIE6) / чистый `.ttf` (Android 4.4). Итог UA→**Android 4.4**, regex→`url((https://[^)]+))`+`_isSfnt`, варианты regular-first, `addFamily` НЕ бросает (try/catch), кэш ревалидируется, response-таймауты 20/30с, UI `_adding` в `finally`. Шрифты РАБОТАЮТ (проверено пользователем). +- ✅ **Пересланные стикеры** (не отображались): `ForwardedGenericBubble` рендерил только `FileAttachment` → стикер = `SizedBox.shrink()`. Добавлен `ForwardedStickerBubble` (header + StickerBubble) + диспатч в `_buildAttachmentContent` (`originalAttachments.whereType()`) + StickerAttachment-ветка в generic. Персист OK (reload ре-парсит из `payload`). +- ✅ **Обрезка пересланного длинного текста**: `_buildForwardedInlineText` убран `maxLines: 2`+ellipsis → полный текст. +- ✅ **K5 (звонок с Linux)** — подтверждён рабочим пользователем. + +**Продолжение backlog:** +- ✅ **K4** (lastMsgPlaceholder типизация): getter `CachedChat.isLastMsgDeleted` инкапсулирует string-sentinel; заменены 3 сайта (chat_list×2, chats internal). Без DB-миграции (backward-compat). +- 🔧 **M2 (facade-split продвинут)**: `AccountApiBase` (shared `ensureOnline`/`checkPacketError`/`requireMapPayload`) + вынесены `SessionsModule` (getSessions/terminate/authorizeWebQrLogin) и `PrivacyModule` (10 методов: privacy config/blocked/push-настройки/токены). AccountModule — facade-делегатор. **account.dart 1510→997** (модели С14 + sessions + privacy). Auth/profile/2fa кластеры оставлены (coupling `_processProfileUpdate`/`_applyProfileResponse`) → С16. +- ✅ **l10n +5 экранов** (агент): call_screen(54 ключа)/komet_hub(20)/scheduled_messages(18)/contact_profile(16)/nfc_exchange(17) = **125 новых ключей** (арб 379×2, идентичные наборы). gen-l10n чисто, full-project analyze=17, 0 rollback. Всего l10n за С14+С15 = **10 экранов**. Остаток: chat_info/security/password_entry/cloud_storage/digital_id/attachment_sheet/photo_editor/font_settings + enum-labels → С16. +- **Верификация С15:** `dart analyze` (весь проект) — **0 errors, 17 issues = бейзлайн** после всех правок (l10n + M2 + K4 + багфиксы сосуществуют чисто). `dart format` только затронутых. Не закоммичено. + +**Продолжение С15 (по запросу «делай, продолжай»):** +- ✅ **M2 facade-split ПОЧТИ ЗАВЕРШЁН**: вынесены ещё `ProfileModule` (updateProfileName/Avatar/getAvatarUploadUrl/removeProfilePhoto + `_applyProfileResponse` + публичный `processProfileUpdate`) и `TwoFactorModule` (12 методов 2fa; юзает `_profile.processProfileUpdate`, тот же инстанс). **account.dart 1510→783** (5 под-модулей: sessions/privacy/profile/two_factor + models + base; в фасаде остался только auth: requestCode/verifyCode/login/completeRegistration/beginAddAccount). **Skeptic против HEAD: PASS все 6 секций** (byte-equivalent modulo renames; `processProfileUpdate` timing сохранён — запрос отправляется до подписки на push, как в HEAD; единственный ProfileModule-инстанс; все 26 делегаторов совпадают по сигнатурам). M2-хвост (auth) оставлен (риск логина) → С16. +- 🔧 **M3-шаг**: `_AnimatedChatTile`+`_ActivitySubtitle` (самодостаточные StatefulWidget'ы) → `chat/view/chat_list_tile.dart` (публичные `AnimatedChatTile`/`ActivitySubtitle`). **chat_list_screen 2897→2644** (С14 shimmer + С15 tile). DockedBottomNav/StoriesBar/K7 — coupled с nav-машинерией State → С16 (аккуратно). +- ✅ **l10n — фронт практически закрыт: 18 экранов** (арб 429→**615 ключей**×2). С14: message_actions/notifications/devices/theme/appearance (5). С15: call/hub/scheduled/contact_profile/nfc (5). С15-прод: chat_info/security/password_entry (3) + cloud_storage/digital_id/attachment_sheet/photo_editor/font_settings (5). Все агентами (арб — single-owner на волну), per-screen gen-l10n+analyze, 0 rollback, норма 17 держится. Остаток l10n: enum-лейблы (`AppThemeMode/AppBubbleShape/AppBubbleBehavior/AppFonts.label()` в core/config — нужен проброс context/l10n в вызовы) + мелочь → С16. + +**⚠️ Осознанно НЕ сделано (риск/низкая ценность, требуют отдельной аккуратной сессии):** M2-хвост (auth/login — риск), M3 DockedBottomNav/StoriesBar+K7 (nav-машинерия вплетена в State, K7-мемоизация — hot-path, риск перф-регрессий), M5 (CachedMessage/CachedChat уже типизированы — остаётся лишь file-move, coupling со статиками ChatsModule), B9 (~140 сайтов opcode-enum, churn, низкая баг-ценность), A-hairline (α 0.18–0.5 намеренно разные — design-change, не dedup — ждёт решения пользователя). Делать по одному со skeptic-верификацией. + +**⚠️ Крупные рефакторы НЕ тронуты вслепую (риск сломать критичные пути):** M2-хвост (auth/profile/2fa), M3-полная декомпозиция + K7 (nav-машинерия State — deeply coupled, риск перф/регрессий), M5 (CachedMessage/CachedChat — coupling с ChatsModule-статиками/parse-хелперами), B9 (~140 сайтов opcode, churn), A-hairline (α 0.18–0.5 намеренно разные → design-change, не dedup). Делать по одному с skeptic-верификацией. + +### Промпт для Сессии 15 + +``` +Продолжаем рефакторинг Komet по AUDIT_REPORT.md (секция «Прогресс исправлений», Сессии 1–14 закрыты). +Работа НЕ закоммичена — пиши код, git-коммиты не делай. Flutter: /home/a/flutter/bin/flutter, dart: /home/a/flutter/bin/dart. +Пиши АБСОЛЮТНЫЙ МИНИМУМ текста — только код и мысли. + +НОРМА: 0 errors, 0 новых warnings, ровно 17 issues бейзлайна (cacheExtent×1 + curly×2 + axisAlignment×1 chat_screen; +axisAlignment×1 connection_status; unnecessary_underscores registration×7/login_success×3/theme_reveal×1; +token_storage encryptedSharedPreferences×1). Счётчик !=17 → чини. +ВНИМАНИЕ: НЕ `dart format lib/` (весь tree) — только затронутые файлы. +⚠️ КОНВЕНЦИЯ: БЕЗ КОММЕНТАРИЕВ — физически удалять в затрагиваемых файлах, не писать новые (вкл. `///`). `// ignore:` НЕ трогать. + +ИСПОЛЬЗУЙ созданное (не дублируй): core/utils/{format,parse,names,ids,debouncer,log_redact}; persisted_setting; app_colors; +models/{contact_info,chat_info,attachment,reaction_info,message_search_result}; widgets/{attachment/bubbles/*,bubble_context}; +Api-хелперы `sendRequestMap`/`sendRequestOk`/`sendRequestOrThrow` + packet `throwIfPacketError`/`isSessionExpiredPayload`; +messages `_sendWithNotReadyRetry`/`_sentMessageMap`/`_sendAndExtractMessageId`; account `_requireMapPayload`/`_applyProfileResponse`; +account/account_models.dart (13 дата-классов, re-export через account.dart); chat_parsing `_resolve*`-кластер; +frontend/debug/* (8 секций debug_menu); chat/view/{composer_input,chat_list_shimmer,...}. BubbleContext.systemTint getter. + +ШАГ 0: сверь карту С1–14 (НЕ переделывай — H6/K5/K6/G/H5/M4 закрыты; M2/M3/M5 сделаны на 1 шаг; 5 l10n-экранов готовы). + +═══ ВОЛНА 1 (l10n порциями — 1 агент = 1-2 изолир. экрана, арб single-owner на волну; параллельно НЕ два l10n) ═══ + Остаток hardcoded-RU (по убыванию): chat_info_screen(93), password_entry_screen(62), security_screen(56), + call_screen(48), digital_id_screen(39), cloud_storage_screen(27), attachment_sheet(25), scheduled_messages(19), + komet_hub(18), contact_profile(16), nfc_exchange_sheet(15), font_settings(17), + enum-дубли + AppThemeMode.label()/AppBubbleShape.label()/AppBubbleBehavior.label() (core/config — их лейблы дублируют экраны). + Метод как С14: агент выносит строки в app_en/ru.arb (same key set, LAST key без запятой, placeholder-мета только в en), + gen-l10n, замена на AppLocalizations.of(context)!; если >0 новых issues — rollback порции. chat_screen/chat_list — сам. + +═══ ВОЛНА 2 (параллельно, изолир.) ═══ + • A-hairline: `ColorScheme`-extension getter `hairline` (outlineVariant α разнобой→ОДНА каноничная, посчитай моду α); + замени `cs.outlineVariant.withValues(alpha: X)` по frontend. ⚠️viz-shift — выбери каноничную α, отметь дельты. + • B9 opcode → enhanced-enum: ~140 сайтов `Opcode.xxx` (int-константы) → enum со `.code`. КРУПНО, изолир. на opcode_map.dart + + переводи потребителей волнами (grep call-сайтов; НЕ ломай `Opcode.name(op)`). Может дать issues — атомизируй. + • K4: `ChatsModule.lastMsgPlaceholder` (String-sentinel) → типизир. (enum/nullable). Изолир. в chats + потребители. + +═══ ВОЛНА 3 / серийно сам (крупные god-декомпозиции — атомизируй + skeptic после каждой) ═══ + • M3 chat_list_screen 2805стр → StoriesBar(promote `_StoriesUi`)/FolderTabsView/PinnedChatsHeader/ChatListTile/ + DockedBottomNav (в chat/view/) + **K7**: `identityHashCode(_chats/_folders/_profile)`-мемоизация (стр ~210/807) → реальные + виджеты + Flutter-diffing (перф-находка). Разбей на атомы, parity каждого против HEAD. + • M5 CachedMessage(messages.dart)/CachedChat(chats.dart) → lib/models/{message,chat}.dart. МНОГО импортов — серийно, + по одному классу, re-export как account_models (`export`), потом чистить импорты. ReactionInfo уже вынесен (С14). + • M2 AccountModule facade-split → backend/modules/account/{auth,profile,privacy,two_factor,sessions}_module.dart + (facade AccountModule делегирует). Модели уже вынесены (account_models.dart). Coupling: `_ensureOnline`/`_checkPacketError` + (→throwIfPacketError)/`_requireMapPayload`/`_processProfileUpdate`/`_applyProfileResponse` — вынеси в shared base/mixin + или top-level, чтобы под-модули брали только `Api`. Крупно — 1–2 под-модуля за раз, parity. + +═══ ХВОСТ (если бюджет) ═══ + D-остаток: optimistic-upload ×5 chat_screen (`_sendVoice/_sendVideoNote/_sendPhotos/_sendVideo/_sendScheduledPhotos` + → расширить `_sendAttachMessage` upload-шагом); edit-message-sheet; reconnect-helper (proxy/server sheets); swipe-dedup; + avatar_hero. J-хвост. B-остаток. + +═══ DEVICE-VERIFY (напомни пользователю — сделано в С14, нужна проверка на устройстве) ═══ + H6 (загрузка custom-шрифта из Google Fonts — MSIE6 UA должен вернуть TTF); K5 (десктоп видеозвонок — VP8 через + setCodecPreferences, проверь что видео идёт в обе стороны); K6 (миграция БД v16→17 на реальном профиле — DIALOG-поиск + по participant, backfill chat_participants). Если что-то сломалось — откат конкретного пункта. + +МЕТОД: волнами по 10–16 sonnet-агентов ПАРАЛЛЕЛЬНО на непересекающихся файлах (строгая партиция; арб — 1 l10n-агент/волна; +foundation что правишь сам НЕ отдавай; verbatim-спеки + сигнатуры shared-функций; каждый агент сам format+analyze до 0). +Foundation/god (chat_screen/chat_list_screen/messages/chats/account/api/message_bubble/app_database/call_session/main/ +dispatcher/receiver) — серийно сам. После КАЖДОЙ foundation-правки: diff-ревью + analyze(норма 17) + format затронутых. +В конце волны — skeptic-агент parity против HEAD (`git show HEAD:…`). Норма 17 после каждой волны; !=17 → чини сразу. + +После каждого фронта — строка в «Сессию 15» + ✅/🔧/⏭️. В конце — промпт для Сессии 16. +``` + +### Промпт для Сессии 11–12 (большой батч medium/low) — АРХИВ (выполнялось в С11) + +``` +Продолжаем рефакторинг Komet по AUDIT_REPORT.md (секция «Прогресс исправлений», Сессии 1–10 закрыты). +Работа НЕ закоммичена — пиши код, git-коммиты не делай. Flutter: /home/a/flutter/bin/flutter. +Пиши АБСОЛЮТНЫЙ МИНИМУМ текста — только код и мысли. + +Норма: 0 errors, 0 новых warnings. Пред-существующие, НЕ трогать (ровно 19 issues бейзлайна): +dead push-код _showMessageNotification/_showCallNotification (push_service.dart); SDK-deprecation +cacheExtent/axisAlignment (chat_screen.dart ×2, connection_status.dart); unnecessary_underscores +(registration_screen ×7, login_success_screen ×3, theme_reveal ×1); token_storage encryptedSharedPreferences; +2× curly_braces_in_flow_control в chat_screen `_resolveCurrentPosition` (нетронутый гео-код). Если после +правок issue-счётчик != 19 — ты внёс новое, чини. + +Утилиты/модели/контроллеры/виджеты ИСПОЛЬЗУЙ (не дублируй): core/utils/{format,ids,debouncer,log_redact}.dart, +core/config/persisted_setting.dart, models/{contact_info,chat_info,message_search_result}.dart, +widgets/attachment/bubbles/bubble_context.dart, backend/modules/{chat_preview,chat_parsing}.dart, +инстанс-репозиторий `chats` (НЕ `ChatsModule.` — статик остались только константы muteOff/muteForever/ +lastMsgPlaceholder), screens/chats/chat/{chat_controller,chat_prank_controller,voice_record_controller, +video_note_controller,command_panel_controller,sticker_panel_controller,chat_search_controller, +message_search_result,upload_status}.dart, screens/chats/chat/view/{search_view,composer_input,selection_bar, +chat_header,command_panel_view,sticker_panel_view,shimmer_loading}.dart. Конвенции: без комментариев; +showCustomNotification (не SnackBar); правильный рефактор вместо хака; quality over quantity. + +Метод: sonnet-агенты ПАРАЛЛЕЛЬНО на непересекающихся НОВЫХ файлах (агент авторит по verbatim-спеке — +точные строки+интерфейс+рамки); правки одного общего файла делаешь СЕРИЙНО сам. После каждого под-куска — +САМ ревьюй дифф + `flutter analyze` (сверяй 19) + `dart format` затронутого. Parity перепроверяй скептик- +агентом против HEAD (`git show HEAD:…`), НЕ рабочего дерева. Маленькие атомарные куски. План на сессию сразу. +После каждого куска обнови AUDIT_REPORT.md (✅/🔧/⏭️) + строка в «Сессию 11». В конце — промпт для Сессии 12. + +СТАТУС: ВСЕ high/крупные закрыты — H15/H15b (chat_screen 6770→4815 + 8 view-виджетов), H16 (message_bubble +2832→1225 + 12 бабблов), H17 (ChatsModule → инстанс-репозиторий `chats` + chat_parsing/chat_preview вынесены), +H14/H11/H3/H4 закрыты. Единственный незакрытый high — H6. + +════════ ГЛАВНАЯ ЦЕЛЬ: БОЛЬШОЙ БАТЧ medium/low (дубли/костыли/layering) — рассчитан на 1–2 сессии (11–12) ════════ +Крупные декомпозиции позади. Теперь агрессивно добиваем длинный хвост: дублирование (89), костыли (65), +оптимизация (40), сомнительные (37). НЕ мельчи — бери СРАЗУ несколько фронтов, тяжёлый параллельный фан-аут. + +ШАГ 0 (обязательно): построй карту OPEN vs DONE. Многое уже закрыто в С1–10 (uuidV4, PersistedSetting, +formatDuration/fileStamp/last-seen/Debouncer, ContactInfo/ChatInfo, HTTP-хелпер, batch-prefetch, ChatsModule→chats, +и т.д.). Пройди «Все находки по темам», отметь по каждой находке ✅/⬜ (сверяя с кодом и Сессиями 1–10), и НЕ +переделывай сделанное. Итог карты — коротко в отчёт. + +Затем фан-аут по НЕПЕРЕСЕКАЮЩИМСЯ фронтам (sonnet-агенты параллельно; общий файл — серийно сам): + ФРОНТ A — Цветовые литералы → AppAccent/тема (@«Hardcoded theming and color literals», 7): raw hex/Color(...) + в chat_info_screen (480/511/521/564/791), chat_list_screen:2243, message_bubble (2438/2801/2963), + traffic_monitor:224, settings_tab:671, media_preview_screen:12, photo_editor:1891 → семантические токены. + ФРОНТ B — Backend parsing/model дубли (@«Backend parsing and model duplication», 22): attach/FORWARD/CONTROL + парсинг в messages.dart (446-463/724-741/534-541) и JSON-decode-with-fallback → единые хелперы (по образцу + chat_parsing.dart/chat_preview.dart). messages.dart — общий файл, правь СЕРИЙНО сам. + ФРОНТ C — Layering violations (@«Layering violations from UI into transport/storage», 7): notifications_screen + (50-70/132-205) + account.dart(496-521) сырые protocol-key мапы → типизированные сеттеры. + ФРОНТ D — Дубли UI-виджетов (@«Duplicated UI widgets and layout», 25, минус уже сделанный PersistedSetting): + общие confirm/prompt-диалоги, settings-rows, bottom-sheet chrome, avatar/spinner/header — в переиспользуемые + виджеты. Много сайтов — дели на под-агентов по кластерам экранов. + ФРОНТ E — Lifecycle/dispose/unbounded caches (@«Lifecycle, dispose, and unbounded caches», 14): нотифаеры/ + таймеры/temp-файлы/кеши без release/bound (в т.ч. ContactCache-blob messages.dart:15-108) → dispose/эвикция. + ФРОНТ F — Остатки утилит/строк/уведомлений (@«Duplicated formatting…» 18 + «Hardcoded Russian strings/l10n» 6 + + «SnackBar convention» 4): добить оставшиеся копии форматтеров, вынести хардкод-строки, SnackBar→ + showCustomNotification там, где ещё не переведено. + +Бери столько фронтов, сколько влезает в контекст (цель — максимум за сессию; остаток и не начатые фронты — +в промпт следующей сессии). Каждый вынос: analyze==19 + dart format + parity скептиком против HEAD. + +H6 (CustomFontService legacy UA) — ЕДИНСТВЕННЫЙ незакрытый high, требует проверки на устройстве +(woff2 vs sfnt/TTF от Google Fonts). САМ НЕ ТРОГАЙ вслепую — СПРОСИ пользователя, готов ли проверить шрифт +на устройстве после правки UA; только тогда правь. + +После каждого фронта — строка в «Сессию 11/12» + ✅/🔧/⏭️. В конце — промпт для следующей сессии. +``` + +## Статистика + +- Всего находок: **231** — 🔴 high: **17**, 🟠 medium: **124**, 🟡 low: **90** +- По типу: дублирование **89**, костыли **65**, оптимизация **40**, сомнительные решения **37** + +**Горячие файлы** (по числу привязок находок): + +- `lib/frontend/screens/chats/chat_screen.dart` — 88 +- `lib/frontend/widgets/message_bubble.dart` — 59 +- `lib/backend/modules/messages.dart` — 41 +- `lib/backend/modules/account.dart` — 37 +- `lib/frontend/screens/chats/chat_list_screen.dart` — 33 +- `lib/backend/modules/chats.dart` — 31 +- `lib/frontend/screens/chats/chat_info_screen.dart` — 25 +- `lib/frontend/screens/profile/security_screen.dart` — 19 +- `lib/backend/modules/file_uploader.dart` — 17 +- `lib/frontend/widgets/attachment/photo_editor.dart` — 15 +- `lib/frontend/screens/profile/debug_menu_screen.dart` — 14 +- `lib/backend/modules/calls.dart` — 13 + +## Итог одним абзацем + +> Komet is a functional, feature-rich Flutter messaging client, but the audit reveals a codebase under sustained schedule pressure where "make it work" has repeatedly won over structural hygiene. The dominant systemic problems are massive god-files (chat_screen.dart at 7568 lines, message_bubble.dart at 3476, chat_list_screen.dart at 2888, plus 1500+ line all-static backend modules) that fuse a dozen unrelated responsibilities and are effectively untestable. Pervasive duplication is the second theme: the same request/response guard, send-with-retry loop, formatting helpers (mm:ss, zero-pad, last-seen, Russian plurals), settings rows, confirm dialogs, avatar widgets, and color literals are re-implemented dozens of times, and several copies have already drifted into inconsistent behavior. A recurring architectural violation is untyped Map wire data flowing straight into widgets, forcing four screens to hand-parse contact names with different rules and producing different names on different screens. There is a real cluster of security/privacy defects (plaintext proxy credentials, a drifted debug-log redactor that leaks device IDs into shareable exports, spoofable cloud-storage identity, plaintext IP geolocation) and several silent failures where bare catch blocks swallow connect, upload, and outbox errors with no logging. Performance hot paths suffer from full-list rebuilds on trivial events, per-contact network fan-out where a batched call exists, and synchronous I/O on the UI isolate. Finally there are outright broken affordances: a navigation popUntil that always ejects the user to app root, a custom-font feature that can never succeed due to a User-Agent mismatch, and multiple no-op buttons presented as working features. None of these are catastrophic individually, but the density of duplication and the god-files make every future change slower and riskier. + +## 🎯 Топ-10 приоритетов (максимум эффекта на усилие) + +### 1. Fix the popUntil route-name that always ejects users to app root +**Почему:** After 2FA setup, password change, email change, and account removal, all four success flows pop the entire navigation stack to the app root instead of returning to SecurityScreen, because the matched route name is never set and the predicate silently degrades to route.isFirst. This is a live, user-visible navigation bug on high-value security flows, and the fix is small. + +**Что сделать:** Either set settings: const RouteSettings(name: 'SecurityScreen') at the single push site in settings_tab.dart and use ModalRoute.withName, or capture Navigator.of(context) before pushing the nested flow and pop a known number of routes / pass an explicit return callback. + +### 2. Move proxy credentials into secure storage and fix the debug-log redactor leak +**Почему:** Two independent high-severity privacy defects: SOCKS5/HTTP proxy username and password are stored in plaintext SharedPreferences even though TokenStorage/FlutterSecureStorage already exists for exactly this, and the DebugSessionLog uses a private redactor that has drifted from the shared allowlist, so device IDs (and likely OTP/QR/codes) are written in cleartext into the support export the user is told is safe to share. + +**Что сделать:** Route proxy username/password through TokenStorage.writeSecure/readSecure/deleteSecure (keep only host/port/type in prefs), and delete DebugSessionLog's private redactor to route recordRequest/recordResponse through the shared redactForLog allowlist; fix the false doc comment. + +### 3. Restore the custom-font feature by sending a legacy User-Agent +**Почему:** CustomFontService sends a Chrome/120 UA to Google Fonts (which then serves woff2) but only accepts a legacy TTF response via regex and _isSfnt, so adding any Google font silently fails for every user, and the blanket catch leaves no diagnostic. A whole shipped feature is dead. + +**Что сделать:** Send an older-browser User-Agent so Google Fonts serves the sfnt/TTF container the existing regex and _isSfnt gate expect (Flutter's FontLoader cannot decode woff2), and propagate the failure so the settings caller can surface showCustomNotification instead of a silent no-op. + +### 4. Stop swallowing connect/upload/outbox failures that report success +**Почему:** switchAccount does try{connect()}catch(_){} and returns a profile even when the app is left disconnected; the file-upload path fabricates status 0 on a 1s timeout and treats it identically to a real 200; OutboxService.flush breaks the entire pending loop on the first per-message error and discards it unlogged. Each silently converts a failure into apparent success, corrupting delivery and account state with zero diagnostics. + +**Что сделать:** In switchAccount verify api.state == online after connect and rethrow otherwise (mirroring loginWithToken); model upload as confirmed-success/confirmed-failure/unknown instead of folding timeout into success; change outbox to catch per-row, log via logger, and continue rather than break. + +### 5. Batch the chat-list contact prefetch instead of one request per contact +**Почему:** _prefetchContactsForChats loops and calls searchContactById per unknown id, each firing its own contactInfo packet, and it runs on every chatsChanged/draft-change/login. The module already exposes ensureContactNames which batches all missing ids into one request and is used correctly elsewhere. This is a high-severity perf win with an essentially one-line change. + +**Что сделать:** Replace the per-id loop with await messagesModule.ensureContactNames(ids); _scheduleContactRebuild(); and drop the now-redundant _inflightContactIds bookkeeping. + +### 6. Stop gating the whole message ListView on composer-height and read-time notifiers +**Почему:** The ListView.builder is wrapped in ListenableBuilder(merge([_otherReadTime, _composerHeight])), so ordinary composer interactions (multi-line wrap, reply preview, panel toggle) and every read receipt rebuild all visible MessageBubbles including status recomputation — a systemic frame-cost multiplier on the busiest screen. + +**Что сделать:** Keep the delegate/list stable: reserve bottom padding via a separate SliverPadding/spacer and move the read/seen checkmark into a per-message ValueListenableBuilder inside MessageBubble so only the last own message reacts to _otherReadTime. + +### 7. Introduce typed ContactInfo/ChatInfo models at the fetch boundary +**Почему:** ContactInfo arrives as raw Map and four screens (call, contact profile, NFC, contacts tab) each re-extract the display name with genuinely different priority rules, so the same contact renders different names on different screens. This is a correctness inconsistency and a layering violation, and it recurs for chat-info participant/admin key coercion. + +**Что сделать:** Parse ContactInfo/ChatInfo/PresenceInfo once in the backend contacts/chats module into typed models with a canonical displayName getter (normalizing int/String keys there), have all UI consume the model, and delete the hand-rolled extractors. + +### 8. Extract shared sendRequest guard and send-with-retry helpers +**Почему:** The 'validate isOk + require Map payload' guard is copy-pasted across ~50 methods and the 'not.ready' retry loop across all 7 media senders, with copies already drifted (folders drops SessionExpiredException, sendFileMessage has an extra blind 3s delay). This is the single largest source of backend duplication and future drift. + +**Что сделать:** Add sendRequestMap/sendRequestOk/sendRequestOrThrow helpers on Api and one private _sendAttachedMessage(...) owning payload construction + the retry loop + response unwrap; collapse the ~50 guards and 7 senders onto them, and drop the redundant sendFileMessage initial sleep. + +### 9. Introduce a generic PersistedSetting/PersistedEnum for the ~17 config classes +**Почему:** Every persisted setting hand-rolls key + ValueNotifier + load + save with an inconsistent contract: most rely on main.dart to assign current.value in a matching pair of ~18 load and ~18 assign lines, so forgetting one pairing silently keeps a toggle at its default forever with no signal. High duplication plus a real latent bug class. + +**Что сделать:** Create PersistedSetting and PersistedEnum whose load() always self-assigns current.value, replace each ad hoc class body with a one-line instance, and make load fire-and-forget so the main.dart pairing disappears by construction. + +### 10. Begin decomposing the god-files, starting with the message list and MessageBubble dispatch +**Почему:** chat_screen.dart (7568 lines, 151 methods), message_bubble.dart (3476 lines), and the all-static ChatsModule are untestable, block parallel work, and directly cause the whole-list rebuild problem. Splitting the message list into its own widget simultaneously fixes the highest-impact perf finding. + +**Что сделать:** Extract the message list into its own widget so ancestor setState stops cascading into it; split MessageBubble into per-type bubble widgets under attachment/bubbles/ keyed on the already-typed attachment models; and move ChatsModule toward an instantiable ChatRepository/ChatPushHandler split with chatsChanged on a real notifier. + +## ⚡ Быстрые победы (мелкие, безопасные, ценные) + +- Fix the popUntil route-name predicate so security-flow success screens return to SecurityScreen instead of the app root. +- Replace the spoof_screen ScaffoldMessenger/SnackBar with showCustomNotification — the only SnackBar in the codebase and a direct convention violation. +- Delete the leftover debug logging that ships in release: the [FLIP] debugPrint in chat_list_screen, the per-voice-message hex/ascii file introspection in _sendVoice, and the print('PUSHDBG'/'REPLYDBG') calls in push_service. +- Move proxy username/password to TokenStorage.writeSecure/readSecure/deleteSecure, keeping only host/port/type in SharedPreferences. +- Route DebugSessionLog record paths through the shared redactForLog allowlist and delete its drifted private redactor. +- Swap the per-contact searchContactById loop in the chat-list prefetch for the existing batched ensureContactNames call. +- Delete chat_info_screen's local _formatLastSeen and call the shared formatLastSeen (this also fixes the missing 'Был(-а)' verb on member tiles and requires removing the doubled prefix at line 307). +- Replace the manual CachedMessage constructor in _loadForwardedSenderNames with msg.copyWith(attachments: newAttaches) to stop silently dropping isControl/deleted/editHistory. +- Change PollView.initState to fetch(force: false) so the module cache and in-flight dedup actually apply on scroll-back. +- Guard tz.initializeTimeZones() with a one-time static flag so it does not re-parse the full IANA database on every reconnect. +- Rename the private _two zero-pad helper in format.dart to a public pad2 and delete the local closures/inline padLeft copies; likewise hoist _fileStamp to a shared formatFileStamp. +- Extract a single Random.secure()-backed Uuid.v4()/randomHex utility so calls.dart stops using a non-secure RNG for conversationId. +- Add pause()/resume() to SelfCheckService and wire it into the existing didChangeAppLifecycleState handler so it stops forcing 10s network round-trips while backgrounded. +- Merge the four separate chatUpdate calls in cloud-storage _configurePrivacy into one setChatOptions(options: {...}) for atomicity and 4x fewer round-trips. +- Wrap the push-handler invocation in PacketDispatcher.dispatch() in a try/catch that logs the opcode, so one throwing handler cannot drop the rest of a decoded batch. +- Add per-row logging (and continue instead of break) to OutboxService.flush, and add logging to the bare catch blocks in PollsModule, CallBridge, and the sticker/photo-editor paths. +- Use the existing shared showConfirmDialog and kSheetShape constant at the chat_screen and security_screen sites that re-typed their own copies (including security_screen's divergent 20px sheet radius). +- Remove the no-comments-convention violations: the // TODO markers in chat_screen.build(), the banner/inline comments in chat_info_screen, and the garbled TODO in call_screen. + +## 📋 Все находки по темам + +Легенда: {'optimization': 'ОПТ', 'duplication': 'ДУБЛЬ', 'crutch': 'КОСТЫЛЬ', 'questionable': 'СОМНИТ'} | 🔴 HIGH / 🟠 MED / 🟡 LOW | усилие S/M/L + + +### God-files and missing decomposition (9 — 3 high) + +_A handful of enormous classes own many unrelated responsibilities, blocking isolated testing and guaranteeing merge conflicts. The documented layered architecture (models/, state/) is not actually realized._ + +
+🔴 HIGH · СОМНИТ · [L] — chat_screen.dart is a 7568-line god-file mixing ~10 unrelated responsibilities + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:326 (_ChatScreenState declaration)`, `lib/frontend/screens/chats/chat_screen.dart:730 (_loadHistory / pagination / persistence)`, `lib/frontend/screens/chats/chat_screen.dart:1217 (_checkPrankTrigger easter egg)`, `lib/frontend/screens/chats/chat_screen.dart:1366 (_enterSelection multi-select bulk actions)`, `lib/frontend/screens/chats/chat_screen.dart:3044 (_startCall call initiation/teardown)`, `lib/frontend/screens/chats/chat_screen.dart:3815 (_openSearch in-chat search overlay)`, `lib/frontend/screens/chats/chat_screen.dart:4860 (_startVoiceRecording voice/video-note state machines)`, `lib/frontend/screens/chats/chat_screen.dart:6107 (_openAttachmentSheet composer send logic)` + + +**Проблема:** Confirmed: one StatefulWidget State class (_ChatScreenState with TickerProviderStateMixin, WidgetsBindingObserver) owns 151 private methods (audit said '130+') spanning message-list rendering/pagination, DB persistence, text/voice/video-note/photo/video/file/sticker/poll/location composer logic, in-chat search, multi-select bulk actions, typing/read-receipt/presence tracking, call initiation, and an unrelated prank/easter-egg feature. All cited locations verified. This makes any single concern untestable in isolation and guarantees merge conflicts when two devs touch different chat features. + + +**Решение:** Split into lib/frontend/screens/chats/chat/ with: a ChatController (ChangeNotifier owning history/pagination/persistence + message-event/presence subscriptions), a ChatComposer widget+controller (text/voice/video-note/attachment sending), a ChatSearchOverlay, and a ChatSelectionBar. Move _checkPrankTrigger/_runPrankReveal into core/config/app_pranks.dart alongside the existing AppPranks config. ChatScreen becomes a thin composition. No comments; proper rewrite over hack per project conventions. + +
+ +
+🔴 HIGH · СОМНИТ · [L] — MessageBubble (3476 lines) renders every attachment type and two stateful media players in one widget + + +**Где:** `lib/frontend/widgets/message_bubble.dart:793 (_buildContent switch dispatch)`, `lib/frontend/widgets/message_bubble.dart:1214-2450 (per-attachment builders: photo/poll/share/file/call/location/video/sticker/contact + forwarded variants)`, `lib/frontend/widgets/message_bubble.dart:2814 (_VoiceMessageBubble stateful player embedded in same file)`, `lib/frontend/widgets/message_bubble.dart:3291 (_VideoNoteBubble stateful player embedded in same file)`, `lib/frontend/widgets/message_bubble.dart:716 (_openMiniApp WebView launcher embedded in bubble)` + + +**Проблема:** Confirmed, with one correction: the file is 3476 lines, not 2988 as the audit title claimed. One class contains ~12 distinct _build*Attachment/_build*Content methods (one per type, plus forwarded variants) plus two fully independent stateful player widgets (_VoiceMessageBubble at 2814, _VideoNoteBubble at 3291) and a mini-app WebView launcher (_openMiniApp at 716). Typed attachment models (PhotoAttachment, PollAttachment, ShareAttachment, LocationAttachment, VideoAttachment) already exist in models/attachment.dart, so there is no obstacle to per-type splitting. + + +**Решение:** Create lib/frontend/widgets/attachment/bubbles/ with one file per type (photo_bubble, file_bubble, call_bubble, location_bubble, contact_bubble, poll_bubble, share_bubble, voice_bubble, video_note_bubble), each taking the already-typed attachment model and _BubbleCtx. MessageBubble becomes a dispatcher keyed on AttachmentType, mirroring the existing attachment/ folder (photo_editor.dart, attachment_sheet.dart). + +
+ +
+🔴 HIGH · СОМНИТ · [L] — ChatsModule is a 1739-line all-static namespace mixing push dispatch, DB parsing, CRUD, and text formatting behind shared mutable static state + + +**Где:** `lib/backend/modules/chats.dart:249 (class ChatsModule — 74 static members, 0 instance methods)`, `lib/backend/modules/chats.dart:455 (static final ValueNotifier chatsChanged — shared global mutable state; _bump at 456)`, `lib/backend/modules/chats.dart:460 (static Future _pushQueue serialized chain) and :462 (static Set _historyFetched)`, `lib/backend/modules/chats.dart:499-878 (_handleGlobalPush/_handlePresence/_handleNotifMessage/_handleNotifMsgDelete/_handleNotifMsgReactionsChanged/_handleNotifMark)`, `lib/backend/modules/chats.dart:974-1195 (cacheServerChat/_parseChat DB row parsing)`, `lib/backend/modules/chats.dart:1540 (togglePin) and CRUD/settings commands following`, `lib/backend/modules/chats.dart:257-347 (attachPreviewLabel/messagePreviewText pure text-formatting helpers)`, `lib/backend/modules/chats.dart:34 (CachedChat domain model defined inside the module file)` + + +**Проблема:** Confirmed, with correction: the file is 1739 lines, not 1490 as the audit title claimed. Every one of the ~74 members is static (grep found 0 instance methods in the class body), and cross-cutting mutable state (chatsChanged ValueNotifier at 455, serialized _pushQueue Future at 460, _historyFetched Set at 462) lives as static fields. This is a global singleton in disguise: it cannot be mocked/faked for testing screens that depend on it, and it conflates server-push handling, local DB caching/parsing, chat command RPCs, and pure preview-text formatting. Kept at high because the concern is genuine testability/global-state hazard, not merely file size. + + +**Решение:** Convert to an instantiable, injectable design: ChatRepository (DB caching/parsing: cacheServerChat/_parseChat/getChats), ChatPushHandler (the _handleNotif*/_handlePresence dispatch, subscribed once from api.dart), ChatCommands (pin/mute/delete/leave/photo/title RPCs), and a stateless ChatPreviewFormatter for attachPreviewLabel/messagePreviewText. Move chatsChanged onto a real per-repository ChangeNotifier instead of a static ValueNotifier. + +
+ +
+🟠 MED · КОСТЫЛЬ · [L] — CLAUDE.md's documented 'state/' ChangeNotifier layer does not exist — state is ad hoc StreamSubscription + setState duplicated per screen + + +**Где:** `lib/main.dart:71 (global singleton `final api = Api();`, with accountModule/messagesModule singletons following, used directly by screens instead of DI)`, `lib/frontend/screens/chats/chat_screen.dart:532-542 (manual StreamSubscription wiring: _onIncomingPush, ChatsModule.messageEvents, api.stateStream, ChatActivityStore listenable)`, `lib/frontend/screens/chats/chat_list_screen.dart:553-645 (separate, independently-implemented subscriptions to the same push/message-event sources)`, `lib/backend/modules/chats.dart:358-361 (static StreamController broadcast used as de facto pub/sub bus)`, `lib/backend/modules/account.dart:450-459 (separate StreamController broadcast, a second ad hoc bus)`, `lib/frontend/widgets/message_actions_overlay.dart:18, lib/frontend/widgets/account_switcher_overlay.dart:12, lib/frontend/screens/chats/chat_list_screen.dart:2722, lib/backend/modules/polls.dart:7, lib/core/transport/traffic_monitor.dart:106 (the only 5 ChangeNotifier classes in the whole codebase, none under a state/ layer)` + + +**Проблема:** Confirmed. There is no lib/state/ directory despite CLAUDE.md documenting 'state/ — ChangeNotifier state classes consumed by the UI'. Only 5 ChangeNotifier classes exist and they are scattered (an overlay controller, an account switcher, a private _StoriesUi embedded in chat_list_screen.dart, the polls module, a traffic monitor) rather than forming a UI-facing layer. The real pattern is a global api singleton (main.dart:71) plus per-screen StreamSubscriptions to independent static StreamControllers (messageEvents in chats.dart:358, loginStatusStream in account.dart:450, raw push packets), each manually listened and setState-ed; chat_screen.dart and chat_list_screen.dart both independently subscribe to the same MessageEvent stream and reimplement similar reload logic. + + +**Решение:** Either (a) fix CLAUDE.md to describe the actual singleton+stream pattern, or (b) build the documented layer: wrap the module event streams in ChangeNotifier classes under lib/state/ (ChatListState, ChatState, AccountState) that screens obtain via a single InheritedNotifier/Provider at the app root, eliminating the duplicated StreamSubscription/dispose boilerplate. + +
+ +
+🟠 MED · КОСТЫЛЬ · [L] — Core domain models (CachedMessage, CachedChat) live inside backend/modules instead of models/, and Map stands in for typed models across the codebase + + +**Где:** `lib/backend/modules/messages.dart:358 (CachedMessage — the actual message model; payload: Map? at line 366, editHistory: List>? at line 370)`, `lib/backend/modules/chats.dart:34 (CachedChat — the actual chat model, defined in the module file, not in lib/models/)`, `lib/backend/modules/messages.dart (49 occurrences of Map; all 7 send*Message methods return raw Map? — see finding on send-method duplication)`, `lib/frontend/widgets/message_bubble.dart:807 (`message.payload?['reactionInfo']`) and 822-830 (raw Map indexing: info['counters'], info['yourReaction'], c['reaction'], c['count'])` + + +**Проблема:** Confirmed, with correction to the scale figures: Map appears ~304 times across ~38 files (audit claimed '303 occurrences across 63 files' — the file count is overstated). The substantive claim holds: lib/models/ contains only 5 files, yet the two most-used domain types — CachedMessage (messages.dart:358) and CachedChat (chats.dart:34) — are defined inside backend/modules, contradicting the layering CLAUDE.md documents. CachedMessage itself falls back to payload: Map? for reaction/control data, forcing message_bubble.dart to do untyped key-indexing (info['counters'], info['yourReaction']) with no compile-time safety. + + +**Решение:** Move CachedMessage/CachedChat (and sibling small types) into lib/models/message.dart and lib/models/chat.dart so models/ holds the domain types the modules operate on, per the documented layering. Introduce typed result classes (e.g. SendMessageResult) to replace the Map? returns from the send*Message family, and add a typed ReactionInfo class parsed once in CachedMessage to replace ad hoc payload?['reactionInfo'] indexing in message_bubble.dart. + +
+ +
+🟠 MED · СОМНИТ · [L] — Monolithic ~7200-line State class couples unrelated setState calls to the message list + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:326`, `lib/frontend/screens/chats/chat_screen.dart:4356`, `lib/frontend/screens/chats/chat_screen.dart:4519-4522` + + +**Проблема:** `_ChatScreenState` is a single ~7200-line class (lines 326-7568) combining message rendering, search, selection, voice/video recording, sticker/attachment panels, wallpaper, forwarding and an easter egg under one `build()` (returns a `ListenableBuilder` at 4356). Its 24 raw `setState` calls (confirmed) rebuild that subtree, which recreates `_buildMessagesList`'s ValueListenableBuilder and the `ListView.builder`, re-invoking `itemBuilder` for every visible message even for changes unrelated to message content. The primary cost here is maintainability; the perf angle mostly overlaps finding #1 and only bites on the less-frequent setState paths (pagination flag, wallpaper load, metadata arrival). + + +**Решение:** Split the message list into its own widget so ancestor setState calls stop cascading into it (this also resolves finding #1's rebuild scope), and follow the ValueNotifier/ListenableBuilder pattern already used in this file (`_headerStatusNotifier`, `_scheduledCount`) for the remaining plain fields (`chat`, `_wallpaper`, `_isLoadingMore`, `_participantsCount`). + +
+ +
+🟠 MED · СОМНИТ · [L] — chat_list_screen.dart (2888 lines) fuses stories UI, folder paging, pinned header, chat tiles, and bottom nav into one screen + + +**Где:** `lib/frontend/screens/chats/chat_list_screen.dart:1006 (_onStoriesRevealTick reveal/close animation state machine)`, `lib/frontend/screens/chats/chat_list_screen.dart:2722 (_StoriesUi ChangeNotifier defined inline in the screen file)`, `lib/frontend/screens/chats/chat_list_screen.dart:843 (_syncFolderChatScrollControllers folder paging + per-folder scroll sync)`, `lib/frontend/screens/chats/chat_list_screen.dart:1209 (_buildPinnedChatsHeader)`, `lib/frontend/screens/chats/chat_list_screen.dart:1718 (_buildDockedBottomNav docked nav + FAB)` + + +**Проблема:** Confirmed. One 2888-line State class owns six largely independent subsystems (stories carousel with its own inline animation-driven _StoriesUi ChangeNotifier at 2722, folder tab paging with per-folder ScrollControllers, pinned-chats header, chat tile + swipe actions, account-switcher trigger, docked bottom nav/FAB), with 17 setState call sites. Each subsystem carries its own listeners/animation controllers/timers wired and disposed in one giant State, blocking partial reuse (e.g. reusing the chat tile in a forwarding picker). Severity lowered from high to medium: this is a size/organization split with no correctness or perf impact. + + +**Решение:** Extract StoriesBar (promote the inline _StoriesUi to lib/frontend/widgets/stories_bar.dart), FolderTabsView, PinnedChatsHeader, ChatListTile, and DockedBottomNav as standalone widgets under frontend/widgets/, each owning its own controller/animation lifecycle. ChatListScreen keeps only cross-cutting orchestration (selected folder, chat feed). + +
+ +
+🟠 MED · СОМНИТ · [M] — debug_menu_screen.dart has a single ~1300-line build() method stitching together a dozen unrelated debug tools + + +**Где:** `lib/frontend/screens/profile/debug_menu_screen.dart:251 (Widget build, runs unbroken to ~1576 where _SyncProbeCard class begins — ~1320 lines)`, `lib/frontend/screens/profile/debug_menu_screen.dart:1550 (_SearchResultCard used inside build)`, `lib/frontend/screens/profile/debug_menu_screen.dart:1566 (_SyncProbeCard used inside build)`, `lib/frontend/screens/profile/debug_menu_screen.dart:1577 (_SyncProbeCard model to follow for extraction)` + + +**Проблема:** Confirmed: a single build() spans line 251 through ~1576 (the first sibling class _SyncProbeCard starts at 1577), ~1320 lines, inlining roughly a dozen independent debug sections (FPS overlay, VPN/TLS bypass toggles, traffic monitor, WebView cache reset, stories/commands/link-preview feature flags, media cache management, call-screen preview, ID-search probe, sync probe) as nested widget trees. The lib/frontend/debug/ directory already exists (currently only fps_overlay_layer.dart), so there is an established home for extracted sections. + + +**Решение:** Break each toggle/section into a small widget under lib/frontend/debug/ (DebugNetworkSection, DebugCacheSection, DebugFeatureFlagsSection, DebugSearchProbeSection — the existing _SyncProbeCard/_SearchResultCard are the pattern to follow) and have build() lay out a ListView of these section widgets. + +
+ +
+🟠 MED · СОМНИТ · [L] — AccountModule (1524 lines) bundles auth, profile, privacy, 2FA, and multi-session management into one class + + +**Где:** `lib/backend/modules/account.dart:448 (class AccountModule — instantiable, takes final Api _api)`, `lib/backend/modules/account.dart:465-628 (getPrivacyConfig/blocklist/profile-name/avatar/photo updates)`, `lib/backend/modules/account.dart:628-840 (create2faTrack..remove2fa — 13 two-factor methods)`, `lib/backend/modules/account.dart:876-1039 (requestCode/resendCode/verifyCode/completeRegistration/login)`, `lib/backend/modules/account.dart:1040-1153 (getSessions/terminateOtherSessions/beginAddAccount — session + multi-account)` + + +**Проблема:** Confirmed. One module answers for privacy/blocklist (465-628), profile editing (551-628), two-factor auth (628-840, 13 methods not 15 as claimed), auth/registration (876-1039), and session/multi-account management (1040+), with all request/response models (PrivacyConfig, BlockedContact, TwoFactorDetails, SessionInfo, LoginResult, VerifyCodeResult, etc.) declared at the top of the same file. It is instantiable (not static), so a facade split is clean. Severity set to medium: 1524 lines with already-clear domain groupings is a moderate maintainability concern, not high. + + +**Решение:** Split into backend/modules/account/ with auth_module.dart, profile_module.dart, privacy_module.dart, two_factor_module.dart, and sessions_module.dart, moving the corresponding model classes alongside each. Keep AccountModule as a thin facade if call sites need one entry point. + +
+ + +### Duplicated request/response and send-path boilerplate (12 — 1 high) + +_The core backend idioms — validate a packet, require a Map payload, send-with-not-ready-retry, extract a message id — are copy-pasted across dozens of methods and five media senders, and several copies have already drifted._ + +
+🔴 HIGH · ДУБЛЬ · [L] — Raw HTTP upload/response machinery is reimplemented across five upload paths + + +**Где:** `lib/backend/modules/file_uploader.dart:71-155`, `lib/backend/modules/file_uploader.dart:163-221`, `lib/backend/modules/file_uploader.dart:259-310`, `lib/backend/modules/file_uploader.dart:312-377`, `lib/backend/modules/file_uploader.dart:386-506` + + +**Проблема:** upload(), uploadMediaFile(), uploadImage(), uploadPhoto() and uploadVideoFile()/_okCdnRequest() each hand-roll the same sequence: open a socket via _openSocket, build raw HTTP headers with a StringBuffer, stream the body, read the response, destroy the socket in try/catch, log/swallow errors. The chunk.map stopwatch-throttled progress pattern is byte-for-byte identical in three places (97-104, 189-196, 342-349), and there are three separate header builders (_writeHeaders, _writeImageHeaders, and the inline builder in _okCdnRequest at 477-489) duplicating Host/Content-Type/Content-Length/Connection boilerplate. Any protocol change (proxy handling, TLS pinning, header ordering, adding the missing User-Agent to _okCdnRequest) must be applied in up to five places and will drift. + + +**Решение:** Extract a single low-level helper _sendHttpRequest(uri, method, headers: Map, body: Stream> | Uint8List, {onProgress, timeout}) -> (int status, String body) that owns socket lifecycle (open/destroy/catch) and serializes headers from a map. Extract the progress-throttle map() into a reusable transformer withProgress(src, cb, throttle, total). Reimplement all five paths on these two primitives so header/socket logic lives in one place. + +
+ +
+🟠 MED · ДУБЛЬ · [M] — Five near-identical msgSend 'not.ready' retry loops duplicated across upload senders + + +**Где:** `lib/backend/modules/messages.dart:1199-1213`, `lib/backend/modules/messages.dart:1249-1268`, `lib/backend/modules/messages.dart:1326-1345`, `lib/backend/modules/messages.dart:1416-1435`, `lib/backend/modules/messages.dart:1499-1518` + + +**Проблема:** sendFileMessage, sendPhotoMessage, sendVideoMessage, sendAudioMessage and sendVideoNoteMessage each hand-roll the identical for-loop: sendRequest(Opcode.msgSend, payload), catch PacketError, rethrow (with a logger.w) unless errorKey contains 'not.ready', await retryDelay, give up after maxAttempts. Any change (backoff, extra error keys, cancellation) must be applied five times and will drift. sendFileMessage already diverges: it returns bool (true/false) and skips the data['message'] extraction the other four share. + + +**Решение:** Extract a private helper Future?> _sendMsgWithRetry(Map payload, {int maxAttempts, Duration retryDelay}) owning the loop, PacketError handling/logging and the data['message'] extraction; the four Map-returning senders call it directly, and sendFileMessage adapts (non-null => true). + +
+ +
+🟠 MED · ДУБЛЬ · [S] — 'check packet error then require Map payload' idiom copy-pasted across ~15 request methods + + +**Где:** `lib/backend/modules/account.dart:474-494`, `lib/backend/modules/account.dart:496-521`, `lib/backend/modules/account.dart:628-643`, `lib/backend/modules/account.dart:669-701`, `lib/backend/modules/account.dart:723-759`, `lib/backend/modules/account.dart:876-920`, `lib/backend/modules/account.dart:1153-1201`, `lib/backend/modules/account.dart:1462-1503` + + +**Проблема:** Nearly every request method in AccountModule repeats the same idiom: call _checkPacketError(packet, tag), then `final data = packet.payload; if (data is! Map) throw Exception(': неожиданный тип payload: ${data.runtimeType}')`, then cast. It recurs in getBlockedContacts, updatePrivacyConfig, create2faTrack, enter2faPanel, get2faDetails, verify2faEmail, verify2faCode, verifyCode, completeRegistration, login, checkPassword, _requestCodeInternal, etc. Besides the boilerplate, the 'not a Map' path throws an untyped generic Exception while other errors in the same file throw the typed PacketError, so callers cannot catch payload-shape failures consistently. + + +**Решение:** Extract one private helper, e.g. `Map _requireMapPayload(Packet packet, String method) { _checkPacketError(packet, method); final data = packet.payload; if (data is! Map) throw PacketError('$method: unexpected payload type ${data.runtimeType}'); return data.cast(); }`, and have each method call `final data = _requireMapPayload(packet, 'methodName');`. Removes the duplicated boilerplate and gives all payload-shape failures the same typed PacketError callers already handle. + +
+ +
+🟠 MED · ДУБЛЬ · [S] — updateProfileName / updateProfileAvatar / removeProfilePhoto duplicate the entire response-parse-and-persist body + + +**Где:** `lib/backend/modules/account.dart:551-570`, `lib/backend/modules/account.dart:572-590`, `lib/backend/modules/account.dart:608-625` + + +**Проблема:** After their differing request, all three methods run an identical body: check packet.isError, cast payload to Map, drill 'profile' -> 'contact', build ProfileData.fromServerMap(contact), call AppDatabase.saveProfile(newProfile, isActive: true), and return newProfile. Only the outgoing sendRequest differs. + + +**Решение:** Factor out `Future _applyProfileResponse(Packet packet)` that performs the shared error-check/extract/persist/return sequence, and have the three methods await their own request then `return _applyProfileResponse(packet);`. (This is also the same profile->contact extraction done in _processProfileUpdate and _processLoginResponse, so the helper can be reused there.) + +
+ +
+🟠 MED · ДУБЛЬ · [M] — The "sendRequest -> validate isOk/Map -> extract or bail" guard is copy-pasted across ~50 module methods with no shared helper + + +**Где:** `lib/backend/modules/calls.dart:90-93`, `lib/backend/modules/calls.dart:132-134`, `lib/backend/modules/calls.dart:155-158`, `lib/backend/modules/calls.dart:199-201`, `lib/backend/modules/stickers.dart:54-55`, `lib/backend/modules/stickers.dart:75-76`, `lib/backend/modules/stickers.dart:99-101`, `lib/backend/modules/stickers.dart:127-128`, `lib/backend/modules/stickers.dart:146-147`, `lib/backend/modules/stickers.dart:171-172`, `lib/backend/modules/messages.dart:987-993`, `lib/backend/modules/account.dart:481-488`, `lib/backend/modules/account.dart:503-511` + + +**Проблема:** Nearly every module method that talks to the server repeats the same skeleton: send a request, then guard with some equivalent of `if (!response.isOk || response.payload is! Map) return ;` before casting `response.payload as Map` and pulling a field. The guard is written several ways (`!response.isOk`, `!response.isOk || response.payload is! Map`, `response.isOk && response.payload is Map`), so the same intent is spelled differently in every file. Note: this is a maintainability/readability issue only, not a correctness bug — the `!isOk`-only variants re-check `is Map` on the following lines, so behaviour is consistent; the original finding's claim that a bug 'already happened' from a drifted variant is not supported by the code. + + +**Решение:** Add a thin helper next to `sendRequest` in api.dart (around line 316): `Future?> sendRequestMap(int opcode, Map payload) async { final r = await sendRequest(opcode, payload); return (r.isOk && r.payload is Map) ? r.payload as Map : null; }`. Each call site collapses to `final data = await _api.sendRequestMap(Opcode.X, payload); if (data == null) return ...;`. Works for both the instance-`_api` modules and the static `Api api` modules (chats/folders). Keep the follow-on field extraction at each site — only the guard is shared. + +
+ +
+🟠 MED · ДУБЛЬ · [M] — Packet error-check (_checkPacketError) is reimplemented per-module and the folders.dart copy drops the SessionExpiredException case + + +**Где:** `lib/backend/api.dart:386-393`, `lib/backend/modules/account.dart:1513-1523`, `lib/backend/modules/account.dart:481-487`, `lib/backend/modules/account.dart:504-510`, `lib/backend/modules/folders.dart:199-201`, `lib/backend/modules/folders.dart:241-243` + + +**Проблема:** account.dart defines `_checkPacketError(Packet, String method)` (1513-1523) that maps FAIL_LOGIN_TOKEN/FAIL_WRONG_PASSWORD to SessionExpiredException and otherwise throws PacketError; it is called ~21 times, with many sites (e.g. 481-487, 504-510) also repeating the identical `if (data is! Map) throw Exception('$method: неожиданный тип payload: ...')` block verbatim. folders.dart independently reimplements a weaker check (199-201, 241-243: `if (packet.isError) throw PacketError(...)`) that omits the SessionExpiredException special-case. Correction to the original claim: the practical impact is smaller than stated — the transport layer at api.dart:386-393 already pushes SessionExpiredException into `_sessionExpiredController` for ANY module when the server returns those codes, so global session-expiry handling still fires for folders. The only real divergence is the exception *type* thrown to the immediate caller (PacketError vs SessionExpiredException). The value here is deduplication and consistency, not fixing a broken session flow. + + +**Решение:** Move the check onto Api as `Future> sendRequestOrThrow(int opcode, Map payload, String method)` that runs sendRequest, applies the FAIL_LOGIN_TOKEN/FAIL_WRONG_PASSWORD -> SessionExpiredException / else PacketError logic, then asserts the payload is a Map (throwing the `неожиданный тип payload` Exception otherwise) and returns it. Delete account.dart's `_checkPacketError` plus the repeated is-Map blocks and folders.dart's two inline copies, and route all three through the shared method so folders gains the same thrown-exception behaviour. + +
+ +
+🟠 MED · ДУБЛЬ · [S] — updateProfileName / updateProfileAvatar / removeProfilePhoto share an identical response-unwrap-and-persist tail + + +**Где:** `lib/backend/modules/account.dart:551-570`, `lib/backend/modules/account.dart:572-590`, `lib/backend/modules/account.dart:608-625` + + +**Проблема:** All three methods send an Opcode.profile-family request with different payloads, then run a byte-for-byte identical tail: throw on `packet.isError`, cast `payload as Map?` (throw if null), pull `data['profile'] as Map?` (throw if null), pull `profile['contact'] as Map?` (throw if null), build `ProfileData.fromServerMap(contact.cast())`, `AppDatabase.saveProfile(newProfile, isActive: true)`, and return it. Any change to profile-response parsing must be made in three places. (The nearby `getAvatarUploadUrl` at 592-606 is correctly NOT part of this — it returns a url, not a ProfileData.) + + +**Решение:** Extract a private `Future _applyProfileResponse(Packet packet)` that performs the shared throw/unwrap/save/return, then have all three methods `return _applyProfileResponse(await _api.sendRequest(Opcode.X, payload));`. No comments, matches existing style. + +
+ +
+🟠 MED · ДУБЛЬ · [S] — sendMessage and forwardMessage duplicate the send-error + message-id-extraction block verbatim + + +**Где:** `lib/backend/modules/messages.dart:798-815`, `lib/backend/modules/messages.dart:847-864` + + +**Проблема:** Both methods send via Opcode.msgSend and then run the same block: if `!response.isOk`, pull `localizedMessage`/`message` from the error payload (falling back to a hardcoded default) and throw; otherwise dig into `response.payload['message']['id']` and return it as a String, or `''` if absent. The only difference between the two copies is the fallback string ('Ошибка отправки' vs 'Ошибка пересылки'). + + +**Решение:** Factor out `Future _sendAndExtractMessageId(Map payload, String defaultError)` on the messages module that sends Opcode.msgSend, throws the localized error on failure, and returns the extracted id (or ''). Call it from both sendMessage and forwardMessage with their payload and default-error text. + +
+ +
+🟠 MED · ДУБЛЬ · [M] — Identical 'attachment.not.ready' retry-with-delay loop copy-pasted across 5 send*Message methods + + +**Где:** `lib/backend/modules/messages.dart:1199`, `lib/backend/modules/messages.dart:1249`, `lib/backend/modules/messages.dart:1326`, `lib/backend/modules/messages.dart:1416`, `lib/backend/modules/messages.dart:1499` + + +**Проблема:** sendFileMessage (1199), sendPhotoMessage (1249), sendVideoMessage (1326), sendAudioMessage (1416) and sendVideoNoteMessage (1499) each contain their own copy of the `for (var attempt = 0; attempt < maxAttempts; attempt++) { try { ...sendRequest(Opcode.msgSend, payload)... } on PacketError catch (e) { if (!(e.errorKey?.contains('not.ready') ?? false)) { logger.w(...); rethrow; } if (attempt == maxAttempts - 1) return null/false; await Future.delayed(retryDelay); } }` block. Only the onOk mapping (bool vs extracting data['message'] into Map) and return type differ. Any change to the retry/backoff policy must be made in 5 places, and it has already drifted: initialDelay + a leading `await Future.delayed(initialDelay)` exists only on sendFileMessage. + + +**Решение:** Extract a private generic helper e.g. `Future _sendWithNotReadyRetry({required Map payload, required int maxAttempts, required Duration retryDelay, Duration? initialDelay, required T? Function(SendResponse) onOk})` in messages.dart and have all 5 send*Message methods build their payload and delegate the retry loop to it. This also lets initialDelay be applied uniformly instead of ad-hoc. + +
+ +
+🟠 MED · ДУБЛЬ · [M] — Every per-media-type send method in MessagesModule duplicates the same construct-payload + retry-on-not-ready loop + + +**Где:** `lib/backend/modules/messages.dart:1224 (sendPhotoMessage)`, `lib/backend/modules/messages.dart:1299 (sendVideoMessage)`, `lib/backend/modules/messages.dart:1384 (sendAudioMessage)`, `lib/backend/modules/messages.dart:1471 (sendVideoNoteMessage)`, `lib/backend/modules/messages.dart:1521 (sendLocationMessage)`, `lib/backend/modules/messages.dart:1554 (sendPollMessage)`, `lib/backend/modules/messages.dart:1602 (sendStickerMessage)` + + +**Проблема:** Confirmed by direct diff. sendPhotoMessage (1249-1268) and sendAudioMessage (1416-1435) contain byte-for-byte identical retry loops (for attempt in 0..maxAttempts { sendRequest(Opcode.msgSend, payload); on PacketError catch e { if !errorKey.contains('not.ready') { logger.w; rethrow } else if last attempt return null else delay } }), plus identical response-unwrap blocks. The same pattern repeats across all 7 send*Message methods (all return Map?), differing only in the type-specific 'attaches'/extra message fields. Each copy is an independent bug target (e.g. one method omitting the not.ready check would silently diverge under server backpressure). + + +**Решение:** Extract one private helper Future?> _sendAttachedMessage(int chatId, List> attaches, {String? caption, bool notify, int? scheduledTime, int maxAttempts, Duration retryDelay, Map? extraMessageFields}) that owns payload construction, the retry loop, and response unwrap. All 7 public methods build only their type-specific attaches/extra fields and delegate. + +
+ +
+🟡 LOW · КОСТЫЛЬ · [S] — Server error-message extraction duplicated verbatim in sendMessage and forwardMessage as untyped Map access + bare Exception + + +**Где:** `lib/backend/modules/messages.dart:799-806`, `lib/backend/modules/messages.dart:848-855` + + +**Проблема:** Both methods repeat the identical (response.payload is Map) ? (response.payload['localizedMessage'] ?? response.payload['message'] ?? 'Ошибка ...') : 'Ошибка ...' pattern and throw a bare Exception(String), so the UI can only string-match to distinguish error kinds. + + +**Решение:** Extract String _extractErrorMessage(PacketResponse, String fallback) and throw a small typed exception (e.g. MessageSendException) so callers can catch it specifically. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — Trivial 'fire request, return isOk' one-liners repeated across modules + + +**Где:** `lib/backend/modules/calls.dart:233-236`, `lib/backend/modules/messages.dart:963`, `lib/backend/modules/messages.dart:1035`, `lib/backend/modules/messages.dart:1062`, `lib/backend/modules/messages.dart:1085`, `lib/backend/modules/chats.dart:1503`, `lib/backend/modules/chats.dart:1515` + + +**Проблема:** Seven toggle-style methods do nothing but `final response = await _api.sendRequest(Opcode.X, payload); return response.isOk;` (delete history, edit message, edit scheduled message, delete messages, set chat photo, set chat options). Minor, but it is one more request/response shape inconsistent with the Map-returning variants nearby. + + +**Решение:** Add `Future sendRequestOk(int opcode, Map payload) async => (await sendRequest(opcode, payload)).isOk;` to Api and have these sites return it directly. Low priority — bundle with the sendRequestMap helper from finding 1 since it is the same class of change. + +
+ + +### Duplicated formatting and shared-utility gaps (18 — 0 high) + +_Time, date, phone, plural, zero-pad, filename-timestamp and UUID helpers are re-implemented across many files instead of living once in core/utils, and the private helpers that do exist are not exposed._ + +
+🟠 MED · ДУБЛЬ · [S] — _uuidV4 reimplemented three times, with calls.dart using a non-secure RNG + + +**Где:** `lib/backend/modules/calls.dart:182-191`, `lib/core/storage/device_identity.dart:41-48`, `lib/core/storage/spoofing_service.dart:252-259` + + +**Проблема:** The same UUID v4 algorithm is copy-pasted in three places. device_identity.dart:42 and spoofing_service.dart:253 both seed from a shared Random.secure() (_rng), but calls.dart:183 uses a plain, non-cryptographic Random() to build the call conversationId sent to the server. The RNG inconsistency exists only because the logic was duplicated instead of shared. (Security impact of a guessable conversationId is minor since calls are server-authenticated, but the DRY violation and the divergent RNG are both real.) + + +**Решение:** Extract a single Uuid.v4() utility backed by Random.secure() (e.g. lib/core/utils/uuid.dart) and have device_identity.dart, spoofing_service.dart, and calls.dart all call it. + +
+ +
+🟠 MED · ДУБЛЬ · [S] — Local _formatLastSeen omits the 'Был(-а)' verb, producing a bare 'N мин назад' on member tiles while every other last-seen surface prefixes it + + +**Где:** `lib/frontend/screens/chats/chat_info_screen.dart:1157-1165`, `lib/frontend/screens/chats/chat_info_screen.dart:307`, `lib/frontend/screens/chats/chat_info_screen.dart:818`, `lib/frontend/screens/chats/chat_info_screen.dart:1167-1174`, `lib/core/utils/format.dart:63-71` + + +**Проблема:** core/utils/format.dart already exports formatLastSeen(int) returning a fully-formed 'Был(-а) ...' string (reused by contact_profile_screen.dart / chat_screen.dart). chat_info_screen.dart instead defines a private _formatLastSeen with different thresholds and no leading verb, then manually prepends 'был(-а) ' at the dialog subtitle (line 307) but NOT at the member-tile call site (line 818: `sublabel = _formatLastSeen(member.seenTime!)`), so group members display a bare 'N мин назад' with no verb, inconsistent with the rest of the app. Separately, _pluralCount (lines 1167-1174) reimplements Russian plural-form selection that already exists as functional variants in call_screen.dart:719-726, sticker_pack_sheet.dart:337, and poll_view.dart:375. + + +**Решение:** Delete _formatLastSeen and call the shared formatLastSeen from core/utils/format.dart, removing the manual 'был(-а) ' prefix at line 307 (which fixes the missing-verb bug at line 818 for free). Extract the Russian plural logic into one shared helper (e.g. String pluralRu(int n, String one, String few, String many) in core/utils/format.dart) and route this file plus call_screen, sticker_pack_sheet, and poll_view through it. + +
+ +
+🟠 MED · ДУБЛЬ · [S] — Duplicated dynamic-to-int parsing with an inconsistent '?? 0' fallback that fabricates a fake id + + +**Где:** `lib/models/attachment.dart:361`, `lib/models/attachment.dart:451`, `lib/models/attachment.dart:452`, `lib/models/attachment.dart:480`, `lib/models/attachment.dart:525-528`, `lib/models/attachment.dart:613-615` + + +**Проблема:** The idiom `v is int ? v : int.tryParse(v?.toString() ?? '')` is reimplemented five times (ContactAttachment.contactId:361, ControlAttachment.userIds:451 / userId:452, PollAttachment.pollId:480, CallAttachment.contactIds:525-528, InlineKeyboardButton.contactId:613-615) with three different fallbacks (null, 0, dropped). Two of them — ControlAttachment.userIds (451) and CallAttachment.contactIds (525-528) — use `... ?? 0` inside a list map, so an unparseable element is turned into a fabricated id `0` rather than dropped. Downstream code resolving those ids to contacts/names cannot tell a real id 0 from a parse failure, so a malformed server payload can silently attribute a control/call event to the wrong (or a nonexistent) contact. Impact is limited to malformed payloads, but the fabricated-id semantics is a genuine latent correctness issue, and the duplication is how the inconsistency arose. + + +**Решение:** Extract two top-level helpers, `int? parseIntOrNull(dynamic v)` and `List parseIntList(dynamic v)` (the list helper using `int.tryParse` + `whereType()` to drop invalid entries rather than coalescing to 0), and have every factory in the file call them. This removes the five copies and, critically, changes userIds/contactIds to drop unparseable entries instead of inserting a fake id 0. + +
+ +
+🟠 MED · ДУБЛЬ · [S] — "Last seen" relative-time text re-derived from scratch instead of calling shared formatLastSeen + + +**Где:** `lib/frontend/screens/chats/chat_info_screen.dart:1157-1165`, `lib/frontend/screens/chats/chat_info_screen.dart:307`, `lib/frontend/screens/chats/chat_info_screen.dart:818`, `lib/core/utils/format.dart:62-71` + + +**Проблема:** chat_info_screen.dart defines its own `String _formatLastSeen(int secondsSinceEpoch)` (lines 1157-1165) that recomputes the same just-now / minutes / hours / days buckets that `formatLastSeen()` in core/utils/format.dart:63-71 already implements — while chat_screen.dart:3147 and contact_profile_screen.dart:102 both correctly call the shared version. The local copy has genuinely diverged: it uses raw millisecond thresholds (60000/3600000/...) instead of Duration fields, its cutoffs differ (shared uses `inMinutes < 2` = 120s for 'только что', local uses `< 60000` = 60s), and after 7 days it falls back to the literal string 'давно' whereas the shared function returns a full date ('5 мая 2024'). Result: the chat-info panel and member list show different last-seen wording/date behavior than the chat header and contact profile, and threshold tweaks must be made twice. + + +**Решение:** Delete the local `_formatLastSeen` and call the shared `formatLastSeen(secondsSinceEpoch)` from core/utils/format.dart. Note the shared function already returns a capitalized 'Был(-а) ...' prefix, so at line 818 use its return directly, and at line 307 remove the existing lowercase 'был(-а) ' prefix (currently `return 'был(-а) ${_formatLastSeen(_seenTime!)}';`) to avoid a doubled 'был(-а) Был(-а)'. This matches the call pattern in chat_screen.dart:3147 and contact_profile_screen.dart:102. + +
+ +
+🟠 MED · ДУБЛЬ · [M] — mm:ss duration formatting hand-rolled in video player and voice-message recorder instead of reusing formatSecondsMmSs/formatDurationMmSs + + +**Где:** `lib/frontend/widgets/video_player_screen.dart:101-111`, `lib/frontend/screens/chats/chat_screen.dart:5407-5413`, `lib/frontend/screens/chats/chat_screen.dart:5372`, `lib/frontend/screens/chats/chat_screen.dart:5535`, `lib/core/utils/format.dart:30-39` + + +**Проблема:** core/utils/format.dart already provides `formatDurationMmSs`/`formatSecondsMmSs`, correctly reused in message_bubble.dart (lines 1885, 2132, 3079). But video_player_screen.dart's static `_fmt(Duration d)` recomputes seconds/minutes with manual `~/`, `%`, and `padLeft(2,'0')` (adding an hour branch the shared helper lacks), and chat_screen.dart's `_formatVoiceElapsed(int ms)` (used at lines 5372 and 5535 for the recording timer) does the same manual mm:ss computation plus an extra decisecond digit. Both re-derive the core padding/division logic that already lives in the shared util rather than composing it. + + +**Решение:** Extend the shared util with an hour-aware variant (e.g. `formatDurationHms`) and a decisecond variant (e.g. `formatVoiceElapsed(int ms)`), composed from the existing `formatDurationMmSs`/`_two` helpers, and have video_player_screen.dart:101-111 and chat_screen.dart:5407-5413 call those instead of reimplementing the arithmetic. + +
+ +
+🟠 MED · ДУБЛЬ · [S] — Hand-rolled cancel-and-restart debounce timer duplicated across 5 files with no shared Debouncer utility + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:421`, `lib/frontend/screens/chats/chat_screen.dart:3847`, `lib/frontend/screens/chats/search_screen.dart:25`, `lib/frontend/screens/chats/search_screen.dart:74`, `lib/frontend/screens/profile/appearance_screen.dart:33`, `lib/frontend/screens/profile/appearance_screen.dart:58`, `lib/frontend/screens/chats/chat_list_screen.dart:799`, `lib/backend/modules/messages.dart:79` + + +**Проблема:** The same `Timer? _x; _x?.cancel(); _x = Timer(duration, callback);` debounce idiom is independently reimplemented in at least 5 places: chat_screen search debounce (field 421, timer 3847), search_screen search debounce (field 25, cancel 57, timer 74), appearance_screen accent-persist debounce (field 33, cancel 57, timer 58), chat_list_screen `_scheduleContactRebuild` (799-804), and messages.dart NameCache `_scheduleSave` (79-81). There is no Debouncer/Throttler helper anywhere in lib/core/utils/ (verified: the directory has 14 util files, none of them a debouncer), so every feature that needs debouncing re-derives the pattern and each site has to independently remember to cancel the pending timer in dispose (they currently do, but nothing enforces it). + + +**Решение:** Add a small `lib/core/utils/debouncer.dart` with a `Debouncer` class (`Debouncer(duration).run(callback)` + `dispose()` that cancels the pending timer). Replace the ad-hoc Timer fields in chat_screen.dart, search_screen.dart, appearance_screen.dart, chat_list_screen.dart, and messages.dart with it. Keep it comment-free per the codebase convention. + +
+ +
+🟡 LOW · КОСТЫЛЬ · [S] — Dead pluralization branches in info_screen week/day formatters + + +**Где:** `lib/frontend/screens/profile/info_screen.dart:283-295` + + +**Проблема:** `_w(int n)` and `_d(int n)` each have three branches based on Russian declension rules, but every branch returns the exact same literal ('нед' / 'дн'). The conditional logic is entirely inert dead code — an abandoned attempt at full-word declension that misleads maintainers into thinking the form varies. + + +**Решение:** If the abbreviation is intentionally invariant, delete the branching and return the constant. If real declension was intended, implement the three forms (неделя/недели/недель, день/дня/дней) and return them per branch. + +
+ +
+🟡 LOW · КОСТЫЛЬ · [S] — Shared 2-digit zero-pad helper is library-private, forcing every caller to re-pad by hand + + +**Где:** `lib/core/utils/format.dart:19`, `lib/frontend/widgets/schedule_time_picker.dart:17`, `lib/frontend/screens/profile/traffic_monitor_screen.dart:83`, `lib/frontend/screens/profile/traffic_monitor_screen.dart:479`, `lib/frontend/screens/profile/debug_menu_screen.dart:111`, `lib/frontend/screens/profile/info_screen.dart:279-280`, `lib/core/config/app_theme_schedule.dart:59-60` + + +**Проблема:** `String _two(int n) => n.toString().padLeft(2, '0')` in format.dart:19 is underscore-private, so it can't be imported. schedule_time_picker.dart imports format.dart (line 4) yet still redeclares its own top-level `_two` (line 17). traffic_monitor_screen.dart (lines 83, 479) and debug_menu_screen.dart (line 111) redeclare a local `two()` closure, while info_screen.dart:279-280 and app_theme_schedule.dart:59-60 inline `.padLeft(2,'0')` repeatedly — all because the one canonical padder isn't exposed. (Note: the original finding overstated this as all files redeclaring a `two` closure; two of them use inline padLeft rather than a closure, but the root cause is the same.) + + +**Решение:** Rename `_two` to a public `pad2` in core/utils/format.dart and update these call sites to import and use it, removing the local closures and inline padLeft. This also makes the format.dart-based fixes for the other duplication findings cheaper. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — Dynamic-to-int coercion reimplemented in four places with divergent edge cases + + +**Где:** `lib/core/calls/call_controller.dart:142-147`, `lib/core/calls/call_session.dart:290-305`, `lib/core/calls/call_session.dart:414-420`, `lib/core/nfc/nfc_exchange_service.dart:63-66` + + +**Проблема:** CallController._asInt (int/num/String), CallSession._participantIdFrom (adds u/g/d composite-id prefix stripping), CallSession._externalId (map['id'] then int/String), and the inline parse in NfcExchangeService._decodeEvent (int/num only, no String) each reimplement 'coerce a dynamic JSON/platform-channel value to int', with subtly different coverage — e.g. the NFC inline version and _externalId omit cases the others handle. Being private, none can reuse another, so any fix must be applied in up to four spots. + + +**Решение:** Extract a single int? intFrom(Object? value) utility in lib/core/utils/, plus a thin wrapper for the u123/g456 composite participant-id format, and have all four call sites delegate to them. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — UUID/hex id generation duplicated verbatim between DeviceIdentity and SpoofingService + + +**Где:** `lib/core/storage/device_identity.dart:9`, `lib/core/storage/device_identity.dart:33-48`, `lib/core/storage/spoofing_service.dart:35`, `lib/core/storage/spoofing_service.dart:244-259` + + +**Проблема:** `_hex(int bytes)` and `_uuidV4()`, plus a private `static final Random _rng = Random.secure()`, are implemented identically byte-for-byte in both DeviceIdentity and SpoofingService. + + +**Решение:** Extract both helpers and the shared Random.secure() into one `lib/core/utils/random_id.dart` exposing `randomHex(int bytes)` / `randomUuidV4()`, and have both classes call it. + +
+ +
+🟡 LOW · ДУБЛЬ · [M] — Contact display-name concatenation duplicated across search_screen and create_group_flow with inconsistent empty-name fallback + + +**Где:** `lib/frontend/screens/chats/search_screen.dart:127-132`, `lib/frontend/screens/chats/create_group_flow.dart:180-183` + + +**Проблема:** Both files independently build a 'first last'.trim() display name from raw contact fields: search_screen._contactName falls back to '+phone' when the combined name is empty, while create_group_flow._displayName has no fallback. The two operate on different shapes (a SQLite Map row vs. a CachedContact), so the concatenation-plus-fallback rule is reimplemented rather than shared. + + +**Решение:** Add a single helper such as displayName(String first, String? last, {String? fallback}) in core/utils and route both call sites (and, over time, other reimplementations in the codebase) through it so fallback behavior is consistent. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — Russian pluralization logic hand-rolled twice instead of using the l10n pipeline + + +**Где:** `lib/frontend/widgets/sticker_pack_sheet.dart:337`, `lib/frontend/widgets/poll_view.dart:373` + + +**Проблема:** _pluralStickers (sticker_pack_sheet.dart:337) and _votesLabel (poll_view.dart:373) each independently reimplement the Russian mod10/mod100 plural algorithm. Both implementations are actually correct — they only differ in how they order the branches — so there is no live bug, but the algorithm and the Russian words are duplicated and hardcoded in widget code instead of going through lib/l10n, which already exists and supports ICU plurals. Any future change must be made in two places. + + +**Решение:** Move these strings into app_ru.arb/app_en.arb using ICU plural syntax (supported by flutter gen-l10n), or at minimum extract one shared `String pluralizeRu(int n, {required String one, required String few, required String many})` utility used by both call sites. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — Identical _fileStamp helper copy-pasted across two screens + + +**Где:** `lib/frontend/screens/profile/debug_menu_screen.dart:110-114`, `lib/frontend/screens/profile/traffic_monitor_screen.dart:82-86` + + +**Проблема:** Both files define a byte-for-byte identical private String _fileStamp(DateTime t) that builds a yyyyMMdd_HHmmss filename suffix for exported logs/captures. Confirmed identical. + + +**Решение:** Move it to core/utils/format.dart (which exists) as a shared formatFileTimestamp(DateTime t) and delete both local copies. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — Smart relative-date formatting reimplemented independently in two screens + + +**Где:** `lib/frontend/screens/profile/settings_tab.dart:635-646`, `lib/frontend/screens/profile/devices_screen.dart:198-214` + + +**Проблема:** `_formatSelfSeen` (settings_tab) and `_formatTime` (devices_screen) both implement the same today/same-year/else branching over the shared formatClock/kRuMonthsShort/formatDateNumeric primitives, but the branching is copy-pasted rather than shared and the two have already diverged in output (one appends the clock time and builds the year via a manual ternary, the other calls formatDateNumeric and omits the time). + + +**Решение:** Add one shared helper to core/utils/format.dart, e.g. `String formatSmartDate(DateTime dt, {bool withTime = false})`, encapsulating the today/same-year/else logic, and have both screens call it. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — Duplicated hand-rolled debounce for VPN-bypass and server-error notifications + + +**Где:** `lib/main.dart:361-380`, `lib/main.dart:382-395` + + +**Проблема:** The _vpnBypassSub (361-380) and _serverErrorSub (382-395) listeners each independently implement the same 'skip if identical to the last shown message within N seconds' debounce against their own _lastX/_lastXAt pair, differing only in the stored fields and a magic-number window (10s vs 3s). + + +**Решение:** Extract a small reusable debouncer (e.g. a _MessageDebouncer holding lastMessage/lastShownAt with a `shouldShow(message, window)` method) and use one instance per notification source instead of duplicating the comparison. Keep using showCustomNotificationOnOverlay for display. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — Compact file-timestamp (yyyyMMdd_HHmmss) generator duplicated byte-for-byte in two screens + + +**Где:** `lib/frontend/screens/profile/debug_menu_screen.dart:110-114`, `lib/frontend/screens/profile/traffic_monitor_screen.dart:82-86` + + +**Проблема:** Both files define a verbatim-identical private `String _fileStamp(DateTime t)` with the same inner `two(int n) => n.toString().padLeft(2, '0')` closure, producing `${year}${MM}${dd}_${HH}${mm}${ss}` for export filenames. Same variable names, same structure — a straight copy. Any change to the export filename convention must be applied in both places. + + +**Решение:** Add a public `String formatFileStamp(DateTime t)` to lib/core/utils/format.dart (built on the module's existing `_two` padding helper) and have both screens call it instead of their local copies. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — "HH:mm:ss.mmm" clock-with-milliseconds formatting duplicated in logger and traffic monitor + + +**Где:** `lib/core/utils/logger.dart:116-117`, `lib/frontend/screens/profile/traffic_monitor_screen.dart:478-482` + + +**Проблема:** logger.dart builds `HH:mm:ss.mmm` inline via chained `.padLeft(2,'0')`/`.padLeft(3,'0')` calls, and traffic_monitor_screen.dart's `_formatTime` builds the same HH:mm:ss.mmm shape with its own local `two()` closure plus a manual millisecond pad. The shared `formatClock()` in format.dart supports an HH:mm / HH:mm:ss toggle but has no millisecond option, so both call sites drop back to raw padLeft arithmetic instead of using the shared formatter. + + +**Решение:** Extend `formatClock()` in format.dart with a `withMillis` flag (or add a small `formatClockMs(DateTime)` built on top of it and the existing `_two`), and switch both logger.dart:116-117 and traffic_monitor_screen.dart:478-482 to it. + +
+ +
+🟡 LOW · ОПТ · [S] — formatPhone compiles a fresh RegExp on every call + + +**Где:** `lib/core/utils/format.dart:80` + + +**Проблема:** `formatPhone` (called per contact/message-header/profile row that renders a phone) does `raw.replaceAll(RegExp(r'[^0-9]'), '')`, constructing and compiling a new RegExp each invocation. This is inconsistent with the codebase's own convention — max_link.dart hoists its patterns to `static final RegExp`. A contacts/chat list with hundreds of entries recompiles the same trivial pattern many times per rebuild. Minor but free to fix. + + +**Решение:** Hoist to a top-level `final RegExp _nonDigits = RegExp(r'[^0-9]');` and reuse it in formatPhone. + +
+ + +### Untyped Map wire data reaching the UI (11 — 2 high) + +_Protocol payloads flow into widgets as raw Map, so screens hand-parse the same shapes with divergent rules and no compile-time safety, violating the UI -> module -> transport layering._ + +
+🔴 HIGH · КОСТЫЛЬ · [M] — Typed attachment models bypassed with `as dynamic` casts + + +**Где:** `lib/frontend/widgets/message_bubble.dart:2063`, `lib/frontend/widgets/message_bubble.dart:2064`, `lib/frontend/widgets/message_bubble.dart:2071`, `lib/frontend/widgets/message_bubble.dart:2072`, `lib/frontend/widgets/message_bubble.dart:2187`, `lib/frontend/widgets/message_bubble.dart:2188`, `lib/frontend/widgets/message_bubble.dart:2220`, `lib/frontend/widgets/message_bubble.dart:2221`, `lib/frontend/widgets/message_bubble.dart:2223`, `lib/frontend/widgets/message_bubble.dart:2655` + + +**Проблема:** `_buildVideoAttachment`, `_playVideo`, `_buildFileAttachment` and `_downloadFile` take a generic `MessageAttachment` and reach into it via `(video as dynamic).thumbnail/.duration/.width/.height/.videoId/.videoToken` and `(file as dynamic).name/.size/.fileId`, even though `VideoAttachment` (models/attachment.dart:122) and `FileAttachment` (models/attachment.dart:236) already declare every one of these fields with proper static types. The concrete type is already known at the dispatch point, and `_buildVideoAttachment` even does `video is VideoAttachment` at line 2047 before falling back to `as dynamic`. The dynamic casts buy nothing except erasing compile-time safety: a future field rename becomes a runtime NoSuchMethodError instead of a compile error, and IDE refactoring can't track the usages. This directly violates the project's 'proper rewrite over hack' convention. + + +**Решение:** Change the signatures to the concrete types (`_buildVideoAttachment(_BubbleCtx ctx, VideoAttachment video)`, `_buildFileAttachment(_BubbleCtx ctx, FileAttachment file, {bool fill})`, and pass the typed instance into `_playVideo`/`_downloadFile`), narrowing once at the `_buildGenericAttachment` dispatch point with `if (attachment is VideoAttachment)` / `is FileAttachment`. Removes all ten `dynamic` casts and restores full static checking. + +
+ +
+🔴 HIGH · КОСТЫЛЬ · [M] — Contact display-name parsing is duplicated four times over an untyped Map, giving inconsistent results per screen + + +**Где:** `lib/frontend/screens/calls/call_screen.dart:149-166`, `lib/frontend/screens/contacts/contact_profile_screen.dart:66-83`, `lib/frontend/screens/contacts/nfc_exchange_sheet.dart:128-145`, `lib/frontend/screens/contacts/contacts_tab.dart:326-332` + + +**Проблема:** ContactInfo packets are exposed to the UI as raw Map, so four screens each re-implement display-name extraction from info['names'] with genuinely different priority rules: call_screen._contactName prefers the entry with type=='ONEME' and builds firstName+lastName; contact_profile_screen._displayName reads only names.first (name, then firstName/lastName); nfc_exchange_sheet._peerName loops all entries taking the first non-empty name; contacts_tab reads only names.first['name']. The same contact can therefore render a different name on different screens, and the UI directly parses the wire payload shape, violating the UI -> backend module -> transport layering. All four call sites confirmed. + + +**Решение:** Introduce a typed ContactInfo model parsed once in the backend contacts module, exposing a single canonical displayName getter, and have all four call sites consume contact.displayName instead of hand-rolling extraction from the raw Map. + +
+ +
+🟠 MED · КОСТЫЛЬ · [M] — Chat-folder filter matching relies on untyped dynamic values with duplicated magic-number/string comparisons + + +**Где:** `lib/backend/modules/folders.dart:61-89` + + +**Проблема:** ChatFolder.filters is List because the server sends filter values inconsistently as ints (8, 9, 0) or strings ('CONTACT', 'NOT_CONTACT', 'UNREAD'). chatMatchesFolder compensates with triple-OR comparisons (f == 9 || f == '9' || f == 'CONTACT') and does so twice — once to compute hasContact/hasNotContact (67-72), then again inside the main filter loop (79-87) — duplicating the same magic-number/string logic in two places that can drift apart. + + +**Решение:** Normalize filter values into a typed enum (e.g. enum ChatFolderFilter { unread, contact, notContact }) once in ChatFolder.fromJson, mapping both int and string server representations to the enum. chatMatchesFolder can then switch on enum values with no repeated raw-value comparisons. + +
+ +
+🟠 MED · КОСТЫЛЬ · [L] — ws2 signaling parsed as untyped Map with duplicated field parsing + + +**Где:** `lib/core/calls/call_session.dart:226-282`, `lib/core/calls/call_session.dart:380-412`, `lib/core/calls/call_session.dart:1174-1192`, `lib/core/calls/ws2_signaling.dart:126-173` + + +**Проблема:** Every ws2 notification arrives as a raw Map and is dispatched by a string switch on msg['notification'] (call_session.dart:232-281). Each handler re-implements ad-hoc dynamic casts on the same shapes. Concretely, the same mediaSettings/isAudioEnabled/isVideoEnabled parsing is duplicated: _upsertParticipant reads mediaSettings/muteStates at 394-409, while _applyConnectionInfo re-derives _peerMuted/_peerVideo from the same mediaSettings map inline at 1185-1189, and _applyPeerMedia does it a third time at 1215-1229. There is no single source of truth for the wire schema, so a server field rename or a participantId type change (already special-cased in _participantIdFrom) silently breaks a handler with no compile-time signal. + + +**Решение:** Introduce a small typed decode layer for the ws2 protocol under models/ or core/calls: named-field classes (or a sealed Ws2Notification hierarchy keyed off the notification string) built once via factory constructors from the decoded JSON, plus a single shared MediaSettings/MuteStates parser reused by _upsertParticipant, _applyConnectionInfo, and _applyPeerMedia. This removes the repeated dynamic-cast boilerplate and consolidates protocol changes into one file. + +
+ +
+🟠 MED · КОСТЫЛЬ · [L] — ChatInfoScreen fetches info via core/cache singletons returning untyped Maps, forcing the UI to hand-parse key-ambiguous protocol data + + +**Где:** `lib/frontend/screens/chats/chat_info_screen.dart:138`, `lib/frontend/screens/chats/chat_info_screen.dart:145-152`, `lib/frontend/screens/chats/chat_info_screen.dart:156-169`, `lib/frontend/screens/chats/chat_info_screen.dart:180-183`, `lib/frontend/screens/chats/chat_info_screen.dart:195-196`, `lib/core/cache/info_cache.dart:110-123`, `lib/core/cache/info_cache.dart:246-259` + + +**Проблема:** ChatInfoScreen._load() calls ChatInfoFetch.get / ContactInfoFetch.get / PresenceFetch.get(getMany) directly. These live in core/cache/info_cache.dart, call api.sendRequest(Opcode.xxx) themselves, and return raw Map straight off the wire, so the widget layer does protocol-response handling instead of the documented UI -> backend module -> api flow. Because the payload is untyped, participant/admin keys arrive as either int or String and the screen must defensively check both forms in several places: `k is int ? k : int.tryParse(k.toString())` (lines 147 and 181) and `admins.containsKey(id.toString()) || admins.containsKey(id)` (line 196). The same int/String key ambiguity is re-handled inside info_cache.dart itself (primeAll line 163, _fetchBatch line 223). Any opcode/shape change means editing every ad hoc call site rather than one parsing boundary. + + +**Решение:** Introduce typed models (ChatInfo, ContactInfo, PresenceInfo) produced once at the fetch boundary, normalizing participant/admin keys to int during that single parse. Have the fetch layer (ideally surfaced through backend/modules/chats.dart / contacts.dart) return the typed model instead of Map, and have ChatInfoScreen consume the model so the defensive key-coercion disappears from the UI. + +
+ +
+🟠 MED · КОСТЫЛЬ · [L] — Contact display-name extraction re-implemented on raw dynamic Maps in two places + + +**Где:** `lib/frontend/widgets/max_link_handler.dart:145-157`, `lib/frontend/commands/info_command.dart:42-51` + + +**Проблема:** `_contactName` (max_link_handler.dart:145-157) and `_nick` (info_command.dart:42-51) are near-verbatim copies of the same logic: pull `names` (a List of Maps) off a raw payload, take `names.first['name']`, else join `firstName`+`lastName`. Both operate on untyped `Map`/`Map` handed straight to the UI/command layer by `LinkModule.resolve` and `ContactInfoFetch.get` (e.g. `ResolvedUser.contact` is a raw Map; `_openResolvedChat` also digs `chat['participants']`, `chat['access']`, etc. out of a raw Map). This ad hoc JSON parsing in widgets and slash commands is exactly the fragile, no-compile-time-safety pattern the layered architecture (models/ = typed data classes) is meant to avoid: a server field rename fails silently deep in UI code instead of at the backend-module boundary. Confirmed both helpers exist and match. + + +**Решение:** Minimum: extract one shared `displayNameFromNames(List names)` helper so the parsing lives in one place. Proper fix (aligned with the layered architecture): give `LinkModule.resolve`/`ContactInfoFetch` typed return models (e.g. `ResolvedContact`/`ContactInfo` in models/ with a `displayName` getter) so the UI never touches raw Maps, and delete both dynamic-parsing helpers. + +
+ +
+🟠 MED · ДУБЛЬ · [M] — Three hand-rolled 'prefer ONEME name' implementations with inconsistent no-ONEME fallback + + +**Где:** `lib/backend/modules/account.dart:197-221`, `lib/backend/modules/contacts.dart:171-195`, `lib/backend/modules/contacts.dart:217-237` + + +**Проблема:** BlockedContact.fromMap loops over `names`, overwriting firstName/lastName each iteration and only `break`ing on type=='ONEME', so when no ONEME entry exists it keeps the LAST map entry. ContactsModule._primeContactCache and _parseContact instead use `firstWhere(type=='ONEME', orElse: firstWhere(is Map))`, which falls back to the FIRST entry. Same concept, three copies, two mutually inconsistent fallbacks — an edge-case divergence where the same contact could resolve to a different name depending on which code path handled it. + + +**Решение:** Extract one shared helper (e.g. returning a firstName/lastName record) taking `List? names` with a single documented fallback rule (first Map entry, matching the two contacts.dart call sites), and use it from BlockedContact.fromMap, _primeContactCache and _parseContact so all paths agree. + +
+ +
+🟠 MED · СОМНИТ · [M] — Cache layer bypasses backend/modules and calls Api/Opcode directly; UI calls it straight from widgets + + +**Где:** `lib/core/cache/info_cache.dart:8-12`, `lib/core/cache/info_cache.dart:110-123`, `lib/core/cache/info_cache.dart:209-229`, `lib/core/cache/info_cache.dart:246-259`, `lib/main.dart:122`, `lib/frontend/screens/contacts/contact_profile_screen.dart:46-47`, `lib/frontend/screens/chats/chat_info_screen.dart:138`, `lib/frontend/screens/calls/call_screen.dart:134`, `lib/frontend/commands/info_command.dart:20` + + +**Проблема:** core/cache/info_cache.dart holds a module-level mutable `Api? _api` (wired once via attachInfoCacheApi from main.dart:122) and ContactInfoFetch/ChatInfoFetch/PresenceFetch call `api.sendRequest(Opcode.contactInfo/chatInfo/contactPresence, ...)` directly, skipping backend/modules entirely. UI screens (contact_profile_screen, chat_info_screen, call_screen, info_command, nfc_exchange_sheet) call these caches straight from widget code, so a widget triggers a protocol-level round trip with no backend module in between. This breaks the documented UI -> backend module -> api.dart -> transport layering, couples UI-adjacent and cache code to Opcode wire details, and makes the cache untestable without a live/mocked Api. Confirmed real: the modules already reference these caches (chats.dart calls ContactInfoFetch.clear/PresenceFetch.apply/ChatInfoFetch.get) and send the same Opcodes elsewhere (contacts.dart, chats.dart:1303/1705, calls.dart:214, messages.dart), so the wire logic is duplicated across layers. + + +**Решение:** Keep core/cache/info_cache.dart as the pure generic `InfoCache` primitive (it already takes a fetcher callback). Move the Opcode round-trip logic into the owning backend modules (contacts.dart / chats.dart already send these Opcodes), expose e.g. `ContactsModule.contactInfo(id)` / `presence(ids)` that internally use a cache, and have UI call the module. Delete attachInfoCacheApi/_api so the cache no longer imports Api/Opcode, restoring the intended layering. + +
+ +
+🟠 MED · СОМНИТ · [M] — UI screen subscribes to and decodes raw protocol packets directly + + +**Где:** `lib/frontend/screens/chats/chat_list_screen.dart:34-35`, `lib/frontend/screens/chats/chat_list_screen.dart:546-548`, `lib/frontend/screens/chats/chat_list_screen.dart:553-565` + + +**Проблема:** Confirmed. `_ChatListScreenState` imports `core/protocol/opcode_map.dart` and `core/protocol/packet.dart` (34-35), subscribes to `api.pushStream.where((p) => p.opcode == Opcode.notifTyping)` (546-548), and manually pulls `chatId`/`userId`/`type` out of the raw decoded `Map` payload in `_onTypingPush` (553-565). CLAUDE.md's layered architecture states packet/opcode decoding belongs in a backend module; every other typed event the screen consumes (`ChatsModule.messageEvents`, `ChatsModule.chatsChanged`) already goes through a module abstraction. (Downgraded from high: single localized handler, not a systemic break.) + + +**Решение:** Move typing-packet decoding into a backend module (e.g. a `ChatsModule.typingEvents` stream next to `messageEvents`) that listens to `api.pushStream`, validates the payload, and emits a typed event; the screen consumes only that typed stream and drops the two `core/protocol` imports. + +
+ +
+🟡 LOW · КОСТЫЛЬ · [S] — info_screen guesses timestamp fields by integer magnitude and threads a raw dynamic JSON map through the UI + + +**Где:** `lib/frontend/screens/profile/info_screen.dart:254-274`, `lib/frontend/screens/profile/info_screen.dart:82-116` + + +**Проблема:** `_info` is a raw `Map?` from jsonDecode, navigated with string-literal keys across two ad-hoc key/label tables plus manual `as Map?` casts. `_formatValue` guesses field semantics at runtime: `if (value is int && value > 1000000000000) return _formatTs(value)` renders any sufficiently large integer as a millisecond date, with a separate special-case for `key == 'edit-timeout'`. A legitimately large non-timestamp integer field would be silently mis-rendered as a date. Note this is a diagnostic Info screen gated behind extra-info mode, so impact is limited to that debug view. + + +**Решение:** Since this is a diagnostic screen, a full typed model is likely overkill; the minimal fix is to make _formatValue key-driven — match against an explicit allowlist of known timestamp/duration keys instead of guessing from the integer's magnitude. If the screen grows, introduce a small typed model in lib/models/ parsed once in _loadData(). + +
+ +
+🟡 LOW · СОМНИТ · [M] — Debug screen issues raw protocol requests directly from UI, bypassing backend modules + + +**Где:** `lib/frontend/screens/profile/debug_menu_screen.dart:194-223`, `lib/frontend/screens/profile/debug_menu_screen.dart:1597-1624` + + +**Проблема:** DebugMenuScreen._search() and _SyncProbeCardState._send() call api.sendRequest(Opcode.contactInfo/chatInfo/sync, {...}) and catch PacketError directly, with the screen importing core/protocol/opcode_map.dart and core/protocol/packet.dart. This breaks the documented UI -> backend module -> api -> transport layering, and line 222 already shows the intended pattern (ChatsModule.searchById). Severity is low because this is a developer-only diagnostic probe screen whose purpose is raw protocol inspection, so the layering cost is limited. + + +**Решение:** If kept, wrap these in a thin backend/modules/debug_probes.dart exposing typed methods (lookupContactInfo(id), lookupChatInfo(id), syncContact(phone, name)) that own the sendRequest/PacketError handling, so the screen stops importing Opcode/PacketError directly. + +
+ + +### Security and privacy defects (7 — 2 high) + +_Secrets and identity checks rely on plaintext storage, drifted redaction, spoofable client-side heuristics, and plaintext third-party requests._ + +
+🔴 HIGH · КОСТЫЛЬ · [S] — Proxy username/password stored in plaintext SharedPreferences instead of the project's own secure storage + + +**Где:** `lib/core/config/proxy_config.dart:36-67` + + +**Проблема:** `ProxyConfig.save`/`load` persist `ProxySettings.username`/`password` (SOCKS5/HTTP-CONNECT proxy credentials) via plain `SharedPreferences.setString`/`getString` (proxy_config.dart:57-64, 41-42). The codebase already has `TokenStorage` (lib/core/storage/token_storage.dart) built on `FlutterSecureStorage` with `encryptedSharedPreferences: true` and exposing `writeSecure`/`readSecure`/`deleteSecure` specifically to avoid storing secrets in plaintext prefs, and uses it for auth tokens. Proxy credentials get no such protection, so on a rooted/compromised device or a shared-prefs backup they are readable in the clear. + + +**Решение:** Route `username`/`password` through `TokenStorage.writeSecure`/`readSecure`/`deleteSecure` (already used elsewhere for exactly this purpose) and keep only `type`/`host`/`port` in plain SharedPreferences. Mirror the same in `ProxyConfig.clear` so `deleteSecure` runs for the credential keys. + +
+ +
+🔴 HIGH · ДУБЛЬ · [S] — Debug session log uses a weaker private redactor than the app's shared allowlist, leaking device IDs (and likely OTP/QR/codes) into the shared export + + +**Где:** `lib/core/utils/debug_session_log.dart:333-365`, `lib/core/utils/log_redact.dart:5-51`, `lib/backend/api.dart:305-318` + + +**Проблема:** DebugSessionLog has its own ad-hoc redactor (`_isTokenKey`, `_isPhoneKey`, `_maskPhone`, `_redact`, lines 333-365) that only masks keys containing 'token' and 'phone'/'msisdn'. Every request/response payload sent through `Api.sendRequest` is recorded via this path (api.dart:318 recordRequest, 328 recordResponse) and persisted to `debug_sessions/*.json`, then assembled by `buildExport()` into a text file the user shares with support. The codebase already has a canonical, broader allowlist in log_redact.dart (`redactForLog`, covering 'auth','secret','code','otp','pin','qrlink','deviceid','mt_instanceid','instanceid','webappdata', etc.) — the same one traffic_monitor.dart:145 relies on precisely because 'файлом можно делиться'. None of those extra keys are masked by debug_session_log's private copy. Concretely verified: the sessionInit payload (api.dart:305-310) carries `mt_instanceid` and `deviceId` in cleartext, and any flow sending verification codes/QR-login links would also pass through unmasked. The class doc comment (lines 91-92) explicitly claims secrets never hit disk, which is false. Two redaction implementations that must stay in sync but have already drifted. + + +**Решение:** Delete the private `_isTokenKey`/`_isPhoneKey`/`_maskPhone`/`_redact` and route recordRequest/recordResponse through the shared `redactForLog` from log_redact.dart (adding the first-3-chars phone behavior to that shared helper if it must be preserved), so the live logger, traffic monitor, and persistent debug log are all governed by one allowlist. Also fix the now-inaccurate doc comment. + +
+ +
+🟠 MED · КОСТЫЛЬ · [S] — print() debug statements left in the live notification reply / call-decline path + + +**Где:** `lib/core/push/push_service.dart:296-298`, `lib/core/push/push_service.dart:332-334`, `lib/core/push/push_service.dart:354`, `lib/core/push/push_service.dart:369`, `lib/core/push/push_service.dart:378-401` + + +**Проблема:** `_onNotificationResponse`, `_handleCallDecline` and `_handleReply` — live code invoked from the native reply/decline actions — contain multiple `print('REPLYDBG ...')` / `print('PUSHDBG ...')` calls that emit action inputs, account/chat IDs and internal state. Unlike the file's own `logger` (used via `logger.w`/`logger.i`, which is level-filtered and release-gated), `print()` is NOT stripped in Flutter release builds, so this diagnostic output runs unconditionally in production and writes to stdout/logcat. (The original finding's 'readable by any app with logcat access' claim is overstated — READ_LOGS has been a system-only permission since Android 4.1 — but unfiltered debug output shipping in release, reachable via adb/same-uid, plus the inconsistency with the project's logger convention, is a real hygiene defect.) + + +**Решение:** Remove these debug prints (the feature is stable), or replace them with `logger.d(...)`/`logger.w(...)` consistent with the rest of push_service.dart so they are level-filtered and release-gated. Deleting the dead-code prints (line 69) is subsumed by the dead-code cleanup finding. + +
+ +
+🟠 MED · КОСТЫЛЬ · [S] — ESIA/Gosuslugi callback redirect can build a malformed double-'?' URL + + +**Где:** `lib/frontend/screens/digital_id/digital_id_web_screen.dart:318-331` + + +**Проблема:** shouldOverrideUrlLoading rebuilds the redirect target as '$base?$query$frag' (line 326), where base is _launch.url truncated at the first '#'. If the launch URL already contains a '?' before the '#' (e.g. https://.../start?flow=x#/hash), the rebuilt target becomes '.../start?flow=x?#/hash' with two '?' segments, breaking the callback forward. The default fallback 'https://digital-id.max.ru' has no query, so this only triggers when the server-supplied _launch.url carries a query string, hence medium rather than high. + + +**Решение:** Build the target with Uri.parse(base).replace(queryParameters: {...existing, ...callbackParams}) so an existing query string is correctly merged with the callback's query instead of concatenated. + +
+ +
+🟠 MED · КОСТЫЛЬ · [M] — Regex-based account-ownership check in the injected JS bridge can leak one account's cached WebView session into another + + +**Где:** `lib/frontend/screens/digital_id/digital_id_web_screen.dart:50-76` + + +**Проблема:** The injected userId() JS function scrapes location.hash with two regexes, swallows all failures via bare try/catch, and falls back to the literal 'anon'. komet_did_owner in localStorage is compared only against this guessed id to decide whether to wipe localStorage/sessionStorage/indexedDB. If the hash shape ever changes so the regexes stop matching, every account resolves to 'anon', the storage wipe never fires on account switch, and account B can read account A's cached Digital ID session data including the locally generated biometric token (bioToken at line 82-93). Confirmed. Trigger is conditional on an upstream URL-shape change, hence medium. + + +**Решение:** Do not infer the owning account from scraped URL text inside injected JS. Pass the authoritative accountId (known in Dart at the webAppModule.fetchDigitalId() call site) into the page via an initial user script argument, and key/clear storage off that value. + +
+ +
+🟠 MED · СОМНИТ · [L] — Cloud-storage env-group identity relies on a spoofable client-side checksum of the chat title + + +**Где:** `lib/backend/modules/cloud_storage.dart:36-46`, `lib/backend/modules/cloud_storage.dart:48-56`, `lib/backend/modules/cloud_storage.dart:65-66` + + +**Проблема:** _computeSpecialNumber derives a 'signature' from the group id with a trivial transform (double it if <4 digits, else sum of first-4 and last-4 digits), and isCloudStorageGroup/findOrphanGroups trust any chat whose title is 'CLST' (or the literal 'Облачное хранилище') as the user's private cloud-storage container. Because the transform is deterministic and derived solely from the publicly-visible chat id, any peer who can invite the victim into an attacker-controlled group with the matching title can make this client treat that group as the user's private storage — where the client then reads/writes private files. + + +**Решение:** Prefer the locally-cached, previously-verified id (getCachedEnvGroupId) as the authoritative identifier, and when the cache is empty fall back to setupEnv to (re)create the group rather than trusting a forgeable title. Note the title-scan currently exists to re-discover the group across devices with no server-authoritative marker; the real fix is a server-side/owner-verified marker for the env group — do not simply delete the scan without providing an equivalent cross-device bootstrap, or duplicate groups will proliferate. + +
+ +
+🟠 MED · СОМНИТ · [M] — IP geolocation lookup done via raw plaintext HTTP directly from the UI widget + + +**Где:** `lib/frontend/screens/profile/devices_screen.dart:152-196` + + +**Проблема:** _lookupIp builds an HttpClient inline in the State and GETs http://ip-api.com/json/$ip?... over plaintext, decoding into an untyped Map stored in _ipDetails. This bypasses the app's transport/backend layering, sends the user's own session/login IP addresses to a third party unencrypted (visible to any on-path observer), and stores an ad-hoc dynamic map instead of a typed model. Confirmed. Severity medium (not high): the data is the user's own already-known session IPs and ip-api's free tier is HTTP-only, so it is a genuine privacy/layering smell rather than a critical leak. + + +**Решение:** Move to a backend module (e.g. backend/modules/geo_lookup.dart) that centralizes the HttpClient lifecycle/timeout/error handling and returns a typed IpGeoInfo model; prefer an HTTPS-capable geolocation endpoint so session IPs are not sent in cleartext. + +
+ + +### Silent failures and swallowed errors (16 — 2 high) + +_Bare catch blocks discard exceptions on connect, upload, outbox, poll, and platform-channel paths with no logging, making production failures undiagnosable and sometimes reporting failure as success._ + +
+🔴 HIGH · КОСТЫЛЬ · [S] — OutboxService.flush aborts the whole pending loop on the first per-message failure and swallows all errors unlogged + + +**Где:** `lib/backend/modules/outbox.dart:41-80` + + +**Проблема:** The per-row catch (_) { break; } (73-75) stops processing ALL remaining pending rows across every chat as soon as one sendMessage throws for any reason (bad text, server validation), and the outer catch (_) {} (77-78) discards the exception with no logging (this file has no logger usage, unlike messages.dart). One message-specific failure silently blocks delivery of every other unrelated pending message until the next online transition, with zero diagnostics. The connection-loss case is already handled separately by the api.state check at line 42. + + +**Решение:** Catch per-row, log via logger.e/logger.w (as messages.dart does), and continue to the next pending row instead of break; reserve aborting the loop for connection-level failures only. + +
+ +
+🔴 HIGH · КОСТЫЛЬ · [M] — switchAccount swallows connect() failures and returns success anyway + + +**Где:** `lib/backend/modules/account.dart:1112-1140` + + +**Проблема:** switchAccount tears down the old session, sets the new active account, clears caches, then does `try { await _api.connect(); } catch (_) {}` (lines 1134-1136) and unconditionally returns the loaded `profile`. If connect() throws (network down, handshake/spoof failure) the caller receives a ProfileData and believes the switch succeeded, while the app is left disconnected with no error surfaced. Note this is specific to switchAccount: loginWithToken (1093-1109) correctly guards with a post-connect `if (_api.state != SessionState.online) throw` and beginAddAccount never connects, so those disconnect() swallows are legitimate best-effort teardown. + + +**Решение:** Do not swallow the connect() failure in switchAccount. After `await _api.connect()`, verify `_api.state == SessionState.online` (mirroring loginWithToken) and rethrow / return a typed failure otherwise, so the UI layer can call showCustomNotification(context, ...) and let the user retry instead of silently pretending the account switch worked. Leave the disconnect() swallows as-is. + +
+ +
+🟠 MED · КОСТЫЛЬ · [M] — Fixed 300ms delay papers over a race before authQrApprove + + +**Где:** `lib/backend/modules/account.dart:1058-1072` + + +**Проблема:** authorizeWebQrLogin sends ping, then sessionsInfo (both awaited), then blindly `await Future.delayed(const Duration(milliseconds: 300))` before sending authQrApprove. It is a magic constant: potentially too short on a slow connection (approve races/fails) and needlessly slow on a fast one, with no indication of what condition is actually being awaited. + + +**Решение:** Drop the bare literal in favor of the awaited signal that actually gates approval — rely on the sessionsInfo response completing, or await the specific push/response that indicates the session is ready before sending authQrApprove. If a server-side settle really is required, drive it from a named, documented constant rather than an inline 300ms sleep. + +
+ +
+🟠 MED · КОСТЫЛЬ · [S] — resolveContacts silently swallows all errors, mislabeling failed lookups as group calls + + +**Где:** `lib/backend/modules/calls.dart:213-227` + + +**Проблема:** resolveContacts wraps the whole request in try { ... } catch (_) {} (calls.dart:213/227), discarding any exception (timeout, disconnect, malformed payload) with no log, returning whatever partial map was built. In parseHistoryPayload, an empty fetched map for a peerId falls through to name = 'Групповой звонок' with isGroup = true (calls.dart:324-326), so a 1:1 call whose name-lookup merely failed gets rendered as a group call. + + +**Решение:** Log the caught exception via the existing logger before returning, and let genuine failures set a distinct 'unresolved' state (or propagate) so fetchHistory can retry or surface a different UI state instead of a wrong group-call label. + +
+ +
+🟠 MED · КОСТЫЛЬ · [M] — 1-second 'no response' timeout is silently collapsed into the upload-success code path + + +**Где:** `lib/backend/modules/file_uploader.dart:56`, `lib/backend/modules/file_uploader.dart:685`, `lib/backend/modules/file_uploader.dart:120` + + +**Проблема:** _readResponse races Timer(autoForceAfter /* default 1s */, () => finish(0)) against the real socket read (line 685). If the CDN has not answered within 1s of the body being flushed, the code fabricates status 0, and the caller's guard `if (statusCode != 200 && statusCode != 0)` (line 120) treats 0 identically to a real 200, proceeding to messages.sendFileMessage as if the upload were confirmed. This conflates 'we do not know yet' with 'it worked': a slow-but-failing upload, or one whose ack loses the race, is reported to the server/UI as successful with no real confirmation. + + +**Решение:** Model three outcomes (confirmed success, confirmed failure, unknown/ack-pending) instead of folding timeout into the success code. Either await the real response with a generous timeout and treat a timeout as a genuine UploadError, or, if the CDN is known to sometimes not ack, surface the 'unknown' state explicitly so sendFileMessage can be retried/verified rather than assumed. If the 1s no-ack behavior is intentional CDN protocol, document why via self-explanatory code (a named outcome enum), not a magic status 0. + +
+ +
+🟠 MED · КОСТЫЛЬ · [S] — Server error responses detected by raw substring search instead of parsing JSON + + +**Где:** `lib/backend/modules/file_uploader.dart:211-213` + + +**Проблема:** uploadMediaFile decides success/failure with `respBody.contains('error_msg') || respBody.contains('error_code')` — a raw string search over the whole response body rather than parsing JSON. It will misfire on any success payload that happens to contain those substrings (e.g. in an echoed field) and gives no access to the actual error reason for logging or UI. + + +**Решение:** Parse the response with jsonDecode and test well-defined fields (e.g. response['error_code'] != null), surfacing the real error message where available, instead of substring containment. (The nearby _parsePhotoToken already does typed jsonDecode traversal and is acceptable as-is.) + +
+ +
+🟠 MED · КОСТЫЛЬ · [S] — Chat/folder load failures and account-switch failures are silently swallowed + + +**Где:** `lib/frontend/screens/chats/chat_list_screen.dart:722-731`, `lib/frontend/screens/chats/chat_list_screen.dart:2598-2600` + + +**Проблема:** Confirmed. `_reloadChatsAndFolders`'s outer `catch (_)` (722) swallows every exception from `ChatsModule.getChats`/`FoldersModule.loadFolders` and just resets folders to empty with `_isInitialLoading = false` — no `showCustomNotification`, no logging — leaving the user with a silently empty chat list. `try { await accountModule.beginAddAccount(); } catch (_) {}` (2598-2600) discards any add-account failure and proceeds to the login screen regardless. The codebase already uses a `logger` elsewhere (messages.dart:1846,1907). + + +**Решение:** Log the caught exception via the existing `logger`, and surface `showCustomNotification(context, ...)` when the failure is not simply 'not connected yet', instead of a bare `catch (_)`. + +
+ +
+🟠 MED · КОСТЫЛЬ · [S] — Silent catch-and-discard blocks with no logging across sticker/photo-editor code + + +**Где:** `lib/frontend/widgets/sticker_lottie.dart:172`, `lib/frontend/widgets/attachment/photo_editor.dart:96`, `lib/frontend/widgets/attachment/photo_editor.dart:394`, `lib/frontend/widgets/attachment/photo_editor.dart:1021`, `lib/frontend/widgets/attachment/photo_editor.dart:1973`, `lib/frontend/widgets/attachment/photo_editor.dart:2341` + + +**Проблема:** Six `catch (_)` blocks discard the exception entirely with no logging. sticker_lottie.dart:172 returns null (blank sticker); photo_editor.dart:96 and :1973 pop the editor with no explanation on image-decode failure; :394/:1021/:2341 return null from _bake. A `logger` already exists (core/utils/logger.dart) and is used in backend/modules/stickers.dart, so a corrupt codec, malformed Lottie, or JPEG-encode failure is invisible in production diagnostics even though the same project logs elsewhere. + + +**Решение:** Replace each bare `catch (_)` with `catch (e, st) { logger.e('...', error: e, stackTrace: st); }` (matching backend/modules/stickers.dart) before the existing UI fallback, so failures stay silent to the user but visible in logs. + +
+ +
+🟠 MED · КОСТЫЛЬ · [M] — Fixed-delay animation-completion hack plus silently-dropped fire-and-forget cleanup calls + + +**Где:** `lib/frontend/screens/profile/cloud_storage_screen.dart:193-208`, `lib/frontend/screens/profile/cloud_storage_screen.dart:143-150`, `lib/frontend/screens/profile/cloud_storage_screen.dart:162-181` + + +**Проблема:** _prependFile uses Future.delayed(800ms, () { if (mounted) setState(_animateNewCard=false); }) to guess when the entry animation has finished instead of listening to the animation. Separately, _deleteOrLeave and _handleOrphansBackground are declared void async (they await ChatsModule.deleteChat/leaveChat internally) and are invoked without await at lines 144, 149 and 165; any thrown error becomes an unhandled async error — a failed orphan delete/leave is silently dropped with no retry, feedback, or logging, so orphan cloud-storage groups can persist. Confirmed. + + +**Решение:** Drive _animateNewCard off the entry animation's AnimationStatusListener (status == completed) rather than a fixed timer. Change _deleteOrLeave/_handleOrphansBackground to return Future and either await them or explicitly unawaited(...) with a .catchError that logs via the existing logger, so failures are observable. + +
+ +
+🟠 MED · СОМНИТ · [S] — WebView session-reset helpers live in a screen file, are imported by four unrelated screens, and swallow all failures + + +**Где:** `lib/frontend/screens/digital_id/digital_id_web_screen.dart:14-26` + + +**Проблема:** resetDigitalIdWebData()/resetDigitalIdSession() reach directly into CookieManager/WebStorageManager and digitalIdModule.reset() — backend/service logic, not screen UI — yet they are defined in digital_id_web_screen.dart and imported by settings_tab.dart:26/213, debug_menu_screen.dart:35/913, chat_list_screen.dart:29/2597/2610, and login_screen.dart:17/66, none of which otherwise depend on this screen. Both functions also swallow every failure with bare catch (_) {} (lines 18, 25), so a failed cookie/storage wipe during logout or account switch is invisible and undiagnosable. + + +**Решение:** Move this logic into backend/modules/digital_id.dart (alongside digitalIdModule) or a small core/storage service, matching the UI -> backend module layering, and log the caught exceptions instead of discarding them. + +
+ +
+🟡 LOW · КОСТЫЛЬ · [S] — CallBridge swallows every platform-channel exception with no diagnostics + + +**Где:** `lib/core/calls/call_bridge.dart:34-37`, `lib/core/calls/call_bridge.dart:67-70`, `lib/core/calls/call_bridge.dart:73-76`, `lib/core/calls/call_bridge.dart:80-84`, `lib/core/calls/call_bridge.dart:87-93`, `lib/core/calls/call_bridge.dart:96-100` + + +**Проблема:** Every native call into the ru.komet.app/calls MethodChannel (consumeInitialCall, notifyAccepted, notifyEnded, cancelIncoming, canUseFullScreenIntent, openFullScreenIntentSettings) is wrapped in try { ... } catch (_) {}, discarding the exception without even logging it. A failure in notifyEnded/cancelIncoming (native service crash, channel not registered) leaves the incoming-call system notification undismissed with zero log trail, unlike the media bridges (e.g. OpusOggEncoder.ensureAvailable at opus_ogg_encoder.dart:46) which log via logger.w. + + +**Решение:** Replace the bare catch (_) {} blocks with catch (e) { logger.w('CallBridge.: $e'); } (add the core/utils/logger import), matching the logger.w pattern already used elsewhere, so platform-channel failures are observable. + +
+ +
+🟡 LOW · КОСТЫЛЬ · [S] — Migration/parse failures swallowed silently, inconsistent with logging used elsewhere in the same files + + +**Где:** `lib/core/storage/app_database.dart:183`, `lib/core/storage/chat_wallpaper_store.dart:114`, `lib/core/storage/chat_wallpaper_store.dart:186-189`, `lib/core/storage/draft_store.dart:32`, `lib/core/storage/spoofing_service.dart:187` + + +**Проблема:** Several `catch (_) {}` blocks discard the exception entirely: legacy DB migration (_migrateLegacyDb), wallpaper/draft JSON hydration in load(), wallpaper file deletion, and spoof-profile JSON decoding. AppDatabase logs other failures via logger.e (e.g. saveChats at :538), so a corrupted chat_drafts/chat_wallpapers blob or failed legacy-db copy fails completely silently with nothing to diagnose a user report of lost drafts/wallpapers. + + +**Решение:** Replace each bare `catch (_) {}` with `catch (e) { logger.w('...: $e'); }` (a logger is already imported/available in app_database.dart; add the import where needed), matching the existing logging pattern so failures stay observable while still degrading gracefully. + +
+ +
+🟡 LOW · КОСТЫЛЬ · [M] — Future.delayed(700ms) papers over a post-call server-sync race + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:3093` + + +**Проблема:** _refreshAfterCall does `await Future.delayed(const Duration(milliseconds: 700))` before re-fetching chat history, evidently to let the server persist the call-ended system message first. This is a wall-clock guess, not a guarantee — under load or a slow connection the fetch can still race ahead of the server write and miss the call message until the next unrelated refresh. + + +**Решение:** Replace the fixed delay with an authoritative trigger: refetch when the server's own call-ended / history-updated event arrives (via the existing ChatsModule event / push stream) rather than guessing a delay. + +
+ +
+🟡 LOW · КОСТЫЛЬ · [S] — Broad catch blocks discard the exception with no diagnostics + + +**Где:** `lib/frontend/widgets/message_bubble.dart:2917`, `lib/frontend/widgets/message_bubble.dart:3213`, `lib/frontend/widgets/message_bubble.dart:3383` + + +**Проблема:** `_VoiceMessageBubbleState._togglePlay` (catch e -> generic 'Ошибка воспроизведения'), `_requestTranscription` (catch e -> 'ошибка транскрибации'), and `_VideoNoteBubbleState._toggle` (catch _ -> sets error flag) all discard the caught exception without logging it. Since release builds use `--obfuscate` per the project's build commands, throwing away the exception at the only place it is caught makes real playback/transcription failures undiagnosable in production. Note: the codebase already has `core/utils/logger.dart`. (The `catch (_) {}` at message_actions_overlay.dart:490 was excluded — it wraps `_animController.reverse()` on close and is a genuinely benign disposed-controller case.) + + +**Решение:** Route the caught exception through the existing logger (guarded by kDebugMode where appropriate) before showing the user-facing notification, so failures are traceable in obfuscated builds. + +
+ +
+🟡 LOW · КОСТЫЛЬ · [S] — Reconnect-login failures are swallowed by an empty catch block + + +**Где:** `lib/main.dart:297-307` + + +**Проблема:** api.setReconnectCallback wraps the entire auto-relogin flow (getActiveAccountId, readToken, accountModule.login) in `try { ... } catch (_) {}`. Any failure — corrupted token storage, network error, unexpected exception from login — is discarded with no logging and no user feedback, so if auto-reconnect silently stops there is nothing to diagnose it. Verified the empty catch at lines 306. + + +**Решение:** Do not swallow silently: at minimum log the caught exception (the app has DebugSessionLog wired at main.dart:150, though its API is request/response/error-oriented — `recordError(seq, error)` — so a plain debugPrint or a dedicated diagnostic log line may fit better here). Preferably distinguish expected cases (no active account / missing token -> just return) from unexpected exceptions, and log or surface the latter rather than catching everything. + +
+ +
+🟡 LOW · СОМНИТ · [S] — PollsModule swallows all exceptions with no logging, unlike the rest of the backend layer + + +**Где:** `lib/backend/modules/polls.dart:55`, `lib/backend/modules/polls.dart:87` + + +**Проблема:** fetch() and vote() use bare catch (_) {} / catch (_) { return false; } with no logging, whereas messages.dart consistently logs failures via logger.e/logger.w. A malformed poll payload (e.g. a bug in Poll.fromServerMap) or a genuine network error becomes invisible in production. + + +**Решение:** Log the caught exception via the project logger before returning, so poll fetch/vote failures are diagnosable. + +
+ + +### Broken or dead UI affordances (9 — 2 high) + +_Several controls render as working but do nothing, and one feature is structurally impossible to succeed._ + +
+🔴 HIGH · КОСТЫЛЬ · [M] — CustomFontService requests a modern-browser UA but only accepts a legacy TTF response, so adding any Google font silently fails + + +**Где:** `lib/core/config/custom_font_service.dart:11`, `lib/core/config/custom_font_service.dart:75-91`, `lib/core/config/custom_font_service.dart:102-125` + + +**Проблема:** `_fetchText`/`_fetchBytes` always send the hardcoded UA `'Mozilla/5.0 (X11; Linux x86_64) Chrome/120'` (line 11) to Google Fonts' css2 endpoint, then `_download` scans the CSS for a font URL with `RegExp(r'url\((https://[^)]+\.ttf)\)')` (line 113) and the bytes are further gated by `_isSfnt` (75-91), which only accepts sfnt magic (ttf/otf/ttc/true) and rejects woff2. Google's css2 selects the served container by UA: a Chrome/120 UA is served woff2, not ttf (the well-known trick to get ttf from Google Fonts is to send an OLD/limited UA). So the ttf regex never matches, `ttfUrl` stays null, both variant iterations exhaust, and `_download` returns null — `addFamily` (32-51) fails for effectively any font, and the blanket `catch (_) { return null; }` (120-121, 45-48) leaves no diagnostic. + + +**Решение:** Send a User-Agent that Google Fonts serves the sfnt/TTF container for (e.g. an older browser UA), so the existing `.ttf` regex and `_isSfnt` gate keep working. Note that simply accepting the woff2 URL will NOT work: Flutter's `FontLoader` only decodes sfnt (ttf/otf) bytes and cannot load woff2 directly, so either keep TTF end-to-end or add a woff2->sfnt decode step. Also stop swallowing the failure silently — propagate the error so the settings caller can surface it via `showCustomNotification(context, ...)` instead of the add-font action looking like a no-op. + +
+ +
+🔴 HIGH · КОСТЫЛЬ · [S] — popUntil route-name match never fires, ejecting user to app root instead of SecurityScreen + + +**Где:** `lib/frontend/screens/profile/password_entry_screen.dart:633-636`, `lib/frontend/screens/profile/password_entry_screen.dart:1001-1003`, `lib/frontend/screens/profile/password_entry_screen.dart:1187-1190`, `lib/frontend/screens/profile/password_entry_screen.dart:1359-1361` + + +**Проблема:** After 2FA setup / password change / email change / removal, all four flows call Navigator.popUntil(context, (route) => route.isFirst || route.settings.name == 'SecurityScreen'). Verified that SecurityScreen is pushed at settings_tab.dart:386-391 with a plain MaterialPageRoute and no settings: RouteSettings(name: 'SecurityScreen'), so route.settings.name is always null and the name half of the predicate can never match. The predicate silently degrades to route.isFirst, so each of these four success flows pops the entire stack back to the app root instead of returning to SecurityScreen. + + +**Решение:** Do not match routes by string name (a magic constant that has already rotted). Either name the SecurityScreen route explicitly at its single push site (settings_tab.dart:388) with settings: const RouteSettings(name: 'SecurityScreen') and switch to ModalRoute.withName, or better: capture Navigator.of(context) before pushing the nested flow and pop back a known number of routes / pass an explicit return callback, so the return target is not coupled to a stringly-typed name. + +
+ +
+🟠 MED · КОСТЫЛЬ · [S] — ~260 lines of Dart push notification-display code are dead; the live path is the native Kotlin FCM service + + +**Где:** `lib/core/push/push_service.dart:31`, `lib/core/push/push_service.dart:41-293`, `android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt` + + +**Проблема:** `_backgroundHandler`, `_showMessageNotification`, `_showCallNotification`, `_appendHistory`, `_isActive`, `_avatarBytes`, `_initialsAvatar`, `_downloadBytes`, `_initialsOf`, `_avatarPalette` and `_NotifMessage` are defined but never referenced (grep-confirmed: only their definitions match). `PushService.init()` only wires token lifecycle and the notification-response callback — `FirebaseMessaging.onMessage`/`onBackgroundMessage` are never registered to any of these. Actual notification rendering is implemented natively in KometFcmService.kt (12KB, present) with its own history/avatar/messaging-style logic, and the two have already drifted. A future engineer could waste time 'fixing' a bug in the dead Dart copy that never runs. (`_clearHistory` and the `_on*Response`/`_handle*` reply/decline handlers are live and must be kept.) + + +**Решение:** Delete the unreachable Dart notification-rendering functions and `_backgroundHandler`, keeping only the token lifecycle and the reply/decline handlers (`_onNotificationResponse`, `_handleReply`, `_handleCallDecline`, `_clearHistory`, `clearChatNotification`) that the native side dispatches into. + +
+ +
+🟠 MED · КОСТЫЛЬ · [S] — Menu items and channel mute button are dead UI (no-op onTap) + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:2875-2886`, `lib/frontend/screens/chats/chat_screen.dart:5620-5646` + + +**Проблема:** `_openChatMenu` wires the `Symbols.volume_up` 'Уведомления' item and the `Symbols.videocam` 'Видеозвонок' item to `onTap: () {}`, and the CHANNEL composer renders a full-width 'Отключить уведомления' `GlossyPill` with `onTap: () {}`. These render as enabled, tappable controls that silently do nothing, which reads to the user as a bug rather than a missing feature. + + +**Решение:** Either implement the real behavior via the appropriate backend module (mute/notifications, video call) or omit the control until the feature exists, rather than shipping a convincing but non-functional affordance. + +
+ +
+🟠 MED · СОМНИТ · [S] — Wallpaper 'theme' picker is permanently empty — kChatWallpaperThemes is always [] + + +**Где:** `lib/core/config/chat_wallpaper_themes.dart:17-36`, `lib/frontend/widgets/chat_wallpaper_sheet.dart:104-110` + + +**Проблема:** `kChatWallpaperThemes` is `const []` with no seed data (chat_wallpaper_themes.dart:28), so `chatWallpaperThemeById` can never resolve anything and the `for (final theme in kChatWallpaperThemes)` loop that builds theme tiles (chat_wallpaper_sheet.dart:104) never renders a tile beyond the 'None' option — a whole UI section is dead in production. The class also has `buildBackground()` and `buildPreview()` (17-25) with byte-for-byte identical bodies. + + +**Решение:** Either finish the feature by populating `kChatWallpaperThemes` with real gradient presets (and collapse `buildBackground`/`buildPreview` into one method), or remove `ChatWallpaperTheme`, `chatWallpaperThemeById`, and the theme-row UI path together until it is implemented, instead of shipping unreachable scaffolding. + +
+ +
+🟠 MED · СОМНИТ · [M] — Non-functional 'Sign in with QR' and 'Sign in with session file' menu entries + + +**Где:** `lib/frontend/screens/auth/login_screen.dart:657-670`, `lib/frontend/screens/auth/login_screen.dart:693-706` + + +**Проблема:** In `_showOtherLoginMethods`, the ListTiles for `l10n.loginSignInWithQr` and `l10n.loginSignInWithSessionFile` have `onTap` handlers that only call `Navigator.pop(context)` — they navigate nowhere and call no backend module (contrast with the adjacent 'Sign in with token' tile at 671-691 which pushes `TokenLoginScreen`). A user opens 'Other sign-in methods', taps QR or session-file, and the sheet just closes with no error, no navigation, nothing — a dead affordance presented as a real feature. (The existing `web_qr_login.dart` flow is a different use case: authorizing a new session from an already-logged-in device, not scanning a QR to log in here.) + + +**Решение:** Either implement the missing flows behind backend module calls (scan/parse a login QR, or pick+parse a session file), or remove/disable these tiles and surface `showCustomNotification(context, ...)` until implemented, so the UI never presents an unimplemented feature as functional. + +
+ +
+🟠 MED · СОМНИТ · [M] — Several fully-styled interactive controls are wired to no-ops, presenting unimplemented features as working + + +**Где:** `lib/frontend/screens/calls/calls_tab.dart:149-152`, `lib/frontend/screens/calls/calls_tab.dart:385-407`, `lib/frontend/screens/contacts/contact_profile_screen.dart:210-215` + + +**Проблема:** Tapping a call-history row does nothing (InkWell.onTap is an empty body with a placeholder comment, lines 150-152), the prominent 'Создать групповой звонок' row's InkWell.onTap is a bare () {} (line 386), and the contact-profile 'Звук'/'Звонок' quick actions are permanently onTap: null (lines 213-214) with no disabled visual treatment. Users see normal-looking tappable controls that silently do nothing. All confirmed. + + +**Решение:** Either finish wiring these (the row tap can reuse the existing _callBack/menu logic; group-call creation should call into CallsModule) or visibly disable/hide the controls until implemented, rather than shipping dead taps. + +
+ +
+🟠 MED · СОМНИТ · [S] — Attachment panel ships a raw fileId send-bypass to every user unconditionally + + +**Где:** `lib/frontend/widgets/attachment_panel.dart:69-92`, `lib/frontend/screens/chats/chat_screen.dart:1714-1718` + + +**Проблема:** `AttachmentPanel` always renders an 'Отправить по id' button plus a numeric TextField (lines 69-92); typing any integer and tapping it calls `onSendById`, sending whatever file that id resolves to and bypassing the normal pick-a-file flow. It is wired unconditionally into the main chat screen's attach panel (chat_screen.dart:1714-1718 → `_sendFileById`) with no debug/feature flag, so it ships to every user and flavor. It reads like a developer testing shortcut promoted straight into production UI. Confirmed present and always-rendered. + + +**Решение:** Remove the raw fileId field from the shipped UI, or gate it behind an explicit kDebugMode / feature flag. If re-sending a known attachment by id is a genuine product need, expose it as a typed action (e.g. re-share from message history) rather than a free-form numeric input. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — LinkText widget is dead code duplicating FormattedMessageText's auto-link handling + + +**Где:** `lib/frontend/widgets/link_text.dart:11`, `lib/frontend/widgets/formatted_message_text.dart:70` + + +**Проблема:** The `LinkText` widget and `_LinkTextState` are never instantiated anywhere (grep for `LinkText(` finds only the constructor declaration). Only the static `LinkText.hasLinks` and the top-level `linkPattern` regex are used, exclusively from `FormattedMessageText`. `_LinkTextState.build` (link_text.dart:44-60) reimplements the exact 'find URL, prefix www. with https://' logic — plus its own TapGestureRecognizer list and dispose bookkeeping — that `FormattedMessageText._withAutoLinks` (formatted_message_text.dart:70-87) already contains. About 50 lines of unreachable widget code with duplicated recognizer lifecycle. + + +**Решение:** Delete the `LinkText` widget/state, keeping only `linkPattern` and a plain top-level `bool hasLinks(String? text)` (optionally moved to `core/utils/text_format.dart`, which `FormattedMessageText` already imports). + +
+ + +### Performance: over-broad rebuilds and wasteful hot paths (34 — 3 high) + +_Cheap events trigger whole-list rebuilds, per-item network fan-out replaces existing batch calls, and blocking work runs on the UI isolate._ + +
+🔴 HIGH · ОПТ · [M] — Entire visible message list rebuilds on every composer-height or read-receipt change + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:4580`, `lib/frontend/screens/chats/chat_screen.dart:4485`, `lib/frontend/screens/chats/chat_screen.dart:4525` + + +**Проблема:** The ListView.builder rendering all visible bubbles is wrapped in ListenableBuilder(listenable: Listenable.merge([_otherReadTime, _composerHeight])). _composerHeight is pushed by _MeasureSize (line 4485) whenever the composer's rendered height changes (text wrapping to a new line, reply-preview appearing, attach/sticker panel toggling), and _otherReadTime changes on each read receipt. Every such change reconstructs the ListView.builder with a fresh itemBuilder closure, and SliverChildBuilderDelegate.shouldRebuild returns true, so all currently-built MessageBubbles are rebuilt — including _effectiveStatus recomputation, _reactionNotifierFor lookups and swipe-to-reply wrappers — even though only the list bottom padding (_composerHeight, used in _messagesListPadding) and a single sender-side read indicator (_otherReadTime) actually depend on these values. This is on top of the outer _messagesRev ValueListenableBuilder that already rebuilds the list on message changes. + + +**Решение:** Stop gating the whole ListView on these notifiers. Feed _otherReadTime into MessageBubble and let a small ValueListenableBuilder inside the status indicator react to it, rather than recomputing _effectiveStatus for every bubble at list-build time. For padding, avoid rebuilding the delegate on height changes (e.g. apply the composer offset outside the ListView or via a stable SliverPadding) so a composer resize does not invalidate all built children. + +
+ +
+🔴 HIGH · ОПТ · [M] — Whole message ListView is gated on composer-height and read-time notifiers + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:4580-4584`, `lib/frontend/screens/chats/chat_screen.dart:4590-4628`, `lib/frontend/screens/chats/chat_screen.dart:4525-4534`, `lib/frontend/screens/chats/chat_screen.dart:4484-4489` + + +**Проблема:** `_buildMessagesListContent()` wraps the whole `ListView.builder` in `ListenableBuilder(listenable: Listenable.merge([_otherReadTime, _composerHeight]))`. When that builder reruns it reconstructs the `ListView.builder` with a fresh `SliverChildBuilderDelegate` (whose default `shouldRebuild` is true), so on the next layout `itemBuilder` is re-invoked for every mounted row and every visible `MessageBubble` is rebuilt. `_composerHeight` is written from `_MeasureSize.onHeight` (line 4485) and changes on any composer size change (mode switch, multi-line wrap while typing, keyboard reserve). `_otherReadTime` forces the same full rebuild just to move the checkmark on the single most-recent own message. So ordinary composer interactions trigger full visible-list rebuilds even though `_composerHeight` is only consumed for the list's bottom padding (line 4532). + + +**Решение:** Stop rebuilding the `ListView.builder` from these notifiers. Keep the delegate/list stable and reserve the bottom space with a separately animated sliver/spacer (or a `ValueListenableBuilder` wrapping only a `SliverPadding`/`Padding`, not the `ListView`). Move the read/seen checkmark into a per-message `ValueListenableBuilder` inside `MessageBubble` so only the last own message reacts to `_otherReadTime`. + +
+ +
+🔴 HIGH · ОПТ · [S] — Chat-list contact prefetch fires one network request per unknown contact instead of the existing batched call + + +**Где:** `lib/frontend/screens/chats/chat_list_screen.dart:770-795`, `lib/backend/modules/messages.dart:1798-1849`, `lib/backend/modules/messages.dart:1851-1910` + + +**Проблема:** Confirmed. `_prefetchContactsForChats` loops over every unresolved id and calls `messagesModule.searchContactById(id)` (line 790). Verified in messages.dart:1805-1807 that each call sends its own `Opcode.contactInfo` request with a single-element `contactIds: [contactId]` list, i.e. one round trip per contact. The module already exposes `ensureContactNames(Iterable ids)` (messages.dart:1851) which sends all missing ids in a single `contactInfo` request, and chat_screen.dart:3659 already uses it correctly for the same purpose. On an account with many dialogs this fires N concurrent packets on every reload, and `_runReload` runs on every `ChatsModule.chatsChanged`, draft change, and login. + + +**Решение:** Replace the per-id loop with a single `await messagesModule.ensureContactNames(ids); _scheduleContactRebuild();` using the already-computed `ids` set. `ensureContactNames` already filters ids whose name is cached, so `_inflightContactIds` can be dropped, or kept only as a coarse in-flight guard to avoid re-issuing the batch while one is pending. + +
+ +
+🟠 MED · ОПТ · [M] — Every compressed incoming packet spawns and tears down a brand-new Isolate + + +**Где:** `lib/core/protocol/packet.dart:156`, `lib/core/protocol/packet.dart:160`, `lib/backend/api.dart:377`, `lib/backend/api.dart:380` + + +**Проблема:** unpackPacket() only decodes inline when `compFlag == 0 && slice.length < _isolateDecodeThreshold` (4096). Any compressed payload — regardless of size — takes the `await Isolate.run(() => _deserializePayload(owned, compFlag))` path (packet.dart:160), which spawns a fresh isolate (VM init + message-copy of the Uint8List) and kills it after a single decode. In api.dart's `_onDataReceived` the decoded packets are processed in a strict `for` loop with `await unpackPacket(raw)` per packet (api.dart:377-380), so a burst of compressed packets pays the spin-up/tear-down cost serially, adding latency and CPU/battery overhead to a hot, high-frequency path. Verified: a compressed 40-byte payload still incurs the full isolate round-trip even though decoding it inline would be cheaper than the copy alone. + + +**Решение:** Start one long-lived worker isolate (Isolate.spawn once at Connection/Api startup) that receives (bytes, compFlag) jobs over a SendPort/ReceivePort and returns decoded results, reused for the session lifetime instead of per-packet spin-up/tear-down. Additionally base the offload decision on actual slice size for both compressed and uncompressed payloads (e.g. `slice.length < threshold`), so tiny compressed payloads decode inline rather than paying the isolate round-trip. + +
+ +
+🟠 MED · ОПТ · [M] — Network history parsed synchronously on the UI isolate with a Duration.zero micro-yield instead of compute() like the DB path + + +**Где:** `lib/backend/modules/messages.dart:597-609`, `lib/backend/modules/messages.dart:511-518`, `lib/backend/modules/messages.dart:997-1001` + + +**Проблема:** fetchHistory runs _parseMessage synchronously per message on the UI isolate and only awaits Future.delayed(Duration.zero) every 20 items — a microtask yield that keeps all CPU work (attachment objects, Map copies) on the UI isolate and merely interleaves it with frame callbacks. fetchDelayedMessages (backward:150) does the same synchronous loop with no yield at all. Meanwhile the equivalent local-DB work in fromDbRowsAsync already offloads to a background isolate via compute() once rows.length>=20, so identical work is treated inconsistently by data source. + + +**Решение:** Make map-to-CachedMessage parsing a top-level/static function (reused by fromDbRowsAsync) and route fetchHistory/fetchDelayedMessages through compute() once the batch is large enough, dropping the Duration.zero micro-yield which does not move the work off-thread. + +
+ +
+🟠 MED · ОПТ · [L] — Every fine-grained event triggers a full chat-list reload and full reparse + + +**Где:** `lib/backend/modules/chats.dart:455-456`, `lib/backend/modules/chats.dart:1101-1110` + + +**Проблема:** chatsChanged is a single global ValueNotifier bumped by virtually every event (new message, read receipt, reaction change, mute toggle, mark, rename...). Listeners react by calling getChats(accountId), which reloads every chat row from SQLite and re-runs CachedChat.fromDbRow for all of them. fromDbRow (lines 115-138) jsonDecodes participants (_parseParticipants) and CSV-splits options and admins (_decodeOptions/_decodeAdmins) per row. For an account with hundreds of chats, a single incoming message anywhere causes an O(all chats) DB read plus O(all chats) JSON/CSV decode, repeated on every subsequent unrelated event. + + +**Решение:** Emit which chat id(s) changed (chatsChanged could carry the changed id, or reuse the existing messageEvents-style broadcast stream) so listeners can patch just the affected CachedChat in their in-memory list instead of re-fetching and re-parsing the entire table on every bump. + +
+ +
+🟠 MED · ОПТ · [S] — SelfCheckService polls every 10s forever with no app-lifecycle awareness + + +**Где:** `lib/backend/modules/self_check.dart:15-42`, `lib/main.dart:465-482` + + +**Проблема:** After init(api), Timer.periodic(10s) fires indefinitely and forces a network round trip via PresenceFetch.get(accountId, forceRefresh: true) (bypassing the cache TTL) regardless of whether the app is foregrounded, backgrounded, or the screen is off. main.dart already has a didChangeAppLifecycleState handler (lines 465-482, wiring CallController, api.wakeUp, DebugSessionLog) but SelfCheckService is never hooked in, so this periodic forced network work keeps draining battery/data while the app is paused/hidden/detached. + + +**Решение:** Give SelfCheckService pause()/resume() (or an AppLifecycleState observer) and call them from the existing didChangeAppLifecycleState handler: cancel the timer on paused/hidden/detached and restart it (with an immediate checkNow()) on resumed. + +
+ +
+🟠 MED · ОПТ · [S] — Timezone database reinitialized on every connect/reconnect attempt + + +**Где:** `lib/backend/api.dart:209` + + +**Проблема:** tz.initializeTimeZones() runs synchronously inside sendHandshake(), which connect() calls on every attempt — including every auto-reconnect scheduled by _scheduleReconnect (delay 2-15s on a flaky link, api.dart:490-497). It re-parses the full embedded IANA timezone database on the calling isolate each time even though the data never changes after first load. Confirmed at api.dart:209, reached via connect()->sendHandshake() at api.dart:128. + + +**Решение:** Guard with a one-time static flag (if (!_tzInitialized) { tz.initializeTimeZones(); _tzInitialized = true; }) or initialize once at app startup before any Api instance is created, so reconnects never repeat the parse. + +
+ +
+🟠 MED · ОПТ · [S] — Privacy setup issues four separate chatUpdate round-trips instead of one merged, atomic request + + +**Где:** `lib/backend/modules/cloud_storage.dart:84-91` + + +**Проблема:** _configurePrivacy calls ChatsModule.setChatOptions four times concurrently, each sending its own Opcode.chatUpdate packet for one boolean, even though setChatOptions already accepts a full options: Map (chats.dart:1506-1516, verified) that packs everything into one chatUpdate. This quadruples round-trips per group setup/repair, and because the four requests are independent, a partial failure leaves the group in an inconsistent privacy state (e.g. icon-lock applied but admin-only-call not) with no atomicity or rollback. + + +**Решение:** Merge all four into one call: setChatOptions(api, chatId: chatId, options: {'ONLY_OWNER_CAN_CHANGE_ICON_TITLE': true, 'ONLY_ADMIN_CAN_ADD_MEMBER': true, 'ALL_CAN_PIN_MESSAGE': false, 'ONLY_ADMIN_CAN_CALL': true}), dropping Future.wait and making the configuration atomic from the client's perspective. + +
+ +
+🟠 MED · ОПТ · [S] — Desktop gallery listing does synchronous filesystem I/O on the calling isolate + + +**Где:** `lib/core/media/gallery_source.dart:131-147`, `lib/frontend/widgets/attachment/attachment_sheet.dart:120` + + +**Проблема:** _DesktopGallerySource.load calls dir.listSync(followLinks: false) and entity.statSync() for every entry synchronously (lines 136-138). It is awaited from AttachmentSheet at line 120 when the picker opens; for a Pictures folder with thousands of files this blocks the UI isolate's event loop for the whole scan (a blocking stat syscall per file), freezing the UI exactly when the user opens the attachment sheet. + + +**Решение:** Replace the synchronous scan with the async Directory.list() stream plus await FileSystemEntity.stat(), or move the entire walk into Isolate.run/compute. Since load is already async, this is a drop-in change with no caller-API impact. + +
+ +
+🟠 MED · ОПТ · [M] — Opus/Ogg voice-note encoding runs synchronously on the UI isolate + + +**Где:** `lib/core/media/opus_ogg_encoder.dart:54-88`, `lib/frontend/screens/chats/chat_screen.dart:5025-5039` + + +**Проблема:** wavToOggOpus -> _encodePcm loops over every 20ms PCM frame making a blocking FFI encode call and then assembles the Ogg container, all synchronously. _transcodeWavToOgg (chat_screen.dart:5025) awaits it directly on the calling isolate with no offload, so for longer recordings the per-frame encode plus WAV parse stalls the UI thread and produces visible jank right after the user stops recording. + + +**Решение:** Move the encode into Isolate.run(() => ...). Note this is NOT a bare drop-in: opus_dart's initOpus is per-isolate global state, so the spawned isolate must itself call OpusOggEncoder.ensureAvailable()/initOpus before encoding (or use a long-lived worker isolate initialized once). Keep the WAV read on the main isolate and pass the bytes in. + +
+ +
+🟠 MED · ОПТ · [M] — Global presence revision counter forces every listener to rebuild on any user's presence change + + +**Где:** `lib/core/cache/info_cache.dart:138`, `lib/core/cache/info_cache.dart:144-149`, `lib/core/cache/info_cache.dart:159-170`, `lib/frontend/widgets/online_dot.dart:23-26`, `lib/frontend/screens/chats/chat_screen.dart:543`, `lib/frontend/screens/chats/chat_screen.dart:1067` + + +**Проблема:** PresenceFetch.revision is a single ValueNotifier bumped on every apply()/primeAll()/clear(). OnlineDot (one per row in chat/contact lists) subscribes to this one global notifier, so a presence packet for one unrelated user rebuilds every visible OnlineDot's ValueListenableBuilder subtree. ChatScreen registers a global _onPresenceChanged listener on the same notifier (line 543/1067) that recomputes header status on every system-wide presence event, not just the peer it cares about. Individual rebuilds are cheap (AnimatedScale + Container), but the fan-out to all rows on each event is unnecessary work. + + +**Решение:** Key notifications per user id, mirroring the existing keyed-listenable pattern already used in this codebase (e.g. ChatActivityStore.instance.listenable(chatId), used in the same chat_screen.dart). Store a keyed pub/sub (e.g. Map>) in PresenceFetch, have apply()/primeAll() notify only the ids that actually changed, and have OnlineDot/ChatScreen listen to `PresenceFetch.listenable(userId)` so only affected consumers rebuild. + +
+ +
+🟠 MED · ОПТ · [M] — MediaCache eviction sorts by calling blocking statSync twice per comparison on the main isolate + + +**Где:** `lib/core/utils/media_cache.dart:169-170` + + +**Проблема:** `_enforceLimit` (run after a download that pushes the cache over its byte limit) sorts cache files with `a.statSync().modified.compareTo(b.statSync().modified)`. `statSync` is blocking synchronous disk I/O on the calling (UI) isolate, and the comparator re-stats both files on every comparison — O(n log n) synchronous stat calls, redundantly re-statting the same file many times. With a large cache (thousands of files) this blocks the UI isolate mid-sort and can cause a visible frame hitch right after a download. Note the surrounding methods deliberately use async `dir.list()`/`await entity.length()`, so this sync call is inconsistent with the file's own style. + + +**Решение:** While iterating `dir.list()`, `await entity.stat()` once per file into a list of `(File, DateTime)` pairs, then sort by the precomputed timestamp — eliminating both the synchronous call and the repeated per-comparison stats. + +
+ +
+🟠 MED · ОПТ · [M] — Lottie sticker frames are rasterized synchronously (toImageSync) in the build/paint path + + +**Где:** `lib/frontend/widgets/sticker_lottie.dart:64`, `lib/frontend/widgets/sticker_lottie.dart:86`, `lib/frontend/widgets/sticker_lottie.dart:357` + + +**Проблема:** _StickerFrames.frameAt is called directly from the ValueListenableBuilder builder in _StickerLottieState.build() (line 357). On a cache miss it synchronously does drawable.draw(...) then picture.toImageSync(pxSize, pxSize) (line 86) inline during build/paint. Each distinct frame index is a miss the first time the ticker reaches it (up to 30 fps), so every animated sticker bursts through this blocking raster path once per unique frame on its first loop. Impact is bounded — frames are cached after first render and StickerLoadGovernor drops to _lastImage under load — but the governor is a reactive workaround for jank that this synchronous call directly causes. + + +**Решение:** Decouple rasterization from build: pre-render frames ahead of the ticker on a scheduler/idle callback using the async picture.toImage() and have build() only ever read an already-rendered frame from _images, turning the governor into a true fallback instead of the primary defense. + +
+ +
+🟠 MED · ОПТ · [S] — PollView force-refetches on every mount, defeating the module's cache and in-flight dedup + + +**Где:** `lib/frontend/widgets/poll_view.dart:61` + + +**Проблема:** initState calls pollsModule.fetch(chatId, messageId, pollId, force: true) unconditionally. PollsModule.fetch short-circuits on `_cache.containsKey(pollId) || _inFlight.contains(pollId)` only when force is false (polls.dart:24), and the post-vote refresh already passes force:true (polls.dart:84). Because poll bubbles live in a chat ListView.builder and are disposed/recreated as the user scrolls, every scroll-back-in triggers a fresh network round trip for data that is very likely already cached and unchanged. + + +**Решение:** Call fetch(...) with force:false on mount so the existing cache/in-flight guards apply; reserve force:true for the post-vote refresh path (already used in PollsModule.vote) or an explicit pull-to-refresh. + +
+ +
+🟠 MED · ОПТ · [S] — Shimmer effect duplicated across two screens with an AnimationController that never stops + + +**Где:** `lib/frontend/screens/profile/security_screen.dart:34-45`, `lib/frontend/screens/profile/security_screen.dart:146-184`, `lib/frontend/screens/profile/devices_screen.dart:36-47`, `lib/frontend/screens/profile/devices_screen.dart:385-444` + + +**Проблема:** Both screens independently implement the same shimmer placeholder: AnimationController(...)..repeat() started unconditionally in initState, AnimatedBuilder with Opacity(0.3 + 0.2 * sin(controller.value * pi * 2)). Confirmed identical. In both, the controller is only stopped in dispose(), so repeat() keeps the ticker scheduling frames for the entire screen lifetime even after _isLoading flips false and the shimmer widgets are no longer built — continuous frame scheduling that prevents the engine from going idle. + + +**Решение:** Extract one shared ShimmerBox widget (owning its own controller) into frontend/widgets and call controller.stop() as soon as loading completes (or only run repeat() while the loading flag is true), reused by both screens. + +
+ +
+🟠 MED · ОПТ · [M] — CallScreen calls setState on the whole widget tree on every audio-level info tick + + +**Где:** `lib/frontend/screens/calls/call_screen.dart:224-231` + + +**Проблема:** The infoUpdates listener bound in _bind() responds to every info tick (which fires on audio-level/speaking-set changes during an active call) by recomputing participants and calling an unconditional setState(() {}), rebuilding the entire CallScreen including any group-call GridView of participant tiles, just to move a speaking-highlight border. Confirmed at lines 224-231. Widget rebuilds of RTCVideoView are cheaper than a full re-render, so impact is moderate, but the whole-screen rebuild several times per second during a call is still wasteful. + + +**Решение:** Expose speaking/mute/video state via a ValueNotifier/ChangeNotifier keyed by participant and rebuild only the affected tile with ValueListenableBuilder, instead of setState on the whole screen on every info tick. + +
+ +
+🟡 LOW · ОПТ · [S] — PacketReceiver re-copies the whole pending buffer on every socket chunk + + +**Где:** `lib/core/transport/receiver.dart:57`, `lib/core/transport/receiver.dart:65`, `lib/core/transport/receiver.dart:66`, `lib/core/transport/receiver.dart:69` + + +**Проблема:** `_append` (receiver.dart:57-72) allocates a new Uint8List of exact size `pending + data.length` and copies both the previously-buffered unconsumed tail and the new chunk into it on every `feed()` call while a packet is still incomplete. For a packet spread across many TCP reads (large chatHistory/chatsList/foldersGet sync responses), this is an O(n^2) copy pattern. The file's own doc comment (receiver.dart:19-21) claims accumulation happens 'без перекопирования всего буфера на каждый чанк', but that only holds for the consumed prefix; the pending unconsumed tail is fully re-copied on each append. Absolute cost is modest (memmove, only on large syncs), hence low severity, but it contradicts the documented behavior and grows with payload size. + + +**Решение:** Grow the backing buffer with spare capacity (e.g. doubling) instead of an exact fit, mirroring the `ensure()` doubling helper already present in lz4_block.dart:13-16, and copy the unconsumed tail once per growth event rather than once per chunk — turning assembly of one large packet from O(n^2) into amortized O(n). + +
+ +
+🟡 LOW · ОПТ · [S] — lastMsgFormatRanges getter re-parses JSON on every access with no memoization + + +**Где:** `lib/backend/modules/chats.dart:88-96` + + +**Проблема:** lastMsgFormatRanges is a getter that calls jsonDecode(raw) and parseFormatElements(...) fresh on every read. Any widget that accesses it more than once per build, or on every rebuild while the chat object is unchanged (e.g. in a scrolling ListView), repeats the JSON decode and format-range parse unnecessarily. + + +**Решение:** Memoize lazily rather than eagerly: use `late final List lastMsgFormatRanges = _computeFormatRanges();` so the parse runs at most once per instance and only when actually accessed. Do NOT compute it eagerly in the constructor initializer list (unlike lastMsgTextOneLine), since that would parse format elements for every chat including off-screen ones and would worsen the bulk-reparse cost noted elsewhere. + +
+ +
+🟡 LOW · ОПТ · [S] — Login persists 8 sync values via 8 sequential single-row inserts + + +**Где:** `lib/backend/modules/account.dart:1300-1324`, `lib/core/storage/app_database.dart:436-447` + + +**Проблема:** _saveSyncState awaits AppDatabase.setSyncValue(...) eight times in sequence on every login (serverTime, lastLogin, chatsSync, contactsSync, callsSync, draftsSync, bannersSync, presenceSync, plus optional configHash). Each call is its own `db.insert(...)` (confirmed at app_database.dart:436-447) — a separate platform-channel round trip and statement — serialized on the login path. Minor since login is infrequent, but trivially batchable. + + +**Решение:** Add `AppDatabase.setSyncValues(accountId, Map values)` that wraps the rows in a single `db.batch()`/transaction, and have _saveSyncState build the map and call it once (the _persistEntryBannerApps loop just below can use the same batched API). + +
+ +
+🟡 LOW · ОПТ · [M] — Response parser re-decodes the whole accumulated body as UTF-8 on every incoming chunk + + +**Где:** `lib/backend/modules/file_uploader.dart:557-584`, `lib/backend/modules/file_uploader.dart:586-591` + + +**Проблема:** _readFullResponse's tryParse() re-scans the entire accumulated buffer for the header terminator from index 0 and utf8.decodes both header and full body-so-far, and is invoked again on every chunk delivered by socket.listen (line 589), giving O(n^2) decode/allocation work to detect completion. In practice these are small CDN ack/JSON responses (typically one or two chunks), so the quadratic cost is negligible today — worth cleaning up but not urgent. + + +**Решение:** Cache the header-end offset once found instead of re-searching from 0, and track completion via raw byte checks (running content-length counter / chunked terminator on the new suffix), performing utf8.decode only once when the response is complete. + +
+ +
+🟡 LOW · ОПТ · [S] — Every ws2 frame is JSON re-encoded for a trace log that is discarded in release + + +**Где:** `lib/core/calls/ws2_signaling.dart:151-155` + + +**Проблема:** _onFrame unconditionally calls jsonEncode(decoded) at line 151 and builds a truncated dump string before passing it to logger.t. In release builds the logger level is Level.info (core/utils/logger.dart:27-29), so the trace call is dropped, but the re-serialization still runs on every incoming signaling frame (including large SDP offer/answer frames). Volume is control-plane only, so cost is modest, but it is pure waste in production. + + +**Решение:** Pass a closure to the logger so serialization is lazy (the printer already supports a Function message via _stringifyMessage in logger.dart:137), e.g. logger.t(() => dump-building expression), or guard the encode with an explicit level check so the jsonEncode only runs when the trace sink is active. + +
+ +
+🟡 LOW · ОПТ · [M] — Desktop URL-scheme registration shells out to OS commands on every launch without checking current state + + +**Где:** `lib/core/links/desktop_url_scheme.dart:20-66`, `lib/core/links/deep_link_service.dart:26` + + +**Проблема:** `DesktopUrlScheme.register()` runs once per launch (deep_link_service.dart:26, guarded only by `_started`) and unconditionally spawns external processes: on Windows 3 `reg add` calls per scheme x 2 schemes = 6 processes; on Linux 2 `xdg-mime default` + 1 `update-desktop-database`. The `.desktop` file write is guarded by a content comparison (lines 58-59), but the `reg`/`xdg-mime`/`update-desktop-database` calls always run, re-asserting already-correct state and rewriting the Linux desktop-database index every launch. Minor startup overhead, desktop-only, best-effort. + + +**Решение:** Query current registration first (`reg query` on Windows, `xdg-mime query default` on Linux) and skip the mutating `Process.run` calls when it already points at the current executable/scheme. + +
+ +
+🟡 LOW · ОПТ · [S] — Forwarded-sender names resolved with sequential awaited network calls + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:3680` + + +**Проблема:** _loadForwardedSenderNames resolves each unknown forwarded sender one at a time: `for (final id in forwardIds) { final name = await messagesModule.searchContactById(id); ... }`, so N unknown senders cost N sequential round trips. The sibling _loadGroupSenderNames, invoked on the same history-load paths, batches equivalent lookups via messagesModule.ensureContactNames(unknownIds) in a single call. + + +**Решение:** Batch with Future.wait, or add a plural searchContactsByIds module call analogous to ensureContactNames, so multiple forwarded senders resolve in one round trip. + +
+ +
+🟡 LOW · ОПТ · [M] — O(n) message lookups by id on every realtime/upload event + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:2129-2150`, `lib/frontend/screens/chats/chat_screen.dart:3376`, `lib/frontend/screens/chats/chat_screen.dart:6446` + + +**Проблема:** Incoming message/edit/delete/reaction/upload/send-confirmation events resolve their target via `_messages.indexWhere((m) => m.id == ...)` at 20 confirmed call sites — a full scan of the loaded message list per event. Real, but low materiality: the list is bounded to loaded history (hundreds, occasionally low thousands), a scan is microseconds, and the subsequent `_bumpMessages()`/rebuild dominates the cost. The suggested index map also adds sync burden across all 20 mutation sites. + + +**Решение:** Only if this measurably shows up: maintain an auxiliary `Map` (id -> index) kept in sync with every `_messages` mutation, or use a `LinkedHashMap`-backed source of truth, to make id lookups O(1). Otherwise leave as-is; not worth the added invariant. + +
+ +
+🟡 LOW · ОПТ · [M] — Search-results list uses a non-virtualized ListView with children spread inline, eagerly building every result tile (and its avatar network load) + + +**Где:** `lib/frontend/screens/chats/search_screen.dart:238-286` + + +**Проблема:** _buildBody builds a plain ListView(children: [...]) with every section spread inline (for (final row in _contacts) ..., for (final hit in _messages) ..., etc.). All result tiles across every section are constructed and mounted immediately rather than on demand, and because each _ResultTile holds a KometAvatar (CachedNetworkImage), all avatar network loads are kicked off at once regardless of what is on screen. + + +**Решение:** Flatten the sections into a single index-based list and render with a builder-style list (ListView.builder or a CustomScrollView with SliverList.builder per section) so off-screen tiles and their avatar requests are deferred until scrolled into view. + +
+ +
+🟡 LOW · ОПТ · [S] — Two independent info fetches in ChatInfoScreen._load() for a dialog run sequentially instead of in parallel + + +**Где:** `lib/frontend/screens/chats/chat_info_screen.dart:156-169` + + +**Проблема:** For a DIALOG chat, ContactInfoFetch.get(_otherId!) (line 156) is awaited to completion before PresenceFetch.get(_otherId!) (line 163) even starts, though the two are independent (profile info vs. presence) and neither depends on the other. This adds an unnecessary extra round-trip of latency to opening a dialog-info screen on a cold cache. + + +**Решение:** Fire both concurrently with Future.wait([...]) and destructure results, as search_screen.dart already does for its parallel searches in _runSearch. Note _isBot is derived from the contact result and used by _tabs, so keep that ordering intact after the join. + +
+ +
+🟡 LOW · ОПТ · [S] — Contact picker re-filters and re-lowercases the whole list on every keystroke with no debounce or memoization + + +**Где:** `lib/frontend/screens/chats/create_group_flow.dart:230-235`, `lib/frontend/screens/chats/create_group_flow.dart:282` + + +**Проблема:** _buildPickerStep recomputes `_all.where((c) => _displayName(c).toLowerCase().contains(query)).toList()` from scratch on every build, and the search TextField's onChanged is `(_) => setState(() {})` with no debounce, so each character re-walks the full contact list and re-concatenates/re-lowercases every contact's display name even though those strings never change between keystrokes. + + +**Решение:** Precompute a lowercase display name once per contact when _all is loaded (store (contact, lowerName) pairs or a small wrapper) and filter against the cached strings. This is a minor cost for typical list sizes, so treat it as low priority. + +
+ +
+🟡 LOW · ОПТ · [S] — Voice-message progress ticker keeps polling after playback pauses or ends + + +**Где:** `lib/frontend/widgets/message_bubble.dart:2912`, `lib/frontend/widgets/message_bubble.dart:2881` + + +**Проблема:** `_togglePlay` creates `_ticker = Timer.periodic(60ms, (_) => _onTick())` only on the first play (inside the `_player == null` branch); subsequent pause/resume take the early-return `_player != null` path and never touch `_ticker`, which is only cancelled in `dispose()`. So once any voice note has been played, its 60ms timer keeps firing for the widget's whole lifetime, calling `player.currentPosition` and running clamp math while paused/ended. (Impact is bounded: `_progress` is a ValueNotifier that dedupes equal writes, so a paused position does not trigger rebuilds — the cost is the wasted 60ms polling itself, scaling with how many voice bubbles have been played.) + + +**Решение:** Start the ticker in `_onPlayerState` when transitioning to `PlayerState.playing` and cancel it when transitioning away (pause/ended), recreating on the next play, instead of keeping one ticker alive for the widget's lifetime. + +
+ +
+🟡 LOW · ОПТ · [S] — Sticker section always shows a loading shimmer even when every sticker is already cached + + +**Где:** `lib/frontend/widgets/sticker_panel.dart:307`, `lib/frontend/widgets/sticker_panel.dart:315` + + +**Проблема:** _StickerSectionState always initializes `_loaded = false` and awaits stickersModule.ensureStickers before flipping it, even when every id is already cached and ensureStickers does no network work. Because the vertical list is a virtualized ListView.builder, sections are disposed/recreated on scroll, so already-loaded sections re-enter the shimmer placeholder and pay an extra microtask/rebuild every time they scroll back into view, purely because the check is async rather than synchronous. + + +**Решение:** In initState, synchronously check whether every id already has stickersModule.cachedSticker(id) != null and set _loaded = true immediately in that case, falling back to the async ensureStickers await (and shimmer) only when something is actually missing. + +
+ +
+🟡 LOW · ОПТ · [S] — Locale-splitting RegExp recompiled on every keystroke + + +**Где:** `lib/frontend/screens/profile/spoof_screen.dart:66`, `lib/frontend/screens/profile/spoof_screen.dart:276` + + +**Проблема:** _syncDeviceLocale is registered as a listener on _localeController (initState line 60) and therefore runs on every character typed into the locale field; it constructs a new RegExp(r'[-_]') each call (line 66). The same literal is re-constructed in _applyPreset (line 276). Confirmed. Minor allocation, but trivially avoidable. + + +**Решение:** Hoist a single static final _localeSplitter = RegExp(r'[-_]'); at class scope and reuse it in both _syncDeviceLocale and _applyPreset. + +
+ +
+🟡 LOW · ОПТ · [S] — Regex recompiled on every phone-input keystroke, plus a vestigial empty if-branch + + +**Где:** `lib/frontend/screens/auth/login_screen.dart:892-895`, `lib/frontend/screens/auth/login_screen.dart:1079,1082`, `lib/frontend/screens/auth/login_screen.dart:1109-1110` + + +**Проблема:** The phone `TextField.onChanged` (892-895) and `_PhoneInputFormatter.formatEditUpdate` (1079, 1082, called by Flutter on every keystroke) each construct a fresh `RegExp(r'\D')` instead of reusing one compiled pattern — the formatter builds it twice per call. Additionally, lines 1109-1110 contain `if (digitIdx == text.length && i < country.phoneGroupSeparators.length - 1) {}` — an empty-bodied `if` with no effect, left over from an incomplete edit, which makes the group-separator loop harder to trust. + + +**Решение:** Hoist a single `static final RegExp _nonDigit = RegExp(r'\D');` and reuse it in both the formatter and the onChanged handler; delete the empty dead `if` statement. + +
+ +
+🟡 LOW · ОПТ · [S] — Country search re-lowercases every country name on every keystroke + + +**Где:** `lib/frontend/screens/auth/select_country_screen.dart:37-50` + + +**Проблема:** `_filterCountries` runs `c.ru.toLowerCase()` and `c.en.toLowerCase()` for every country on every keystroke; these fields never change, so the same strings are lower-cased and reallocated repeatedly instead of once. Modest given the bounded country-list size, but it is wasted allocation on the search path that scales with list length times keystrokes. + + +**Решение:** Precompute lowercase `ru`/`en` (and phoneCode) once in `initState` into a cached search-key list, or cache them on `CountryName`, and filter against the cached keys. + +
+ +
+🟡 LOW · ОПТ · [S] — Schedule-time picker eagerly materializes all 366 day items and rebuilds every wheel on any wheel's scroll + + +**Где:** `lib/frontend/widgets/schedule_time_picker.dart:217-241`, `lib/frontend/widgets/schedule_time_picker.dart:150-179` + + +**Проблема:** `_wheel` builds `CupertinoPicker(children: List.generate(count, ...))` with eagerly materialized widgets — 366 Align/Padding/Text for the day column (each computing `_dayLabel`). `onSelectedItemChanged` calls `setState(() => onChanged(i))` (line 226), which re-runs `_ScheduleSheetState.build()` and reconstructs all three wheels — including the full 366-item day list — every time the user crosses an item on any of the three wheels, not just the day wheel. The setState is needed to refresh the button label, but rebuilding the wheels' child lists is wasted. Confirmed. (Impact is real but bounded: it fires on discrete item changes, not per frame, and Text/Align construction is cheap — hence low.) + + +**Решение:** Switch `_wheel` to `CupertinoPicker.builder(itemBuilder: ..., childCount: count)` so items build lazily per visible index, and/or split each column into its own small stateful widget so scrolling one wheel doesn't rebuild the others' item lists. + +
+ + +### Lifecycle, dispose, and unbounded caches (14 — 0 high) + +_Notifiers, timers, temp files and in-memory caches are created but never released or bounded, leaking for the life of the process or the screen._ + +
+🟠 MED · КОСТЫЛЬ · [L] — ContactCache persists the entire cache as one SharedPreferences JSON blob with no eviction, bypassing the SQLite layer + + +**Где:** `lib/backend/modules/messages.dart:15-108` + + +**Проблема:** ContactCache holds three static Maps that grow with every distinct contact id for the process lifetime with no eviction (unlike FileHistoryCache in the same file, capped at _maxEntries=50 at lines 183/217). _save() unions all ids across all three maps and JSON-encodes the whole thing to a single prefs key on every 3s-debounced change, so the write cost grows O(n) with contact count, even though the app already has AppDatabase used for everything else message-related. + + +**Решение:** Back ContactCache with an AppDatabase table (contact_id, name, avatar, options) doing per-row upserts instead of a monolithic JSON blob, consistent with how CachedMessage persists; add an LRU cap or accept the bound of real contact count once persisted per-row. + +
+ +
+🟠 MED · КОСТЫЛЬ · [M] — MediaCache.getOrDownload has no in-flight de-duplication; concurrent requests for the same key race on a shared .part file + + +**Где:** `lib/core/utils/media_cache.dart:58-104` + + +**Проблема:** `getOrDownload` checks `existing(name)`, and if absent downloads to a fixed `.part` path (line 67) then `part.rename(file.path)` (line 85). There is no tracking keyed by `name`, so two concurrent callers for the same cache key (e.g. the same image attachment shown as both a reply preview and the message itself, or a rebuild re-triggering the fetch before the first completes) both open the same `.part` via `openWrite()`, interleave writes, and both rename the same file — producing a corrupted/partial cached file that later opens as broken media. + + +**Решение:** Maintain a `static final Map> _inFlight` keyed by cache name; when a download for the same name is already running, return the existing Future instead of starting a second write to the shared .part path. + +
+ +
+🟠 MED · КОСТЫЛЬ · [M] — VideoPlayerScreen quality switch lacks a generation guard, racing controller state on rapid switches + + +**Где:** `lib/frontend/widgets/video_player_screen.dart:36`, `lib/frontend/widgets/video_player_screen.dart:47`, `lib/frontend/widgets/video_player_screen.dart:56`, `lib/frontend/widgets/video_player_screen.dart:67` + + +**Проблема:** _load() captures `final old = _controller`, sets `_controller = controller`, then `await controller.initialize()` before mutating state. There is no `controller == _controller` (or monotonic token) check after the await. If _switchQuality runs twice in quick succession, the second call overwrites _controller while the first is still initializing; when the first resumes it calls seekTo/addListener/play on a controller that is no longer _controller — which the second call may already have disposed via its own `old?.dispose()` — leaving either an orphaned playing controller or a thrown PlatformException swallowed by `catch (_) { setState(() => _error = true); }`, so the user sees a spurious playback error even though the newest controller is fine. Triggering it requires rapid successive quality selections, so probability is moderate, but the fix is the correct pattern regardless. + + +**Решение:** Add an incrementing `_loadGeneration` int; capture it at the start of _load and, after every await, `if (generation != _loadGeneration) { await controller.dispose(); return; }` before touching _controller or calling further controller methods. This makes stale loads a no-op and removes the fragile old/current juggling. + +
+ +
+🟠 MED · КОСТЫЛЬ · [M] — Edited-photo temp files leak on sheet cancel; cleanup gated by a duplicated filename-prefix convention + + +**Где:** `lib/frontend/widgets/attachment/media_preview_screen.dart:105`, `lib/frontend/widgets/attachment/attachment_sheet.dart:73`, `lib/frontend/widgets/attachment/attachment_sheet.dart:100`, `lib/frontend/widgets/attachment/photo_editor.dart:389`, `lib/frontend/widgets/attachment/photo_editor.dart:1016`, `lib/frontend/widgets/attachment/photo_editor.dart:2336` + + +**Проблема:** Each _bake writes a JPEG into getTemporaryDirectory() named `komet_crop_/komet_edit_/komet_adj_.jpg`. `_disposeTemp` (media_preview_screen.dart:105) only deletes a file whose name starts with the hardcoded `'komet_'` and only when a *newer* edit supersedes an older one. If the user closes AttachmentSheet without sending, `_AttachmentSheetState.dispose()` (attachment_sheet.dart:100) never touches `_edits`, so every baked JPEG produced that session stays on disk until the OS clears the cache dir. The prefix string is also an implicit contract duplicated across three editors, so renaming a prefix in one silently disables its cleanup. + + +**Решение:** Track produced temp files explicitly instead of inferring ownership from a filename prefix: keep the baked File in a session-scoped Set in AttachmentSheet, and on dispose delete every tracked file that is not part of the final sent selection (the sent files are handed to onSend and must be preserved). This closes the cancel leak and removes the string-convention coupling. + +
+ +
+🟠 MED · ОПТ · [S] — In-memory caches never evict, growing for the life of the process (chiefly the message session cache) + + +**Где:** `lib/core/cache/info_cache.dart:25`, `lib/core/cache/info_cache.dart:137`, `lib/core/cache/message_session_cache.dart:11`, `lib/core/cache/message_session_cache.dart:18-29` + + +**Проблема:** InfoCache._entries (backing ContactInfoFetch/ChatInfoFetch/PresenceFetch) and PresenceFetch._live only grow via putIfAbsent/assignment; the only shrink path is a full clear() on logout. The material one is MessageSessionCache._store, which retains a full copied List (text, payload, attachments, edit history) per chat ever opened this session, uncapped. A long session browsing many chats accumulates full history for all of them. The InfoCache maps hold small maps so their growth is minor, but MessageSessionCache can be significant. + + +**Решение:** Bound MessageSessionCache with a simple LRU (access-order LinkedHashMap, evict oldest past a cap of ~20-30 recent chats). Optionally apply the same to InfoCache (~200 entries). This keeps memory bounded regardless of session length without changing call sites. + +
+ +
+🟠 MED · СОМНИТ · [M] — UploadManager exposes raw mutable UI callbacks and drops errors on the stream-level onError path + + +**Где:** `lib/backend/modules/upload_manager.dart:17-19`, `lib/backend/modules/upload_manager.dart:91-95` + + +**Проблема:** onProgress/onDone/onError are plain mutable fields on a process-wide singleton, set by whichever screen is currently mounted, with nothing enforcing that a screen clears them on dispose — a closure left in onProgress/onDone keeps the disposed State reachable and can fire into an unmounted widget, and two concurrent observers silently clobber each other. Separately, the top-level `.listen(..., onError: (_) { _sub = null; UploadNotificationService.stop(); })` (lines 91-95) never calls the public onError callback, unlike the UploadError case just above it (line 88) — any failure reaching this branch is invisible to the UI. + + +**Решение:** Replace the mutable callback fields with a proper subscription API — a broadcast Stream the manager exposes, or a register-listener method returning a disposer — so screens subscribe/unsubscribe deterministically in initState/dispose. Route the top-level stream onError into the same UI-facing error path used by the UploadError case so no failure is silently dropped. + +
+ +
+🟠 MED · СОМНИТ · [M] — App backgrounding does not stop active voice/video-note recording + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:988`, `lib/frontend/screens/chats/chat_screen.dart:5190` + + +**Проблема:** didChangeAppLifecycleState only calls _saveDraft on paused/inactive; it never checks _isRecordingVoice/_isRecordingNote nor stops the recorders. If the user is mid-recording (voice or video note) when the app is backgrounded (incoming call, notification shade, home button), the mic/camera session keeps running unmanaged and _isRecordingVoice/_isRecordingNote can desync from the native recorder on resume. Both _stopVoiceRecording({required bool cancel}) (4970) and _stopNoteRecording({required bool cancel}) (5243) already exist and release their resources. + + +**Решение:** In didChangeAppLifecycleState, on paused/inactive, forcibly stop any in-flight recording via _stopVoiceRecording(cancel: true) / _stopNoteRecording(cancel: true) (and dispose the note camera) so the native session and UI state stay consistent across the lifecycle transition. + +
+ +
+🟠 MED · СОМНИТ · [S] — Fire-and-forget Future.delayed writes to _highlightMessageId after it can be disposed, with no cancellation guard + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:3802`, `lib/frontend/screens/chats/chat_screen.dart:3920`, `lib/frontend/screens/chats/chat_screen.dart:1099` + + +**Проблема:** _jumpToMessage (3802-3807) and _scrollToLoadedMessage (3920-3925) both do `_highlightMessageId.value = messageId; Future.delayed(Duration(ms: 1400/1600), () { if (_highlightMessageId.value == messageId) _highlightMessageId.value = null; });` as fire-and-forget. `_highlightMessageId` is disposed unconditionally at dispose() line 1099 with no cancellation of these pending futures and no mounted check inside the callbacks. If the user pops ChatScreen within the 1.4-1.6s window, dispose() disposes the notifier, then the delayed callback assigns `.value = null`, which triggers notifyListeners on a disposed ChangeNotifier and throws a debug assertion (`used after being disposed`). Note: this fails only in debug builds — in release, ChangeNotifier.dispose zeroes the listener count so notifyListeners silently no-ops — so it is a development-time crash rather than a production one, but still a real latent bug and inconsistent with how _floatingDateTimer/_shimmerStartTimer are handled (they are stored as cancelable Timer fields and cancelled in dispose). + + +**Решение:** Store the delayed operation as a cancelable `Timer` field (`_highlightTimer?.cancel(); _highlightTimer = Timer(duration, () { if (!mounted) return; ... });`) and cancel it in dispose() before `_highlightMessageId.dispose()`, mirroring the existing timer handling in the same file. A leading `if (!mounted) return;` in both callbacks is a minimal alternative but the Timer-field approach matches convention. + +
+ +
+🟡 LOW · КОСТЫЛЬ · [S] — ComplaintsModule cache is never cleared on account switch, unlike sibling caches + + +**Где:** `lib/backend/modules/complaints.dart:11-50`, `lib/backend/modules/account.dart:1086-1088`, `lib/backend/modules/account.dart:1099-1101`, `lib/backend/modules/account.dart:1129-1131` + + +**Проблема:** beginAddAccount, loginWithToken and switchAccount all clear ContactCache, TranscriptionCache and ChatsModule state on switch, but ComplaintsModule's static `_cache` is never touched and the class exposes no clear()/reset(). Once populated it is served for every subsequently switched-to account. Impact is small in practice (complaint reasons are almost certainly global/locale-level rather than account-scoped), so this is mainly a cache-lifecycle consistency gap. + + +**Решение:** Add `ComplaintsModule.clear()` (set `_cache = null`) and call it alongside the other cache resets in beginAddAccount, loginWithToken and switchAccount to keep cache lifecycle uniform across modules. + +
+ +
+🟡 LOW · ОПТ · [M] — MediaDownloadProgress ValueNotifiers are never disposed or evicted + + +**Где:** `lib/core/utils/download_progress.dart:7-14`, `lib/frontend/widgets/message_bubble.dart:2664-2674` + + +**Проблема:** `MediaDownloadProgress._notifiers` is a static `Map>` that only ever grows via `putIfAbsent` in `notifier()`; nothing removes entries or calls `dispose()`. Callers (message_bubble.dart) set the value back to `null` on completion (line 2674) but never release the notifier, so one entry accumulates per distinct cache key ever viewed. Over a long-lived session scrolling many attachments this grows unbounded. Impact is small (each entry is tiny), so low severity, but it is a genuine unbounded-growth pattern for objects only meaningful during an in-flight download. + + +**Решение:** Add a `MediaDownloadProgress.release(key)` that disposes the notifier and removes the map entry when it has no listeners, called from message_bubble after the download settles (alongside the existing `set(cacheName, null)`). + +
+ +
+🟡 LOW · ОПТ · [S] — _messageKeys map grows without bound for the life of the screen + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:3810`, `lib/frontend/screens/chats/chat_screen.dart:4715` + + +**Проблема:** _keyForMessage lazily allocates and caches a GlobalKey per message id in _messageKeys, used as the KeyedSubtree key for every rendered bubble (call sites at 3791, 3933, 4715). Unlike _separatorKeys (pruned every _buildCombinedItems call at line 4228) or the reaction/upload notifiers (disposed on prune), _messageKeys is never pruned, cleared, or dropped in dispose(). Across a long session with repeated _loadMoreHistory pagination it accumulates one entry per message ever loaded. The leak is small per entry, but it is an unbounded, inconsistent-with-siblings growth. + + +**Решение:** Prune _messageKeys alongside the reaction-notifier prune (drop ids no longer in _messages), and clear it in dispose(). + +
+ +
+🟡 LOW · ОПТ · [S] — GlobalKey map for messages is never pruned + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:3810-3813`, `lib/frontend/screens/chats/chat_screen.dart:396-404` + + +**Проблема:** `_messageKeys` is only ever added to via `putIfAbsent` in `_keyForMessage`, never pruned — unlike `_reactionNotifiers`, which has a matching `_pruneReactionNotifiers()` (lines 396-404) removing ids no longer in `_messages`. Over a long session with deep pagination, cleared history or many deletions, the map keeps a `GlobalKey` for every id ever seen, e.g. after `_clearHistory` empties `_messages` the keys remain. Minor unbounded retention. + + +**Решение:** Prune `_messageKeys` the same way `_pruneReactionNotifiers()` does — drop entries whose id is no longer in `_messages` when the list changes, and at minimum on `_clearHistory`. + +
+ +
+🟡 LOW · СОМНИТ · [M] — Gallery items/permission cached as static mutable fields on a private widget State + + +**Где:** `lib/frontend/widgets/attachment/attachment_sheet.dart:68`, `lib/frontend/widgets/attachment/attachment_sheet.dart:69`, `lib/frontend/widgets/attachment/attachment_sheet.dart:122` + + +**Проблема:** _AttachmentSheetState declares `static List? _cachedItems` and `static GalleryPermission _cachedPermission`, a process-lifetime cache living inside a private widget State rather than in the media layer. It is refreshed (each open does a silent _loadGallery), so staleness is limited, but the cache is invisible to and unreusable by any other screen that wants gallery data, and its lifetime/invalidation belong architecturally in core/media/gallery_source.dart, not hanging off a widget's static state. + + +**Решение:** Move the cache into GallerySource (or a small dedicated repository) as an instance-level cache with explicit invalidate()/TTL, exposed the same way other core state is exposed to widgets, instead of static state on a widget's private State class. + +
+ +
+🟡 LOW · СОМНИТ · [S] — setState after awaiting a confirm dialog with no mounted check in PerformanceScreen + + +**Где:** `lib/frontend/screens/profile/performance_screen.dart:53`, `lib/frontend/screens/profile/performance_screen.dart:69` + + +**Проблема:** _onChangeEnd awaits a modal confirm dialog (`await _showWarning(...)`, which returns showConfirmDialog) and, when the user declines, calls `setState(() => _value = _preZoneValue);` directly at lines 53 and 69 with no mounted guard. The mounted-after-await convention is widespread across the profile screens (many use `if (!mounted) return;`), so these two sites are inconsistent. If the underlying route is popped while the dialog is still open, the eventual setState throws `setState() called after dispose()`. The realistic trigger is narrow because the dialog is modal and sits on top of the screen, so this is a defensive/consistency fix rather than a likely crash. + + +**Решение:** Guard both call sites with `if (!mounted) return;` before setState, matching the pattern already used across the profile screens. + +
+ + +### Duplicated UI widgets and layout (25 — 2 high) + +_Settings rows, confirm/prompt dialogs, bottom-sheet chrome, avatars, spinners, headers, and overlay lifecycles are re-implemented per screen instead of extracted, and copies have drifted._ + +
+🔴 HIGH · ДУБЛЬ · [M] — Every persisted setting reimplements the same load/save/ValueNotifier boilerplate — and inconsistently + + +**Где:** `lib/core/config/app_amoled.dart:4-18`, `lib/core/config/app_bubble_shape.dart:6-22`, `lib/core/config/app_bubble_behavior.dart:6-22`, `lib/core/config/app_message_actions_style.dart:6-21`, `lib/core/config/app_theme_mode.dart:6-21`, `lib/core/config/app_visual_style.dart:6-25`, `lib/core/config/app_chat_chrome.dart:6-42`, `lib/core/config/app_icon.dart:19-52`, `lib/main.dart:131-196` + + +**Проблема:** ~17 config classes independently hand-roll the identical shape: a pref-key string, a `static final ValueNotifier current`, a `static Future load()` that reads SharedPreferences, and a `static Future save(T)` that writes and flips `current.value`. The contract is inconsistent: `AppIconConfig.load()` (app_icon.dart:29-34) self-assigns `current.value`, but every other class's `load()` only returns the stored value and relies on main.dart to also assign it. Verified: main.dart:131-148 is ~18 near-identical `final xFuture = X.load()` lines and 179-196 is the matching ~18 `X.current.value = await xFuture` lines. Miss one of those pairs and that toggle silently keeps its default forever regardless of what the user saved, with no compiler or runtime signal. + + +**Решение:** Introduce one generic `PersistedSetting` (bool/int/double via a getter/setter pair on SharedPreferences) and one `PersistedEnum` in core/config that own the key, default, ValueNotifier, and a `load()` that always self-assigns `current.value` before returning. Replace each ad hoc class body with a one-line instance, e.g. `final appAmoled = PersistedSetting('app_amoled', false);`. Load becomes fire-and-forget (`unawaited(appAmoled.load())`), removing the main.dart load/assign pairing and eliminating the 'forgot to sync current' failure mode by construction. + +
+ +
+🔴 HIGH · ДУБЛЬ · [M] — WebAppScreen and DigitalIdWebScreen are near-complete duplicates of the same WebView screen + + +**Где:** `lib/frontend/screens/webapp/web_app_screen.dart:24-143`, `lib/frontend/screens/digital_id/digital_id_web_screen.dart:175-343` + + +**Проблема:** _WebAppScreenState and _DigitalIdWebScreenState carry identical fields (_controller, _launch, _loadError, _userAgent, _progress) and identical _load()/_handleBack()/build()/PopScope/AppBar-with-progress-bar/InAppWebViewSettings/onProgressChanged/onReceivedError wiring. DigitalIdWebScreen only adds initialUserScripts, a closeWebApp JS handler, debug console/loadStart hooks, and a custom shouldOverrideUrlLoading. Confirmed verbatim in both files. Any future change to WebView setup (cookies, permission prompts, progress handling, back navigation) has to be applied twice and will drift. + + +**Решение:** Give WebAppScreen optional hook parameters (extraUserScripts, onWebViewCreated, shouldOverrideUrlLoading, onConsoleMessage/onLoadStart for debug) and have DigitalIdWebScreen construct a WebAppScreen with those hooks instead of re-implementing the whole screen. + +
+ +
+🟠 MED · ДУБЛЬ · [M] — Five near-identical optimistic-upload send flows duplicate the same lifecycle + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:5060`, `lib/frontend/screens/chats/chat_screen.dart:5272`, `lib/frontend/screens/chats/chat_screen.dart:6133`, `lib/frontend/screens/chats/chat_screen.dart:6239`, `lib/frontend/screens/chats/chat_screen.dart:6350` + + +**Проблема:** _sendVoice, _sendVideoNote, _sendPhotos, _sendVideo and _sendScheduledPhotos each reimplement the same control flow: allocate tempId, insert optimistic CachedMessage with a 'sending' status and an attachment, haptics + scroll-to-bottom, request an upload URL, upload with a progress ValueNotifier, call the module send method, swap the temp message for the real one via CachedMessage.fromPushPayload or mark it error, dispose the progress notifier, delete the local temp file. Only the attachment type, upload endpoint, and module call differ. Error/dispose handling is copy-pasted and has already drifted (e.g. _sendPhotos lacks the file.delete() cleanup the others have; scheduled variants diverge further). The generic _sendAttachMessage (line 6420) already owns the post-send replace/error lifecycle but does not cover the upload phase, so none of these five reuse it. + + +**Решение:** Extend the _sendAttachMessage-style helper to take an optional upload step (a Future Function(ValueNotifier> progress) plus a List Function(String token) builder) and route all five paths through it, so upload, progress-notifier disposal, temp-file cleanup and error marking live in one place and can't drift per media type. + +
+ +
+🟠 MED · ДУБЛЬ · [M] — Header row tree duplicated wholesale for glossy vs. material chrome + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:2427-2603`, `lib/frontend/screens/chats/chat_screen.dart:2605-2748`, `lib/frontend/screens/chats/chat_screen.dart:2357-2425`, `lib/frontend/screens/chats/chat_screen.dart:2750-2865` + + +**Проблема:** `_glossyHeaderRow` and `_materialHeaderRow` rebuild the identical structure — back button + unread badge, avatar + online dot, name + verified icon + status text, schedule/call/more-menu cluster — twice, differing only in whether each piece is wrapped in a `GlossyPill` and in paddings/sizes/icon weights. The same glossy/non-glossy duplication recurs in `_searchTopBar` and `_selectionTopBar`. Any header change (icon, badge, label) must be made in up to four places and will silently drift. + + +**Решение:** Extract one parameterized builder that constructs the row content once and takes a small style descriptor (e.g. a `Widget Function(Widget child)` chrome wrapper plus size/weight tokens), so glossy vs. material only changes how pieces are wrapped, not the whole tree. Apply the same pattern to the search and selection bars. + +
+ +
+✅ ЗАКРЫТО С11 · 🟠 MED · ДУБЛЬ · [S] — Bespoke _Avatar in create_group_flow.dart duplicates KometAvatar but drops its memCache sizing, decoding every contact avatar at full source resolution + +_✅ С11: `_Avatar` удалён, оба сайта (40px/24px) → `KometAvatar` (memCache-фикс применён); снят неисп. cached_network_image import. Inherent: KometAvatar даёт bold-инициал и не показывает букву во время загрузки._ + + +**Где:** `lib/frontend/screens/chats/create_group_flow.dart:507-553`, `lib/frontend/widgets/komet_avatar.dart:40-56` + + +**Проблема:** _Avatar (used for the 40px contact-list rows at line 319 and the 24px selected chips at line 580) re-implements CachedNetworkImage-with-initials-fallback that KometAvatar already provides, but without KometAvatar's memCacheWidth/memCacheHeight (which it sets to size*3). Every contact avatar in this sheet is therefore decoded and held in memory at full source resolution instead of the ~24-40px display size, multiplying memory/CPU cost across what is often a long contact list. + + +**Решение:** Delete _Avatar and use KometAvatar(name: _displayName(c), imageUrl: contact.baseUrl, size: ...) directly, as search_screen.dart already does via _ResultTile. + +
+ +
+🟠 MED · ДУБЛЬ · [S] — Message-status icon mapping duplicated three times + + +**Где:** `lib/frontend/widgets/message_bubble.dart:2782`, `lib/frontend/widgets/message_bubble.dart:2419`, `lib/frontend/widgets/message_bubble.dart:2941` + + +**Проблема:** `MessageBubble._buildStatusIcon` (2782), `MessageBubble._buildStickerStatusIcon` (2419) and `_VoiceMessageBubbleState._buildStatusIcon` (2941) each re-implement the same switch over the status string (sending/pending -> schedule, sent/null -> check, delivered -> done_all dim, read -> done_all `0xFF4FC3F7`, error -> error/redAccent). Only the dim/read color source differs (ctx.dim vs Colors.white vs onPrimaryContainer alpha); the read color and error color are identical literals in all three. A new status value or semantic change must be edited in three places or the UI drifts between text bubbles, stickers and voice notes. + + +**Решение:** Extract one pure helper, e.g. `({IconData icon, Color color}) messageStatusVisual(String? status, {required Color dimColor, required Color readColor, required Color errorColor})`, and have all three call sites build their `Icon` from its result with their own dim color. + +
+ +
+🟠 MED · ДУБЛЬ · [M] — Contact-card rendering duplicated between direct and forwarded contacts, with an existing inconsistency + + +**Где:** `lib/frontend/widgets/message_bubble.dart:2450`, `lib/frontend/widgets/message_bubble.dart:2537` + + +**Проблема:** `_buildContactAttachment` (2450-2535) and `_buildForwardedContactContent` (2537-2637) are ~90 lines of near-identical code: same firstName/lastName/fallback name-building, same 48px avatar Container + ClipRRect + CachedNetworkImage with `Icon(Symbols.person)` fallback, same phone-number row. The only real difference is the forwarded variant prepends `_buildForwardedHeader`. The copy has already drifted: the direct variant sets `memCacheWidth: 144`/`memCacheHeight: 144` (lines 2487-2488) but the forwarded variant's CachedNetworkImage (2580-2583) omits them, so forwarded contact avatars decode at full resolution. + + +**Решение:** Factor out a single `_buildContactCard(_BubbleCtx ctx, {String? firstName, String? lastName, String? name, String? photoUrl, String? phoneNumber})` used by both, with the forwarded path wrapping it in a Column that prepends `_buildForwardedHeader`. This also fixes the missing memCache sizing. + +
+ +
+🟠 MED · ДУБЛЬ · [M] — Eleven near-identical inline toggle-row blocks in DebugMenuScreen.build() + + +**Где:** `lib/frontend/screens/profile/debug_menu_screen.dart:417-478`, `lib/frontend/screens/profile/debug_menu_screen.dart:479-542`, `lib/frontend/screens/profile/debug_menu_screen.dart:543-665`, `lib/frontend/screens/profile/debug_menu_screen.dart:731-903`, `lib/frontend/screens/profile/debug_menu_screen.dart:964-1200` + + +**Проблема:** Each toggle row (FPS overlay, VPN bypass, offline-test, TLS-insecure, swipe-back, pranks, digital-ID-native, stories, commands, link-preview, extra-info) is a ~50-line copy-pasted SliverToBoxAdapter > Padding > ValueListenableBuilder > GlossyPill > Row(Icon, title/subtitle Column, Switch) block differing only in icon, strings and the ValueListenable/setter. Verified the first two blocks are byte-for-byte structurally identical. This is a large, real maintenance-cost duplication (any styling change needs eleven edits). Note: the perf angle is minor since each row's ValueListenableBuilder already isolates its own switch state; the concern is duplication/maintainability, not rebuild cost. + + +**Решение:** Extract one reusable _DebugToggleTile({icon, title, subtitle, valueListenable, onChanged}) widget (or a data-driven list of tile descriptors rendered via .map) and replace all eleven call sites, removing several hundred lines of duplication. + +
+ +
+🟠 MED · ДУБЛЬ · [M] — Four parallel settings-row builder methods reimplement the same InkWell/Row/Divider layout + + +**Где:** `lib/frontend/screens/profile/security_screen.dart:842-930`, `lib/frontend/screens/profile/security_screen.dart:932-992`, `lib/frontend/screens/profile/security_screen.dart:994-1045`, `lib/frontend/screens/profile/security_screen.dart:1047-1097` + + +**Проблема:** _buildNavRow, _buildOptionRow, _buildSubRow and _buildSwitchRow all build the same Column > Material > InkWell > Padding > Row(icon?, label/subtitle Column, trailing/value/switch) > conditional Divider skeleton, differing only in which optional parts are present. Confirmed all four verbatim. The same skeleton is re-derived independently in the debug_menu toggle blocks. + + +**Решение:** Consolidate into one configurable SettingsRow widget under frontend/widgets/ taking optional icon, label, subtitle, trailingText, trailingWidget, isLast and onTap, and use it from all four call sites (and reuse from the debug/password screens). + +
+ +
+🟠 MED · ДУБЛЬ · [M] — Loading-state FilledButton pattern duplicated five times in password_entry_screen + + +**Где:** `lib/frontend/screens/profile/password_entry_screen.dart:272-295`, `lib/frontend/screens/profile/password_entry_screen.dart:701-724`, `lib/frontend/screens/profile/password_entry_screen.dart:1095-1119`, `lib/frontend/screens/profile/password_entry_screen.dart:1298-1322`, `lib/frontend/screens/profile/password_entry_screen.dart:1432-1456` + + +**Проблема:** Each sub-screen repeats an identical ValueListenableBuilder(...) => FilledButton(onPressed: loading ? null : action, style: FilledButton.styleFrom(primary bg/onPrimary fg, vertical 16 padding, 12 radius), child: loading ? SizedBox(20x20 CircularProgressIndicator strokeWidth 2) : Text(...)) block, varying only in label and the loading ValueNotifier (first uses _isVerifying, rest _isLoading). Confirmed at the first two sites verbatim. + + +**Решение:** Extract a shared PrimaryLoadingButton({required ValueListenable loading, required VoidCallback? onPressed, required Widget child, Color? background}) under frontend/widgets and replace all five inline copies. + +
+ +
+🟠 MED · ДУБЛЬ · [M] — Settings card/divider/toggle-row layout is copy-pasted across three screens + + +**Где:** `lib/frontend/screens/profile/settings_tab.dart:693-771`, `lib/frontend/screens/profile/notifications_screen.dart:229-308`, `lib/frontend/screens/profile/komet_settings_screen.dart:107-181` + + +**Проблема:** settings_tab.dart (`_buildSection`/`_buildSettingsRow`), notifications_screen.dart (`_card`/`_divider`/`_toggleRow`) and komet_settings_screen.dart (`_card`/`_divider`/`_toggle`) each independently define the same GlossyPill-wrapped Column of InkWell rows with the identical 58px-inset divider and the same horizontal-20 padding. Any visual tweak must be applied in three places, and the copies have already diverged: notifications_screen's `enabled`/`AnimatedOpacity`/`IgnorePointer` disabled-state handling is missing from komet_settings_screen's otherwise-identical toggle. + + +**Решение:** Extract a shared widget set into frontend/widgets/ (e.g. `SettingsCard`, `SettingsToggleTile`, `SettingsNavTile`) parameterized by icon/leading, label, optional subtitle, trailing (chevron or Switch), enabled flag, and onTap/onChanged. Replace all three local implementations. + +
+ +
+🟠 MED · ДУБЛЬ · [M] — Selectable radio-tile widget is redefined near-identically in three customization screens + + +**Где:** `lib/frontend/screens/profile/theme_settings_screen.dart:103-162`, `lib/frontend/screens/profile/message_actions_screen.dart:106-174`, `lib/frontend/screens/profile/app_icon_screen.dart:113-170` + + +**Проблема:** `_ModeTile`, `_StyleTile` and `_IconTile` build the same pattern: a leading icon/image, a label (+ optional description) and a trailing `Symbols.radio_button_checked`/`unchecked` inside an InkWell with the same borderRadius(16) and padding(8,12). Only the leading widget and presence of a subtitle differ; `_ModeTile` is additionally stateful only to capture the tap position for the reveal animation. + + +**Решение:** Factor out a single `SettingsRadioTile` taking a `leading` widget, `label`, optional `description`, `selected`, and an `onTap`/`onTapDown` callback (to preserve theme_settings_screen's tap-position capture). Instantiate it in all three screens. + +
+ +
+🟠 MED · ДУБЛЬ · [S] — Edit-profile avatar reimplements KometAvatar without caching or error fallback + + +**Где:** `lib/frontend/screens/profile/edit_profile_screen.dart:202-234`, `lib/frontend/widgets/komet_avatar.dart:1-58`, `lib/frontend/screens/profile/settings_tab.dart:574-590` + + +**Проблема:** edit_profile_screen.dart hand-builds the avatar-with-initials-fallback using a raw ClipOval + `Image.network(_avatarUrl!, fit: BoxFit.cover)`, duplicating what `KometAvatar` already does and what settings_tab.dart uses for the same profile picture (settings_tab.dart:584). Unlike KometAvatar, this copy has no CachedNetworkImage/memCacheWidth/memCacheHeight (re-downloads and re-decodes full-resolution on every rebuild instead of using the cache) and no errorWidget, so a failed load renders Flutter's default red error box instead of falling back to the initial-letter placeholder. + + +**Решение:** Replace the hand-rolled ClipOval/Image.network block with `KometAvatar(name: ..., imageUrl: _avatarUrl, size: 88, fontSize: 32)`, keeping only the camera-button overlay as screen-specific chrome. + +
+ +
+🟠 MED · ДУБЛЬ · [M] — Reconnect-and-report pattern duplicated four times across the two settings sheets, with drift + + +**Где:** `lib/frontend/screens/auth/proxy_settings_sheet.dart:44-98`, `lib/frontend/screens/auth/server_settings_sheet.dart:45-105` + + +**Проблема:** `_apply`/`_disable` in `ProxySettingsSheet` and `_apply`/`_resetToDefault` in `ServerSettingsSheet` each independently: set `_busy`, persist config, `api.disconnect()` then reconnect, then report success/failure via `l10n.xxxSettingsSaved`/`l10n.serverReconnectFailed`. The copies have drifted in how they observe the reconnect result: `ProxySettingsSheet` does `await api.connect()` and then immediately reads `api.state == SessionState.online`, whereas `ServerSettingsSheet` waits on `api.stateStream.firstWhere(...)` with a 15s timeout. If `api.connect()` returns before the session actually transitions to online, the proxy path reports success/failure from a premature `api.state` read while the server path reports the real outcome. + + +**Решение:** Extract one `Future applyConnectionChange(Future Function() persist)` helper (in api.dart or a shared settings helper) that does persist/disconnect/connect/wait-on-stream-with-timeout/return-result once, and have all four call sites use it with only the persist step varying. + +
+ +
+✅ ЗАКРЫТО С11 · 🟠 MED · ДУБЛЬ · [S] — _ErrorView widget copy-pasted verbatim in three screens + +_✅ С11: `widgets/error_view.dart` (`ErrorView`); три локальных `_ErrorView` удалены (web_app/digital_id_web/digital_id)._ + + +**Где:** `lib/frontend/screens/webapp/web_app_screen.dart:145-177`, `lib/frontend/screens/digital_id/digital_id_web_screen.dart:346-375`, `lib/frontend/screens/digital_id/digital_id_screen.dart:508-540` + + +**Проблема:** The same private _ErrorView class (cloud_off icon, message text, 'Повторить' FilledButton, identical padding/typography) is defined three separate times with identical bodies. Confirmed identical in all three files. + + +**Решение:** Extract a single shared ErrorView widget into lib/frontend/widgets/ and have all three screens import it. + +
+ +
+✅ ЗАКРЫТО С11 · 🟠 MED · ДУБЛЬ · [S] — chat_screen.dart reimplements a confirm dialog instead of using the shared showConfirmDialog + +_✅ С11: `_showConfirmDialog` удалён; `_clearHistory`/`_deleteChat` → `showConfirmDialog(..., destructive: true)`, `confirmed != true`→`!confirmed`. Импорт confirm_dialog.dart добавлен._ + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:2968-2994 (_showConfirmDialog)`, `lib/frontend/screens/chats/chat_screen.dart:2997-3003 (_clearHistory call site)`, `lib/frontend/screens/chats/chat_screen.dart:3024-3028 (_deleteChat call site)`, `lib/frontend/widgets/confirm_dialog.dart:4-44 (existing showConfirmDialog)` + + +**Проблема:** chat_screen.dart does not import confirm_dialog.dart and instead defines a private `_showConfirmDialog({title, body, confirmLabel})` (2968-2994) that rebuilds the same AlertDialog structure the shared `showConfirmDialog` already provides. The two differ visually: the private one omits the shared 24px RoundedRectangleBorder shape and renders the destructive action as a plain TextButton tinted cs.error, whereas showConfirmDialog uses a FilledButton.tonal with errorContainer/onErrorContainer. Result is a second, subtly different confirm-dialog look for clear-history and delete-chat, out of step with every other screen. + + +**Решение:** Delete `_showConfirmDialog` and call the shared helper at both sites: `showConfirmDialog(context, title: ..., message: body, confirmLabel: ..., destructive: true)`. Note the shared param is `message` (not `body`) and it returns a non-null `bool`, so change the call sites from `confirmed != true` to `!confirmed`. + +
+ +
+🟠 MED · ДУБЛЬ · [S] — security_screen.dart hand-rolls _showHiddenStatusSheet instead of reusing its own _showOptionSheet + + +**Где:** `lib/frontend/screens/profile/security_screen.dart:524-592 (_showOptionSheet generic helper)`, `lib/frontend/screens/profile/security_screen.dart:594-651 (_showHiddenStatusSheet)` + + +**Проблема:** `_showOptionSheet` (524-592) is a generic helper that takes title/currentValue/options/onSelect and renders the full showModalBottomSheet + SafeArea + Column + SheetGrabber + title + selectable rows sheet. `_showHiddenStatusSheet` (594-651), a few lines below, needs exactly a title + two selectable options with a checkmark, but re-implements the entire sheet chrome by hand (using per-row `_buildOptionSheetItem` calls) rather than delegating to `_showOptionSheet`. ~40-50 duplicated layout lines in one file. + + +**Решение:** Route `_showHiddenStatusSheet` through `_showOptionSheet` with `options: [('CONTACTS','Мои контакты'), ('NONE','Никто')]` and an `onSelect` that maps CONTACTS to `_updateSetting('HIDDEN', false)` and NONE to `_showHiddenStatusConfirmDialog`. onSelect fires after the sheet pops, so the confirm-dialog flow is preserved. + +
+ +
+🟠 MED · ДУБЛЬ · [S] — Shared kSheetShape constant ignored — identical top-24px sheet shape re-typed at 7+ call sites (and a divergent 20px in security_screen) + + +**Где:** `lib/frontend/widgets/sheet_helpers.dart:4-6 (kSheetShape definition)`, `lib/frontend/screens/calls/komet_hub.dart:20`, `lib/frontend/screens/chats/scheduled_messages_screen.dart:88`, `lib/frontend/widgets/info_action_sheet.dart:51`, `lib/frontend/widgets/schedule_time_picker.dart:30`, `lib/frontend/widgets/web_qr_login.dart:12`, `lib/frontend/screens/chats/chat_screen.dart:1884`, `lib/frontend/screens/calls/call_screen.dart:402`, `lib/frontend/screens/profile/security_screen.dart:535 (20px variant)`, `lib/frontend/screens/profile/security_screen.dart:616 (20px variant)` + + +**Проблема:** kSheetShape exists to standardize the top-24px rounded bottom-sheet shape and is used correctly in ~8 screens (login_screen, settings_tab, poll_create_screen, contacts_tab, chat_list_screen, create_group_flow, debug_menu_screen). Yet the listed call sites re-type the identical literal `RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(24)))`. web_qr_login.dart already imports sheet_helpers.dart (line 5, for SheetGrabber) but still inlines the shape on line 12. security_screen.dart also imports it (line 12) yet inlines a 20px variant at lines 535 and 616, producing a visibly different corner radius from the rest of the app. (sticker_pack_sheet.dart:144, chat_wallpaper_sheet.dart:51, and attachment_sheet.dart:199 inline it too, reinforcing the pattern.) + + +**Решение:** Use `shape: kSheetShape` at all listed 24px call sites. For security_screen's two 20px sheets, align to 24 (kSheetShape) unless a compact variant is intended — if it is, add a named `kCompactSheetShape` constant so the radius is still centrally defined rather than inlined. + +
+ +
+🔧 ЧАСТИЧНО С11 · 🟠 MED · ДУБЛЬ · [M] — Four hand-rolled single-line text-input AlertDialogs should share one prompt helper + +_🔧 С11: `widgets/prompt_dialog.dart` (`showTextInputDialog`, слот `description`, владеет controller+dispose); devices `_showPasteQrDialog` и font_settings `_showAddFontDialog` делегированы (закрыт leak в font). ⏭️ Осталось: password_entry `_promptPassword`, photo_editor `_addText` (dark-themed — параметризовать) → С12._ + + +**Где:** `lib/frontend/screens/profile/devices_screen.dart:66-113 (_showPasteQrDialog)`, `lib/frontend/screens/profile/password_entry_screen.dart:60-89 (_promptPassword)`, `lib/frontend/screens/profile/font_settings_screen.dart:79-135 (_showAddFontDialog)`, `lib/frontend/widgets/attachment/photo_editor.dart:900-934 (_addText)` + + +**Проблема:** Each independently builds a TextEditingController + showDialog + AlertDialog with a title, one autofocus TextField (onSubmitted popping the value), a Cancel TextButton and a Confirm FilledButton/TextButton. Only labels/hints/styling vary. Three of the four dispose the controller in a finally block; font_settings_screen._showAddFontDialog (79-135) never disposes its controller at all — a real leak a shared helper would eliminate. photo_editor._addText additionally hardcodes a dark theme (Color(0xFF1E1E1E), Colors.white) instead of ColorScheme, which a themed helper could parameterize. + + +**Решение:** Add `Future showTextInputDialog(BuildContext context, {String? title, String? hint, String confirmLabel, String cancelLabel, bool obscureText, int maxLines})` alongside showConfirmDialog (or a new prompt_dialog.dart) that owns controller creation and disposal and returns the trimmed value, then delegate all four call sites to it. This also closes the font_settings_screen controller leak. + +
+ +
+✅ ЗАКРЫТО С11 · 🟡 LOW · ДУБЛЬ · [S] — Identical spinner and 'baking' overlay widgets duplicated verbatim across four screens + +_✅ С11: `widgets/small_spinner.dart` (`SmallSpinner`+`BusyOverlay`); sticker_panel/sticker_pack_sheet → SmallSpinner; photo_editor ×2 baking-overlay → BusyOverlay._ + + +**Где:** `lib/frontend/widgets/sticker_panel.dart:166`, `lib/frontend/widgets/sticker_pack_sheet.dart:166`, `lib/frontend/widgets/attachment/photo_editor.dart:1040`, `lib/frontend/widgets/attachment/photo_editor.dart:2377` + + +**Проблема:** The same `SizedBox(width: 26, height: 26, child: CircularProgressIndicator(strokeWidth: 2.4, color: cs.primary))` appears verbatim in sticker_panel.dart and sticker_pack_sheet.dart, and the identical full-screen baking overlay `Positioned.fill(child: ColoredBox(color: Colors.black54, child: Center(child: CircularProgressIndicator(color: Colors.white))))` appears verbatim in PhotoDrawEditor (1040) and PhotoAdjustEditor (2377). + + +**Решение:** Extract a shared SmallSpinner({Color? color}) and a shared BusyOverlay({required bool visible}) widget used by all four call sites so any future visual tweak is made once. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — Duplicated labeled-TextField builder between the two settings sheets + + +**Где:** `lib/frontend/screens/auth/proxy_settings_sheet.dart:246-294`, `lib/frontend/screens/auth/server_settings_sheet.dart:173-219` + + +**Проблема:** `_buildTextField` in both `_ProxySettingsSheetState` and `_ServerSettingsSheetState` is essentially identical: a label `Text`, then a `TextField` with the same `fillColor`, `OutlineInputBorder`, and `contentPadding` (proxy's only extra is an `obscureText` param). Any visual tweak to the settings-sheet input style has to be made twice, and a design change applied to one sheet is easily forgotten in the other. + + +**Решение:** Extract a shared `LabeledSettingsField` widget in frontend/widgets/ taking controller/label/hint/keyboardType/obscureText/inputFormatters, and use it from both sheets instead of two private copies. + +
+ +
+🟡 LOW · ДУБЛЬ · [M] — Overlay-popup lifecycle skeleton copy-pasted between account switcher and chat menu + + +**Где:** `lib/frontend/widgets/account_switcher_overlay.dart:119-135`, `lib/frontend/widgets/account_switcher_overlay.dart:233-241`, `lib/frontend/widgets/chat_menu_overlay.dart:70-100` + + +**Проблема:** `_AccountSwitcherLayerState` and `_ChatMenuLayerState` hand-roll the identical overlay-popup skeleton: an `AnimationController` + `CurvedAnimation` built in initState with the same easeOutCubic/easeInCubic pair and forward-on-mount, a `_closing` guard flag, and a `_close()` that does `try { await _animController.reverse(); } catch (_) {}` then calls `widget.onDismiss()`. The guarded reverse-then-dismiss (including the empty catch that swallows the reverse TickerFuture error on interruption/disposal) is duplicated verbatim, so a fix there needs doing twice and a third overlay will likely copy it again. Confirmed identical in both files. (The bodies otherwise differ substantially — pointer-routing/geometry vs tap items — so only the lifecycle skeleton is shared.) + + +**Решение:** Factor the shared lifecycle into an `AnimatedOverlayPopup` mixin/base StatefulWidget that owns the controller, the forward-on-mount, and the guarded reverse-then-dismiss, exposing a content builder and `onDismiss`. Both layers become thin subclasses supplying only their own content and hit-testing. + +
+ +
+🟡 LOW · ДУБЛЬ · [M] — Two drifting implementations of drag-to-dismiss with inconsistent velocity units + + +**Где:** `lib/frontend/widgets/swipe_route.dart:127-236`, `lib/frontend/widgets/swipe_to_pop.dart:53-90` + + +**Проблема:** `SwipeRoute`'s `_SwipeBackGestureDetector`/`_SwipeBackController` and the standalone `SwipeToPop` widget both re-implement the drag-to-dismiss decision tree: measure track width via `context.size`/`MediaQuery`, feed a `RightwardDragRecognizer`, accumulate `primaryDelta/width`, and on drag-end decide complete-vs-rewind from a distance/velocity threshold. The two already disagree on units: SwipeRoute normalizes velocity as `pixelsPerSecond.dx / width` and compares to `_kMinFlingVelocity = 1.0` (swipe_route.dart:145,197), while SwipeToPop compares the raw `pixelsPerSecond.dx` to `velocityThreshold = 700` (swipe_to_pop.dart:66-68), and the distance thresholds differ (0.5 vs 0.35). So the two swipe-back gestures already feel different and tuning must be done twice. Confirmed. (Note: they are structurally distinct — SwipeRoute drives the PageRoute's own controller, essentially reimplementing Flutter's private _CupertinoBackGestureController, while SwipeToPop owns its controller and Transform.translates a child — so a full merge is non-trivial.) + + +**Решение:** Extract a shared threshold/decision helper (width-normalized velocity + distance fling-vs-rewind rule) that both gestures call, so at minimum the units and thresholds can't diverge, even if the two keep their own animation sinks (route controller vs local controller). + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — Avatar image + initials fallback duplicated with inconsistent cache sizing and no error fallback in hero flight + + +**Где:** `lib/frontend/widgets/komet_avatar.dart:40-56`, `lib/frontend/widgets/avatar_hero.dart:36-67` + + +**Проблема:** `KometAvatar` and `_AvatarHeroFlight` independently implement 'circular clip, network image, first-letter fallback'. `KometAvatar` caps decode resolution via `memCacheWidth/Height = (size*3).round()` and supplies an `errorWidget` initials fallback (komet_avatar.dart:41,51-53). `_AvatarHeroFlight` — which renders the same avatar mid-flight for a hero started from a `KometAvatar` — uses a bare `Image(image: CachedNetworkImageProvider(url))` with no memCache cap and no error fallback (avatar_hero.dart:47-51), so the resting and flying frames of the same avatar can decode at different resolutions and only the resting one recovers from a load error (a failed image shows a blank circle during flight). Confirmed. + + +**Решение:** Extract one shared avatar-content builder (network image with capped memCacheWidth/Height + initials placeholder + errorWidget) used by both `KometAvatar` and the hero flight shuttle, so caching and fallback can't diverge between resting and flying states. + +
+ +
+🟡 LOW · ДУБЛЬ · [M] — Near-identical 'edit message in a bottom sheet' layout duplicated across chat_screen and scheduled_messages_screen + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:1879-1933 (_startEditMessage sheet)`, `lib/frontend/screens/chats/scheduled_messages_screen.dart:83-150 (_edit sheet)` + + +**Проблема:** Both build `showModalBottomSheet` (isScrollControlled, surfaceContainerHigh, same inline 24px shape), the same title TextStyle (fontSize 18, w600, fontFamily 'Outfit'), the same filled TextField with surfaceContainerHighest fill and a 14px borderless OutlineInputBorder, and the same viewInsets-aware Padding. The only differences are maxLines (6 vs 5), chat_screen's contextMenuBuilder for format actions, and scheduled_messages' time-picker row. + + +**Решение:** Extract a shared 'edit text sheet' helper (styled title + TextField + save button, with an optional trailing child slot for the time picker and an optional contextMenuBuilder) that both screens call, collapsing ~70 lines of near-identical layout. Lower priority than the other findings since the two variants genuinely diverge. + +
+ + +### Hardcoded theming and color literals (7 — 0 high) + +_Semantic colors and shadows are re-typed as raw expressions or hex across many sites with inconsistent alpha, bypassing the app's accent-customization system._ + +
+✅ ЗАКРЫТО С11 · 🟠 MED · КОСТЫЛЬ · [M] — Brand/status accent colors hardcoded as raw hex, bypassing the app's AppAccent customization and duplicating literals + +_✅ С11: 007AFF→`cs.primary` (chat_info ×5); 4FC3F7→`kReadReceiptBlue`; 34C759→`kOnlineGreen`; 2F8FFF→`kEditorAccent` (core/config/app_colors.dart). Residual-grep всех четырёх = 0. Editor-chrome остаётся фикс-акцентом, но теперь через один const._ + + +**Где:** `lib/frontend/screens/chats/chat_info_screen.dart:480`, `lib/frontend/screens/chats/chat_info_screen.dart:511`, `lib/frontend/screens/chats/chat_info_screen.dart:521`, `lib/frontend/screens/chats/chat_info_screen.dart:564`, `lib/frontend/screens/chats/chat_info_screen.dart:791`, `lib/frontend/screens/chats/chat_list_screen.dart:2243`, `lib/frontend/widgets/message_bubble.dart:2438`, `lib/frontend/widgets/message_bubble.dart:2801`, `lib/frontend/widgets/message_bubble.dart:2963`, `lib/frontend/screens/profile/traffic_monitor_screen.dart:224`, `lib/frontend/screens/profile/settings_tab.dart:671`, `lib/frontend/widgets/attachment/media_preview_screen.dart:12`, `lib/frontend/widgets/attachment/photo_editor.dart:1891` + + +**Проблема:** Despite the `AppAccent` seed-color system (lib/core/config/app_accent.dart) with per-user accent customization, brand accents are hardcoded as bare hex and duplicated: `0xFF007AFF` (iOS link blue) appears 5x in chat_info_screen.dart for link/action text and icons — these should track `cs.primary` so the user's chosen accent applies; `0xFF4FC3F7` appears 4x across chat_list_screen.dart and message_bubble.dart for the same indicator; `0xFF34C759` (green) appears 3x (traffic_monitor:224, settings_tab:671, photo_editor:1274); `0xFF2F8FFF` is defined as a separate `_kAccent` const in both media_preview_screen.dart:12 and photo_editor.dart:1891 and also inlined as a raw literal at photo_editor.dart 458/513/650/1276/1706. The photo-editor/media-viewer cases are dark-fixed editor chrome where a fixed accent is defensible, but the value is still duplicated rather than centralized. + + +**Решение:** Add named semantic constants to a central `AppColors` (`linkBlue`, `onlineGreen`, `editorAccent`) so each value exists once, and convert the chat_info_screen link/action colors and the message_bubble/chat_list indicator to `Theme.of(context).colorScheme.primary` (or a proper AppAccent token) so custom accent colors propagate instead of being overridden by a hardcoded blue. + +
+ +
+✅ ЗАКРЫТО С11 · 🟠 MED · ДУБЛЬ · [M] — Muted/secondary text color `cs.onSurfaceVariant.withValues(alpha: 0.6)` re-derived ad hoc in 8 call sites instead of one theme token + +_✅ С11: extension `ColorScheme.mutedText` (app_colors.dart); все 8 сайтов → `cs.mutedText` (settings_tab ×2, security, devices, server/proxy sheets, composer_input, chat_screen). Residual-grep α0.6 = 0._ + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:5602`, `lib/frontend/screens/chats/chat_screen.dart:7319`, `lib/frontend/screens/profile/devices_screen.dart:671`, `lib/frontend/screens/profile/security_screen.dart:718`, `lib/frontend/screens/profile/settings_tab.dart:626`, `lib/frontend/screens/profile/settings_tab.dart:672`, `lib/frontend/screens/auth/server_settings_sheet.dart:202`, `lib/frontend/screens/auth/proxy_settings_sheet.dart:277` + + +**Проблема:** The exact expression `cs.onSurfaceVariant.withValues(alpha: 0.6)` (the standard 'subtitle/secondary label' color) is retyped independently at 8 verified call sites across 6 files. There is no single source of truth, so a design tweak (e.g. bumping alpha for accessibility contrast) requires editing every copy, and new screens can silently drift (onSurfaceVariant is already used at 0.3/0.35/0.4/0.5/0.55/0.7/0.75/0.8/0.85 elsewhere for what is loosely the same intent). Note: the audit's 9th cited location (chat_list_screen.dart:1511) was mis-cited — that line uses `cs.onSurface.withValues(alpha: 0.6)` (a different base color), so it is excluded here. + + +**Решение:** Add a `ColorScheme` extension getter (or `AppColors.of(context).mutedText`) backed by `onSurfaceVariant.withValues(alpha: 0.6)` and route all 8 sites through it. Keep this consistent with the app's existing theme-token conventions (single source of truth in core/config). + +
+ +
+🟠 MED · ДУБЛЬ · [M] — Hairline divider/border color duplicated with inconsistent alpha (0.3 / 0.35 / 0.4 / 0.5) across the app + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:1759`, `lib/frontend/screens/chats/chat_screen.dart:1849`, `lib/frontend/screens/chats/chat_screen.dart:2267`, `lib/frontend/screens/chats/chat_screen.dart:5630`, `lib/frontend/screens/chats/chat_screen.dart:5679`, `lib/frontend/screens/chats/chat_screen.dart:6916`, `lib/frontend/screens/chats/chat_screen.dart:6968`, `lib/frontend/screens/chats/chat_list_screen.dart:1531`, `lib/frontend/screens/profile/security_screen.dart:331`, `lib/frontend/screens/profile/security_screen.dart:396`, `lib/frontend/screens/profile/security_screen.dart:436`, `lib/frontend/screens/profile/security_screen.dart:705`, `lib/frontend/screens/profile/security_screen.dart:925`, `lib/frontend/screens/profile/security_screen.dart:987`, `lib/frontend/screens/profile/security_screen.dart:1039`, `lib/frontend/screens/profile/security_screen.dart:1092` + + +**Проблема:** Hairline dividers/borders are written inline as `cs.outlineVariant.withValues(alpha: X)` with X inconsistent across 37 total usages: security_screen.dart uses 0.35 (except line 705 which is 0.3), chat_screen.dart mixes 0.4/0.5 with a 0.3 at line 6916. Dividers in the security/settings pages render noticeably fainter than those in chat screens for no design reason — just because each author picked their own number. (The audit's `appearance_screen.dart:517`, `attachment_panel.dart:127`, `attachment_sheet.dart:263`, and `message_actions_overlay.dart:687` were not re-verified for exact alpha but the pattern is clearly file-wide.) + + +**Решение:** Introduce a single `cs.hairline` extension getter returning the canonical `outlineVariant.withValues(alpha: 0.4)` (or whatever design intends) and replace every inline computation so all dividers render identically and can be retuned in one place. + +
+ +
+🟠 MED · ДУБЛЬ · [S] — Identical 'frosted pill surface' Color.alphaBlend block copy-pasted 5 times in chat_screen.dart + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:1842`, `lib/frontend/screens/chats/chat_screen.dart:5475`, `lib/frontend/screens/chats/chat_screen.dart:5511`, `lib/frontend/screens/chats/chat_screen.dart:5622`, `lib/frontend/screens/chats/chat_screen.dart:5672` + + +**Проблема:** The expression `Color.alphaBlend(cs.surfaceContainerHighest.withValues(alpha: 0.92), cs.surface)` is copy-pasted verbatim as the fill color for GlossyPill/Container surfaces at 4 locations, plus a 0.96 variant at line 5475. The same file also repeats a near-identical `_FrostedPanel(tint: cs.surfaceContainerHigh.withValues(alpha: 0.55), border: ... outlineVariant.withValues(alpha: 0.4))` config at lines 1755-1762 and 2263-2270 (these two differ only in top- vs bottom-BorderSide, so not strictly verbatim). + + +**Решение:** Extract a helper `Color appPillSurface(ColorScheme cs) => Color.alphaBlend(cs.surfaceContainerHighest.withValues(alpha: 0.92), cs.surface);` and a small widget wrapping the repeated `_FrostedPanel` chrome (parameterized by border edge), then reuse at all sites. + +
+ +
+✅ ЗАКРЫТО С11 · 🟡 LOW · ДУБЛЬ · [S] — Avatar thumbnail cache dimensions duplicated as a magic literal in four places + +_✅ С11: `const kAvatarThumbSize = 144` (app_colors.dart); все 4 `maxWidth/maxHeight: 144` в chat_list_screen → `kAvatarThumbSize`._ + + +**Где:** `lib/frontend/screens/chats/chat_list_screen.dart:2105-2107`, `lib/frontend/screens/chats/chat_list_screen.dart:2348`, `lib/frontend/screens/chats/chat_list_screen.dart:2387-2389`, `lib/frontend/screens/chats/chat_list_screen.dart:2711-2713` + + +**Проблема:** Confirmed. `CachedNetworkImageProvider(..., maxWidth: 144, maxHeight: 144)` is written four separate times (story avatar, chat-tile avatar, precache-on-tap, folded story). Since the resize size participates in the cache key, changing one literal without the others would cache the same URL under two keys, wasting memory and missing hits. + + +**Решение:** Introduce a single shared `const kAvatarThumbSize = 144;` (or an `avatarImageProvider(String url)` factory) in a shared utils/widgets file and use it at all four sites. + +
+ +
+🟡 LOW · ДУБЛЬ · [M] — Ad-hoc black drop-shadows reimplemented in several widgets despite an existing GlossyDecor.dropShadow helper + + +**Где:** `lib/frontend/screens/calls/call_screen.dart:980`, `lib/frontend/screens/chats/chat_list_screen.dart:2008`, `lib/frontend/widgets/connection_status.dart:170`, `lib/frontend/widgets/sliding_pill_nav.dart:122`, `lib/frontend/widgets/message_actions_overlay.dart:1085`, `lib/frontend/screens/chats/chat_screen.dart:5481`, `lib/frontend/widgets/glossy_pill.dart:72` + + +**Проблема:** `glossy_pill.dart:72` implements `GlossyDecor.dropShadow(base, depth)` mapping a depth scale to blur/spread/offset/alpha (and used at glossy_pill.dart:154), yet ~6 widgets hand-roll `BoxShadow(color: Colors.black.withValues(alpha: ...), blurRadius: ..., offset: ...)` for the same 'floating surface' shadow with alpha 0.1–0.5 and blur 6–24. Note: the audit's two `login_success_screen.dart` locations (222, 270) were mis-cited — they are `cs.primary`-colored glows, not black shadows, so they are excluded. Shadow values legitimately vary by context, so this is a minor consistency/reuse nit rather than a strong duplication. + + +**Решение:** Where these are genuinely the same 'soft floating surface' shadow, route them through `GlossyDecor.dropShadow` (or a small `AppShadows.soft({depth})` helper mapping an elevation-depth scale) so depth stays consistent and tunable in one place. Leave intentionally distinct shadows (colored glows, media-viewer scrims) as-is. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — message_bubble.dart re-derives the same tint/opacity expressions 6-7 times each within one file + + +**Где:** `lib/frontend/widgets/message_bubble.dart:1901`, `lib/frontend/widgets/message_bubble.dart:1997`, `lib/frontend/widgets/message_bubble.dart:2259`, `lib/frontend/widgets/message_bubble.dart:2319`, `lib/frontend/widgets/message_bubble.dart:2465`, `lib/frontend/widgets/message_bubble.dart:2555`, `lib/frontend/widgets/message_bubble.dart:3001`, `lib/frontend/widgets/message_bubble.dart:3055`, `lib/frontend/widgets/message_bubble.dart:3061`, `lib/frontend/widgets/message_bubble.dart:3114`, `lib/frontend/widgets/message_bubble.dart:3127`, `lib/frontend/widgets/message_bubble.dart:3143`, `lib/frontend/widgets/message_bubble.dart:3156` + + +**Проблема:** Within this single large file, `cs.onPrimaryContainer.withValues(alpha: 0.12)` (the tinted system-message background) is retyped at 7 verified locations, and `widget.textColor.withValues(alpha: 0.6)` (secondary/timestamp text on a bubble) at 6 more, as pure copy-paste with not even a shared local constant. + + +**Решение:** Hoist these into local getters on the widget/state class (e.g. `Color get _systemTint => cs.onPrimaryContainer.withValues(alpha: 0.12);` and `Color get _secondaryTextColor => widget.textColor.withValues(alpha: 0.6);`), or route the secondary-text one through the shared muted-text token proposed in finding 1. + +
+ + +### Hardcoded Russian strings / missing l10n (6 — 0 high) + +_Despite active English+Russian ARB support, user-facing strings are hardcoded in Russian in backend previews and many widgets/screens, so English users see Russian mid-flow._ + +
+🟠 MED · КОСТЫЛЬ · [M] — Session/error classification uses free-text Russian substring matching, with a hardcoded Russian user-facing string baked into the protocol layer + + +**Где:** `lib/core/protocol/packet.dart:73`, `lib/core/protocol/packet.dart:86`, `lib/core/protocol/packet.dart:89`, `lib/backend/modules/account.dart:1519`, `lib/backend/modules/folders.dart:200`, `lib/frontend/screens/auth/login_screen.dart:498` + + +**Проблема:** `isSessionStateError` (packet.dart:86-93) lower-cases `error.toString()` and checks for Russian substrings ('состояние сессии', 'сессия не найдена', 'авторизационная сессия', 'сессия не онлайн') to gate auth-flow decisions in login/2FA/code-confirmation screens — this breaks the moment server wording changes or unrelated error text collides. `messageFromErrorPayload` (packet.dart:73) hardcodes a Russian user-facing sentence directly in the protocol layer, and that string is threaded through as display text across account, folders, and links modules. Since the app ships both `app_en.arb` and `app_ru.arb`, this hardcoded string always renders in Russian regardless of the user's chosen language (a real user-facing i18n defect), and UI copy is decided two layers below the UI, inverting the UI → backend → api → transport layering. + + +**Решение:** Return a typed/structured error from the protocol layer (the `errorKey` / `payload['error']` machine code already available at dispatcher.dart:104) instead of pre-baked display text or free-text detection. Map that typed code to a localized string in the backend/UI layer via `AppLocalizations`, and have `isSessionStateError` compare the typed code rather than parsing translated prose. + +
+ +
+🟠 MED · КОСТЫЛЬ · [L] — Message-preview strings hardcoded in Russian, bypassing l10n + + +**Где:** `lib/backend/modules/chats.dart:257-304`, `lib/backend/modules/chats.dart:306-330` + + +**Проблема:** attachPreviewLabel and _controlPreviewLabel return literal Russian strings ('Фото', 'Видео', 'Голосовое сообщение', 'Системное сообщение', etc.) directly from the backend module. These strings feed CachedChat.lastMsgText / chat-list subtitles shown to every user. The project explicitly supports English and Russian via ARB files, but any non-plain-text last message (photo, video, sticker, call, system event) will always render in Russian regardless of device locale, so English users see Russian preview text in their chat list. + + +**Решение:** Do not bake final UI strings into the backend layer. Store a preview kind tag/enum (e.g. AttachKind.photo, ControlEventKind.pin) on CachedChat instead of a pre-rendered string, and resolve the localized label in the UI via AppLocalizations at render time (widgets already have BuildContext). This keeps chats.dart free of UI text and makes previews correctly localized. + +
+ +
+🟠 MED · КОСТЫЛЬ · [M] — User-facing strings hardcoded in Russian, bypassing the project's active l10n system + + +**Где:** `lib/frontend/widgets/message_bubble.dart:2190`, `lib/frontend/widgets/message_bubble.dart:2203`, `lib/frontend/widgets/message_bubble.dart:2657`, `lib/frontend/widgets/message_bubble.dart:2906`, `lib/frontend/widgets/message_bubble.dart:2918`, `lib/frontend/widgets/message_actions_overlay.dart:385`, `lib/frontend/widgets/message_actions_overlay.dart:502` + + +**Проблема:** `AppLocalizations` is actively consumed in ~30 sites across the app (auth/profile screens, main.dart), yet these widgets hard-code Russian literals directly: video/file notifications ('Не удалось открыть видео', 'Не удалось получить видео', 'Не удалось определить файл'), audio errors ('Не удалось загрузить аудио', 'Ошибка воспроизведения'), and the action-menu label set ('Копировать', 'Изменить', 'Ответить', 'Переслать', 'Удалить', 'Скопировано'). English-locale users see Russian for all of these, and wording changes require hunting through widget code. This is an inconsistency with the app's own established pattern, not a systemic absence of l10n. + + +**Решение:** Move each literal into `app_en.arb`/`app_ru.arb` and reference via `AppLocalizations.of(context)!.xxx`, as the auth/profile screens already do. + +
+ +
+🟠 MED · КОСТЫЛЬ · [M] — Raw exception text shown directly to end users in auth catch blocks + + +**Где:** `lib/frontend/screens/auth/login_screen.dart:497-501`, `lib/frontend/screens/auth/code_confirmation_screen.dart:151,314`, `lib/frontend/screens/auth/password_2fa_screen.dart:121`, `lib/frontend/screens/auth/registration_screen.dart:85`, `lib/frontend/widgets/web_qr_login.dart:110` + + +**Проблема:** Auth catch blocks fall back to `showCustomNotification(context, e.toString())` or string-interpolate `$e` into an otherwise-localized message (`'Неверный пароль: $e'`, `'Не удалось подтвердить вход: $e'`, `'Не удалось обновить код: $e'`) instead of mapping the error to a user-facing, localized message. `e.toString()` on a protocol/Dart exception typically carries a type prefix and internal detail that is neither translated nor meaningful to an end user, and gets appended to a notification meant to read cleanly. + + +**Решение:** Introduce a single error-to-message mapping (e.g. an `AuthException` hierarchy thrown from `backend/modules/account.dart` plus a shared `describeAuthError(Object e, AppLocalizations l10n)` helper) and have every catch block call it instead of interpolating `e.toString()` directly. + +
+ +
+🟠 MED · СОМНИТ · [M] — Hardcoded Russian-only strings break English locale across the auth flow + + +**Где:** `lib/frontend/screens/auth/password_2fa_screen.dart:51,63,121,147,156,167,182`, `lib/frontend/widgets/web_qr_login.dart:26,35,50,59,104,110`, `lib/frontend/screens/auth/login_screen.dart:473,499-500,536`, `lib/frontend/screens/auth/code_confirmation_screen.dart:108,129,147,151` + + +**Проблема:** The project supports English and Russian via `AppLocalizations`/ARB files, and most auth screens use `l10n.xxx`. `Password2FAScreen` never imports `AppLocalizations` at all — every string is a hardcoded Russian literal ('Двухфакторная аутентификация', 'Введите пароль для завершения входа', 'Неверный пароль: $e', 'Пароль', etc.). `web_qr_login.dart` is the same ('Вход по QR', 'Отмена', 'Войти', 'Вход подтверждён', ...). Several connectivity/error messages in `login_screen.dart` (473, 499-500, 536) and `code_confirmation_screen.dart` (108, 129, 147, 151) are also hardcoded Russian even though the rest of those same screens use l10n. An English-locale user reaching 2FA or the QR-login sheet is shown Russian text mid-flow. + + +**Решение:** Move every hardcoded string in these files into `app_en.arb`/`app_ru.arb` and reference them through `AppLocalizations.of(context)!`, matching the pattern already used by `CodeConfirmationScreen`, `RegistrationScreen`, and the rest of `LoginScreen`. + +
+ +
+🟡 LOW · КОСТЫЛЬ · [L] — Screen is single-locale: hardcoded Russian strings, no AppLocalizations, TODO comments left in build() + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:4350-4351`, `lib/frontend/screens/chats/chat_screen.dart:693`, `lib/frontend/screens/chats/chat_screen.dart:2373`, `lib/frontend/screens/chats/chat_screen.dart:2754` + + +**Проблема:** The file has zero `AppLocalizations` references (confirmed) and leaves `// TODO: Локализация` / `// TODO: Cклонения` in `build()` at 4350-4351 — the latter also violating the project's no-comments convention. Every user-visible string (notifications, header labels, dialog text, menu items, composer/search hints) is a hardcoded Russian literal, so the screen is effectively single-locale despite the project building for English and Russian. + + +**Решение:** Route user-visible strings in this file through `AppLocalizations.of(context)`, add the missing keys to `app_en.arb`/`app_ru.arb`, and remove the TODO comments. Low priority but tracked debt. + +
+ + +### SnackBar and notification convention violations (4 — 0 high) + +_The mandated showCustomNotification is bypassed and stray debug logging ships in release, against project conventions._ + +
+🟠 MED · КОСТЫЛЬ · [S] — Debug-only file introspection performs real I/O and logging on every voice-message send + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:5088` + + +**Проблема:** Inside _sendVoice, before each upload, the code opens the recorded file, reads its first 80 bytes, hex/ascii-encodes them and logs via logger.w('VOICE size=... hex=... ascii=...'), all wrapped in a swallowing catch(_){}. This is leftover debugging instrumentation running unconditionally in release on every voice message a user sends, adding file I/O and string building to the hot send path. + + +**Решение:** Remove the block, or gate it behind kDebugMode so it never executes in release builds. + +
+ +
+🟠 MED · КОСТЫЛЬ · [S] — SnackBar used instead of showCustomNotification in spoof apply error path + + +**Где:** `lib/frontend/screens/profile/spoof_screen.dart:391-402` + + +**Проблема:** CLAUDE.md/AGENTS.md explicitly mandate showCustomNotification(context, 'text') for all user-facing notifications and forbid SnackBars. In _saveSpoofingSettings's catch block (after a failed disconnect/connect), the reconnect failure is reported via ScaffoldMessenger.of(context).showSnackBar(SnackBar(...)). Confirmed this is the only showSnackBar/ScaffoldMessenger call in the file; every other path in the screen family uses showCustomNotification. + + +**Решение:** Replace the ScaffoldMessenger/SnackBar block with showCustomNotification(context, AppLocalizations.of(context)!.spoofErrorApplyFailed(e.toString())), matching the rest of the file. + +
+ +
+🟠 MED · КОСТЫЛЬ · [S] — spoof_screen.dart uses ScaffoldMessenger/SnackBar, the only violation of the mandated showCustomNotification convention + + +**Где:** `lib/frontend/screens/profile/spoof_screen.dart:391-402` + + +**Проблема:** CLAUDE.md/AGENTS.md mandates `showCustomNotification(context, 'text')` and forbids SnackBars. A repo-wide grep confirms this is the sole `ScaffoldMessenger.of(context).showSnackBar(...)` in lib/. It fires when re-applying a spoofed profile fails to reconnect (line 391-402) — precisely a spot a user is likely to hit — producing a Material SnackBar instead of the app's custom notification banner. + + +**Решение:** Replace the ScaffoldMessenger/SnackBar block with `showCustomNotification(context, AppLocalizations.of(context)!.spoofErrorApplyFailed(e.toString()))`, matching the localized string already used inside the SnackBar. + +
+ +
+🟡 LOW · КОСТЫЛЬ · [S] — Debug logging left in shipped list-reorder animation + + +**Где:** `lib/frontend/screens/chats/chat_list_screen.dart:2803` + + +**Проблема:** Confirmed. `_AnimatedChatTileState._runMove` unconditionally calls `debugPrint('[FLIP] move id=${widget.id} oldY=$oldY newY=$newY');` on every move. `debugPrint` is not stripped in release builds, so this does string interpolation on a hot UI path in production with a literal `[FLIP]` developer tag. + + +**Решение:** Delete the `debugPrint` call; it is leftover FLIP-development instrumentation with no product purpose. + +
+ + +### Layering violations from UI into transport/storage (7 — 0 high) + +_Widgets and push code reach directly into protocol opcodes, storage, and spoof-profile construction, inverting the documented data flow._ + +
+🟠 MED · КОСТЫЛЬ · [S] — Notification toggles push raw protocol-key maps to the backend instead of typed setters + + +**Где:** `lib/frontend/screens/profile/notifications_screen.dart:50-70`, `lib/frontend/screens/profile/notifications_screen.dart:132-205`, `lib/backend/modules/account.dart:496-521` + + +**Проблема:** `NotificationsScreen._apply()` builds ad-hoc `Map` literals with server wire keys ('CHATS_PUSH_NOTIFICATION', 'PUSH_DETAILS', 'PUSH_SOUND', 'CHATS_PUSH_SOUND', 'M_CALL_PUSH_NOTIFICATION', 'PUSH_NEW_CONTACTS') in the UI layer and passes them to the generic `accountModule.updatePrivacyConfig(Map)`. `PrivacyConfig` is a fully typed model on the read path (`getPrivacyConfig()`), but the write path forces every call site to spell exact protocol strings, so a server rename or typo silently no-ops a setting with no compile-time check. This is inconsistent with komet_settings_screen, which drives typed methods like `setGhostMode`/`setAntiRead`. + + +**Решение:** Add typed setters on the account module (e.g. `setChatsPushNotification(bool)`, `setMessagePreview(bool)`, `setSound(bool)`, `setCallNotifications(bool)`, `setNewContacts(bool)`) that build the wire-format map internally in account.dart. notifications_screen.dart then calls `accountModule.setXxx(value)`, keeping its existing optimistic-update/rollback pattern. + +
+ +
+🟠 MED · ДУБЛЬ · [M] — Notification reply handler hand-builds a raw login packet, duplicating AccountModule.login()/_buildLoginPayload + + +**Где:** `lib/core/push/push_service.dart:379-397`, `lib/backend/modules/account.dart:978-1033`, `lib/backend/modules/account.dart:1203-1213` + + +**Проблема:** `_handleReply` builds a bare login request `api.sendRequest(Opcode.login, {...})` with a hand-copied payload, including the magic `Uint8List.fromList([0x0b, 0x32])` for `exp.chatsCountGroups` (push_service.dart:379-386). That exact payload is already produced by `AccountModule._buildLoginPayload` (account.dart:1203-1213), and `AccountModule.login()` wraps it with error checking, chatCacheFingerprint, token/account resolution and full response processing. This is core/push code reaching straight into the protocol layer instead of the backend module. If the login encoding changes (e.g. a new required field, or the fingerprint becomes mandatory), every other caller is updated via _buildLoginPayload while this hand-built copy silently goes stale, so background replies start failing while foreground login works. Note the copy also diverges today: it hardcodes `interactive: false` and omits `chatCacheFingerprint`. + + +**Решение:** Instantiate `AccountModule(api)` (already imported) and call `login(accountId: account, token: token)` — passing accountId explicitly so it does not resolve the wrong active account in the background isolate — reusing the shared payload build and response handling. If login()'s UI-facing side effects (status controller, _loggedIn) are undesirable in the background path, extract the shared `_buildLoginPayload` into something both callers use rather than re-copying the magic bytes. + +
+ +
+🟠 MED · ДУБЛЬ · [M] — Logout hand-rolls the account-teardown sequence and drops the spoof-clear step + + +**Где:** `lib/frontend/screens/profile/settings_tab.dart:200-223`, `lib/backend/modules/account.dart:1146-1151` + + +**Проблема:** `_SettingsTabState._doLogout()` drives `TokenStorage.deleteAccount`, `AppDatabase.deleteAccount`, `ContactCache.clear`, `TranscriptionCache.clear` and `ChatsModule.resetForAccountSwitch` directly from the widget, duplicating the delete-token+delete-db+clear-cache sequence that `AccountModule.removeAccount()`/`switchAccount()` already own. Because it is a hand-copy it has drifted: `removeAccount()` also calls `SpoofingService.clearAccountSpoof(accountId)`, but the logout path never does, so the device-spoofing profile for the logged-out account is left behind on disk. It also violates the UI -> backend-module layering by importing and driving core/storage and cache singletons from settings_tab.dart. + + +**Решение:** Add a single `Future AccountModule.logout()` in account.dart that composes `removeAccount()` (which already clears spoof data) with disconnect, cache-clear and reconnect. `_doLogout()` then just does `await accountModule.logout()` and navigates. This removes the duplicated sequence, fixes the missing spoof-clear, and keeps storage/cache details out of the widget. + +
+ +
+🟠 MED · СОМНИТ · [S] — Read-receipt handler mutates a shared cached chat model in place from the UI layer + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:3168-3182` + + +**Проблема:** `_onMessageRead` does `c.participants[userId] = mark` directly on the `chat` object obtained from `ChatsModule.getChat`, mutating a cached model in place from the UI layer instead of going through the backend module. If that instance/its participants map is shared with other consumers (chat list, chat info screen), the mutation propagates without notifying their listeners; if it isn't the same instance, state diverges. Either way it bypasses the layered UI -> backend module -> state architecture used elsewhere. + + +**Решение:** Route the read-mark update through `ChatsModule` (mirroring how `markRead`/`markUnread` work) so the participants map is updated in one authoritative place and listeners are notified, rather than reaching into the cached model from the chat screen. + +
+ +
+🟠 MED · СОМНИТ · [M] — UI layer builds the device-spoof protocol struct and calls storage, bypassing the backend module layer + + +**Где:** `lib/frontend/screens/auth/token_login_screen.dart:4`, `lib/frontend/screens/auth/token_login_screen.dart:79-101` + + +**Проблема:** `TokenLoginScreen` imports `core/storage/spoofing_service.dart` directly, builds a full `SpoofProfile` (device name, OS version, arch, timezone, IDs, user agent, ...) inline in the widget, then calls `SpoofingService.saveProfile(...)` before `accountModule.loginWithToken`. This skips the `backend/modules` layer the architecture prescribes (UI -> backend module -> api.dart) — the UI talks straight to core/storage and assembles a protocol-shaped struct itself. If the on-wire device-profile shape or validation changes, the change lands inside this widget instead of one backend module, and any future spoof-login screen re-duplicates this construction. + + +**Решение:** Add a method on `accountModule` (e.g. `loginWithTokenAndSpoof(token, SpoofFields fields)`) that owns building the `SpoofProfile`, persisting it via `SpoofingService`, and performing the login; the screen should only collect raw field values and call one backend method. + +
+ +
+🟡 LOW · СОМНИТ · [L] — ContactCache is a static singleton read directly by UI widgets, non-reactive and bypassing the state/ layer + + +**Где:** `lib/backend/modules/messages.dart:15-77` + + +**Проблема:** ContactCache.get/getAvatar/getOptions are called directly from many widgets (message_bubble.dart, chat_list_screen.dart, chat_screen.dart, chat_info_screen.dart, call_screen.dart) rather than through a ChangeNotifier in state/ as the architecture prescribes. Because it is not observable, a widget that builds before a name resolves has no signal to rebuild when the name later arrives and only refreshes incidentally when something else rebuilds it, risking stale placeholder names. + + +**Решение:** Wrap ContactCache in a ChangeNotifier state class (mirroring PollsModule) that notifyListeners() on put/putAvatar/putOptions and have widgets consume it reactively instead of calling static getters during build. + +
+ +
+🟡 LOW · СОМНИТ · [L] — Contact name/avatar read from a non-reactive static cache during build + + +**Где:** `lib/frontend/widgets/message_bubble.dart:349`, `lib/frontend/widgets/message_bubble.dart:377`, `lib/frontend/widgets/message_bubble.dart:378` + + +**Проблема:** `ContactCache` (backend/modules/messages.dart:15) is a plain static class with in-memory Maps and no ChangeNotifier/listenable surface. `MessageBubble` (a StatelessWidget) reads `ContactCache.get(...)`/`getAvatar(...)` directly during build() for the sender header and leading avatar. Per the project's own layered architecture (`state/` holds ChangeNotifier classes the UI observes), contact data should reach the UI through a listenable. Because it doesn't, if a contact's name/avatar resolves after a bubble has already rendered, the bubble keeps showing the placeholder until some unrelated event rebuilds that row. (Impact is somewhat speculative — chat lists rebuild frequently on new messages/scroll — hence low severity.) + + +**Решение:** Route contact name/avatar lookups through a ValueNotifier/ChangeNotifier-backed contacts state (mirroring how `reactionsListenable` and `MediaDownloadProgress.notifier` are already used in this file) so bubbles rebuild when contact data arrives. + +
+ + +### Robustness, magic values, and stringly-typed state (18 — 0 high) + +_Sentinel strings, magic bitmasks, substring matching, and desync-on-overflow patterns encode important state without type safety or error signaling._ + +
+🟠 MED · КОСТЫЛЬ · [M] — Buffer overflow silently discards bytes and desyncs the stream with no reconnect signal + + +**Где:** `lib/core/transport/receiver.dart:13`, `lib/core/transport/receiver.dart:25`, `lib/core/transport/receiver.dart:29`, `lib/backend/api.dart:376` + + +**Проблема:** The 24-bit payload-length field (`packedLen & 0xFFFFFF`, packet.dart:143 / receiver.dart:41) permits ~16 MB payloads on the wire, but `_maxBufferSize` is hardcoded to 2 MB (receiver.dart:13) with no stated relationship to the header format. If a single packet exceeds 2 MB before it is fully received, `feed()` logs an error and calls `reset()` (receiver.dart:25-30), discarding the buffered bytes and returning `const []` — while the connection stays open. `_onDataReceived` (api.dart:376) gets an empty list and continues as if nothing happened; there is no error signal from `feed()`/`reset()` to the caller. A too-large packet then thrashes (accumulate → overflow → reset → misaligned re-parse), leaving the session effectively dead until an unrelated reconnect. Note: the practical trigger is bounded because the client caps decompression at ~1 MB, but the missing error signal is a genuine robustness gap. + + +**Решение:** On overflow, surface a hard error to the transport owner — push onto the existing dispatcher error stream (or a dedicated receiver error signal) that Connection/Api listens to — so the caller forces an immediate disconnect+reconnect instead of silently feeding a desynced stream. Separately, size `_maxBufferSize` deliberately relative to the protocol's real maximum sync payload rather than an arbitrary constant. + +
+ +
+🟠 MED · КОСТЫЛЬ · [S] — sendFileMessage blocks a hardcoded 3s before its first send attempt, redundant with its own retry loop + + +**Где:** `lib/backend/modules/messages.dart:1171-1197` + + +**Проблема:** await Future.delayed(initialDelay) (default 3s) runs unconditionally before even the first msgSend attempt. The method already has a not.ready retry loop immediately after (1199-1213) that handles server-side readiness, and the other four upload senders rely purely on that loop with no initial delay. The blind sleep adds a guaranteed 3s latency tax to every file send even when the server is ready, and still fails if readiness takes longer than 3s. + + +**Решение:** Remove the initial blind sleep and let the existing not.ready retry loop handle readiness, matching the other four senders and removing the fixed 3s latency. + +
+ +
+🟠 MED · КОСТЫЛЬ · [M] — Catch-all try/catch around the entire chat parser silently drops chats + + +**Где:** `lib/backend/modules/chats.dart:1146-1278` + + +**Проблема:** _parseChat wraps ~130 lines of largely independent logic (title/icon resolution, options, last-message extraction, mute/favorite config, presence, participants, owner, admins) in one try/catch that logs and returns null on ANY exception. While most sub-steps are already individually defensive (as int?, tryParse, whereType), the broad catch means a single unexpected type/null in any one step aborts the whole parse and drops the chat from that sync with only a debug log — no partial recovery and nothing surfaced to the user. It runs on every login sync and every server push path. + + +**Решение:** Narrow the try/catch to the specific fallible conversions and let each fail safe with sensible per-field defaults rather than aborting the whole parse. Extract the function into smaller named steps (e.g. _resolveTitleAndIcon, _resolveLastMessage, _resolveMuteAndFavorite, _resolvePresence, _resolveAdmins), each independently defensive, so one bad field cannot take down the entire chat object. + +
+ +
+🟠 MED · КОСТЫЛЬ · [M] — VP8 codec selection done via hand-written SDP regex surgery + + +**Где:** `lib/core/calls/call_session.dart:857`, `lib/core/calls/call_session.dart:874-927` + + +**Проблема:** _forceVp8 rewrites the freshly-created offer SDP with regexes to strip every payload type except VP8 and its RTX companion from the m=video line and its attribute block (desktop only, before setLocalDescription). It assumes a single video m= section and a specific a=fmtp: apt= RTX shape, and silently returns the untouched SDP whenever a regex fails to match, with no signal that the intended codec restriction did not apply. + + +**Решение:** Use the WebRTC codec-preference API instead of text munging: after creating/obtaining the video transceiver, call getCapabilities('video') and transceiver.setCodecPreferences([...]) with VP8 ordered first before createOffer, letting the WebRTC stack emit a self-consistent SDP regardless of payload-type count or m-line layout. + +
+ +
+🟠 MED · КОСТЫЛЬ · [L] — Chat participants stored as a JSON-blob TEXT column, looked up via a LIKE substring scan instead of a normalized table + + +**Где:** `lib/core/storage/app_database.dart:351`, `lib/core/storage/app_database.dart:578-589`, `lib/backend/modules/chats.dart:173` + + +**Проблема:** chats_cache.participants stores the entire Map (userId -> role) as a JSON string in one TEXT column. findDialogChatByParticipant locates an existing 1:1 dialog with `participants LIKE '%"$contactId":%'` (whereArgs `%"$contactId":%`) — a leading-% substring scan that cannot use any index, and whose correctness silently depends on the current JSON encoding quoting every key. Any future change to how participants are serialized breaks the match with no compiler- or query-level signal. + + +**Решение:** Add a normalized `chat_participants(chat_id, account_id, user_id, role)` table populated alongside chats_cache, indexed on (account_id, user_id). findDialogChatByParticipant becomes an indexed equality lookup instead of a string scan, and the table can serve future "which chats share contact X" queries without JSON parsing. Requires a schema migration (currently version 16). + +
+ +
+🟠 MED · КОСТЫЛЬ · [M] — Forwarded and unknown attachments are mistyped as AttachmentType.photo + + +**Где:** `lib/models/attachment.dart:3-16`, `lib/models/attachment.dart:698`, `lib/models/attachment.dart:767`, `lib/backend/modules/messages.dart:315-317`, `lib/frontend/screens/chats/scheduled_messages_screen.dart:287-289` + + +**Проблема:** ForwardedMessageAttachment (attachment.dart:698) and UnknownAttachment (attachment.dart:767) both hardcode `super(type: AttachmentType.photo)` because AttachmentType has no forward/unknown member. Verified that CachedMessage.previewText() (messages.dart:310-343) is an exhaustive switch over AttachmentType with no default, so a forward-only message returns 'Фото', and _attachLabel() (scheduled_messages_screen.dart:284-301) hits its photo case and also returns 'Фото'. message_bubble.dart correctly guards with `is ForwardedMessageAttachment`/`is UnknownAttachment` (lines 224, 237, 776, 927, 1209, 1221), but these two call sites don't, so every forwarded or unrecognized attachment is mislabeled as a photo in chat-list previews and the scheduled-messages list. Cosmetic/label impact only, not data loss, hence medium. + + +**Решение:** Add real `forward` and `unknown` members to AttachmentType and construct the two subclasses with them instead of reusing `photo` as a sentinel. Then handle the new cases explicitly in the two switch statements (forward -> original text or 'Переслано', unknown -> a generic 'Вложение' label), which also removes the need for every future caller to remember the `is` check before trusting `.type`. + +
+ +
+🟠 MED · ДУБЛЬ · [S] — Manual CachedMessage reconstruction in _loadForwardedSenderNames drops isControl/deleted/editHistory + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:3718` + + +**Проблема:** When an unknown forwarded-sender name resolves, the message is rebuilt by hand-constructing a new CachedMessage(...) from a subset of the old one's fields, omitting isControl, deleted, and editHistory. CachedMessage.copyWith (backend/modules/messages.dart:387) already carries every field forward and already accepts an `attachments` override, and is used correctly elsewhere. Here an edited forwarded message (non-null editHistory) or a soft-deleted one silently resets those fields to their defaults during ordinary pagination — real silent data loss for the affected rows. + + +**Решение:** Replace the manual constructor with `msg.copyWith(attachments: newAttaches)`. copyWith already covers attachments, so all other fields (isControl, deleted, editHistory, payload) are preserved by construction and no future field can be silently dropped. + +
+ +
+🟠 MED · ДУБЛЬ · [M] — Session-stale recovery state machine duplicated across two auth screens + + +**Где:** `lib/frontend/screens/auth/code_confirmation_screen.dart:47-156`, `lib/frontend/screens/auth/password_2fa_screen.dart:24-66` + + +**Проблема:** Both `_CodeConfirmationScreenState` and `_Password2FAScreenState` independently reimplement the same session-epoch tracking: `_epoch`, `_recovering`, `_dropNotified`, an identical `_sessionStale` getter comparing `api.sessionEpoch`/`api.state`, a `_stateSub` listener calling `_onSessionState`, and a `_recoverStaleSession` handler. The two copies have already drifted (code_confirmation re-requests a fresh code and resumes; password_2fa just pops the screen). A future change to `SessionState`/`sessionEpoch` semantics in api.dart could be applied to one copy and missed in the other, leaving one auth step silently stuck instead of recovering after a reconnect. + + +**Решение:** Extract a reusable mixin or helper (in state/ or backend/) that owns `epoch`, `isStale`, the state-stream subscription, and a per-screen recovery callback (resend code vs. pop-and-notify), and have both screens compose it instead of copy-pasting the fields and methods. + +
+ +
+🟠 MED · ДУБЛЬ · [S] — Call-history row removal animation duration is duplicated as an unlinked magic constant + + +**Где:** `lib/frontend/screens/calls/calls_tab.dart:271-283`, `lib/frontend/screens/calls/calls_tab.dart:466-472` + + +**Проблема:** _deleteCall awaits Future.delayed(const Duration(milliseconds: 260)) (line 277) before removing the entry from _calls, while _RemovableCallEntryState's AnimationController independently uses duration: const Duration(milliseconds: 260) (line 470) for the collapse/fade-out. The two constants must be kept in sync by hand; changing either desyncs list removal from the actual animation. Both confirmed. + + +**Решение:** Drop the parent-side delay and duplicated constant; have _RemovableCallEntry accept an onDismissed callback fired from an AnimationStatus.dismissed listener on its own controller, so real animation completion drives the list removal. + +
+ +
+🟠 MED · СОМНИТ · [S] — No error boundary around push-handler dispatch; one throwing handler drops the rest of the batch + + +**Где:** `lib/core/transport/dispatcher.dart:119`, `lib/backend/api.dart:377`, `lib/backend/api.dart:394` + + +**Проблема:** `PacketDispatcher.dispatch()` invokes `_pushHandlers[packet.opcode]?.call(packet)` (dispatcher.dart:119) with no try/catch. `dispatch()` is called at api.dart:394 inside the `for` loop over every packet decoded from one socket read (api.dart:377), and only `unpackPacket` is guarded (api.dart:379-384) — the `dispatch()` call is not. If any single push handler throws (e.g. a bug reacting to a malformed notifChat/notifMessage payload), the exception propagates out of the loop, aborting processing of every other already-decoded packet in that batch and becoming an unhandled async error, with no log identifying the failing handler. + + +**Решение:** Wrap the handler invocation in `dispatch()` in a try/catch, log the opcode (via `Opcode.name`) and the error, and continue, so one misbehaving push handler cannot cascade into dropping unrelated packets (read receipts, typing indicators, other chats' updates) that arrived in the same TCP read. + +
+ +
+🟠 MED · СОМНИТ · [M] — Outbox flush only retries plain-text pending rows; attachment/poll/location sends left in 'pending' are never recovered + + +**Где:** `lib/backend/modules/outbox.dart:44-45` + + +**Проблема:** if (text == null || text.isEmpty || pending.payload != null) continue; skips any pending DB row carrying a payload (photo/video/audio/file/poll/location) on every flush, forever. Since flush() (triggered on reconnect) is the only generic recovery for 'pending' messages, an attachment send interrupted between local insert and server ack has no path back to being sent or surfaced as failed — it sits in the local DB indefinitely. + + +**Решение:** Either re-invoke the appropriate typed sender based on the stored payload's attachment type, or mark non-text pending rows 'failed' after a timeout so the UI can offer manual retry/delete instead of invisible limbo. (Verify attachment sends actually create 'pending' rows; if they never do, downgrade or drop.) + +
+ +
+🟡 LOW · КОСТЫЛЬ · [S] — Poll settings encoded as an unexplained bitmask magic number + + +**Где:** `lib/backend/modules/messages.dart:1562` + + +**Проблема:** final settings = (anonymous ? 4 : 0) | (multiple ? 1 : 0); bakes the meaning of bits 4 and 1 into one call site with no named constants; adding or auditing a poll flag requires reverse-engineering the protocol from this line. + + +**Решение:** Define named bit constants (e.g. _pollAnonymousFlag = 4, _pollMultipleFlag = 1) and compose settings from them. + +
+ +
+🟡 LOW · КОСТЫЛЬ · [M] — Magic sentinel string encodes "no cached last message" state in the text column + + +**Где:** `lib/backend/modules/chats.dart:255`, `lib/backend/modules/chats.dart:748`, `lib/backend/modules/chats.dart:767` + + +**Проблема:** lastMsgPlaceholder ('__komet_lastmsg_placeholder__') is a magic string written into the same last_msg_text DB column that stores real message text (line 748), then detected via string equality (line 767) to decide whether to reconcile. This overloads the text column with a semantic 'unknown/placeholder' state, so every future consumer of lastMsgText must remember to special-case the sentinel. The state is deliberate and documented, so real-world risk is low, but it is not type-checked. + + +**Решение:** Add an explicit nullable/boolean field (e.g. lastMsgIsPlaceholder on CachedChat backed by a dedicated DB column) instead of overloading the text column with a sentinel value, so the placeholder state is explicit and type-checked rather than string-matched. + +
+ +
+🟡 LOW · КОСТЫЛЬ · [S] — Call-setup paths throw generic untyped Exceptions instead of a typed error + + +**Где:** `lib/backend/modules/calls.dart:91`, `lib/backend/modules/calls.dart:102`, `lib/backend/modules/calls.dart:156`, `lib/backend/modules/calls.dart:167` + + +**Проблема:** initiateCall/joinByLink throw plain Exception('initiateCall: bad response')-style strings on failure, unlike webapp.dart's WebAppUnavailable (webapp.dart:79-86), a proper typed exception carrying a user-facing message. Callers can only catch generic Exception and string-match to distinguish failure kinds, and there is an established typed-exception pattern in the same layer to follow. + + +**Решение:** Introduce a typed exception (e.g. CallSetupException with a reason/userMessage) thrown consistently from both methods, mirroring WebAppUnavailable, so UI code can branch on failure kind instead of matching message text. + +
+ +
+🟡 LOW · КОСТЫЛЬ · [M] — Peer-Komet detection uses an unversioned magic-string handshake mixed with JSON frames + + +**Где:** `lib/core/calls/call_session.dart:99-100`, `lib/core/calls/call_session.dart:571-615` + + +**Проблема:** Detecting whether the remote peer is also Komet is done by opening a 'komet' data channel and exchanging literal strings 'AreYouKomet?' / 'YesImKomet😎' (lines 99-100), matched by exact equality in _onProbeMessage (610-614) alongside the {'t':'chat'} / {'t':'game'} JSON envelope carried on the same channel. There is no version field, so any future edit to either string breaks detection for older peers with no fallback, and two message conventions (raw string vs JSON) share one wire. + + +**Решение:** Fold the capability probe into the existing JSON envelope, e.g. {'t':'probe','v':1} / {'t':'probe-ack','v':1}, so the channel carries one message format and a version number is available for future negotiation. + +
+ +
+🟡 LOW · КОСТЫЛЬ · [S] — profile_options persisted as a hand-rolled comma-joined string instead of JSON, with silent parse-failure fallback + + +**Где:** `lib/core/storage/app_database.dart:76-88`, `lib/core/storage/app_database.dart:116` + + +**Проблема:** ProfileData.profileOptions (List) is serialized with `profileOptions?.join(',')` (toDbRow) and parsed back with `.split(',').map(int.parse)` inside a try/catch that swallows any failure and returns null, discarding the whole list. Every other structured column in the same schema (participants/admins/options, edit_history) uses jsonEncode/jsonDecode, so this one field uses a different, more fragile ad-hoc format for no apparent reason. + + +**Решение:** Store profile_options as `jsonEncode(profileOptions)` / read via `jsonDecode`, consistent with the other list/map columns, and drop the comma-split parser. Since existing rows hold the legacy comma format, either bump the schema version with a migration that rewrites the column, or make fromDbRow tolerate both formats during a transition. + +
+ +
+🟡 LOW · КОСТЫЛЬ · [S] — FLIP reorder measurement uses a bare catch-all and magic thresholds + + +**Где:** `lib/frontend/screens/chats/chat_list_screen.dart:2789-2797`, `lib/frontend/screens/chats/chat_list_screen.dart:2799-2811` + + +**Проблема:** Confirmed. `_measureContentY` wraps `RenderAbstractViewport.of(box).getOffsetToReveal(...)` in a bare `try { ... } catch (_) { return null; }` (2792-2796), swallowing any exception including genuine bugs, and `_runMove` discards deltas with unexplained magic thresholds `dy.abs() < 1.0 || dy.abs() > 2000` (2806). (Downgraded from medium and suggestion narrowed: the animation works and a wholesale rewrite to AnimatedList is disproportionate; the concrete defects are the blanket catch and the unnamed thresholds.) + + +**Решение:** Narrow the catch to the specific failure mode `getOffsetToReveal` can raise (or guard the precondition) so unexpected exceptions surface, and lift the `1.0`/`2000` thresholds into named constants so their intent is explicit rather than inline magic numbers. + +
+ +
+🟡 LOW · КОСТЫЛЬ · [S] — TOS-read flag stored via a raw SharedPreferences magic-string key (with a typo) inline in the widget + + +**Где:** `lib/frontend/screens/auth/login_screen.dart:98-105`, `lib/frontend/screens/auth/login_screen.dart:123-131` + + +**Проблема:** `_checkTOS`/`_markTOSRead` call `SharedPreferences.getInstance()` directly from the widget and read/write the flag with the literal string `'IsReadeTOS'` (note the 'Reade' typo), with no shared constant — unlike `ServerConfig.prefHostKey`/`prefPortKey` used by the sibling settings sheets in the same directory. A future screen or refactor that re-types the key correctly ('IsReadTOS') silently creates a divergent key, so the read state is lost and the TOS prompt reappears. + + +**Решение:** Add a named constant (e.g. `TermsConfig.readFlagKey`) alongside the other config constants, or move this into a small `TermsService` in core/storage that owns the key and read/write methods, matching the server/proxy config pattern. + +
+ + +### Backend parsing and model duplication (22 — 0 high) + +_Attachment/message/chat/folder parsing and JSON decode-with-fallback logic are re-implemented across construction paths with divergent behavior._ + +
+🟠 MED · ДУБЛЬ · [M] — Attachment/FORWARD/CONTROL parsing duplicated across three CachedMessage construction paths with inconsistent behavior + + +**Где:** `lib/backend/modules/messages.dart:446-463`, `lib/backend/modules/messages.dart:724-741`, `lib/backend/modules/messages.dart:534-541` + + +**Проблема:** CachedMessage.fromDbRow, MessagesModule._parseMessage and CachedMessage.fromPushPayload each re-implement 'if link.type==FORWARD build ForwardedMessageAttachment, else map attaches to MessageAttachment.fromMap, else detect AttachmentType.control' with real divergences: fromPushPayload does NOT handle FORWARD links and never sets isControl, and fromDbRow omits the whereType guard the other two use. A fix in one copy silently misses the others. + + +**Решение:** Add a single static helper on CachedMessage returning (List?, bool isControl) from a raw map, and call it from all three paths so FORWARD and control detection stay identical everywhere messages are built. + +
+ +
+🟠 MED · ДУБЛЬ · [M] — Read-mutate-save-bump boilerplate on untyped Map rows, repeated across ~6 methods + + +**Где:** `lib/backend/modules/chats.dart:367-394`, `lib/backend/modules/chats.dart:396-424`, `lib/backend/modules/chats.dart:426-453`, `lib/backend/modules/chats.dart:680-696`, `lib/backend/modules/chats.dart:1518-1538`, `lib/backend/modules/chats.dart:1596-1627` + + +**Проблема:** markRead, markUnread, applyOutgoing, _handleNotifMessage, setChatTitle and setChatMute each independently load chat rows via AppDatabase.loadChat, copy to a mutable Map, hand-edit string-keyed fields (row['last_msg_text'], row['unread_count'], row['dont_disturb_until']...), call AppDatabase.saveChats([row]) then _bump(). The DB column names are duplicated as raw string literals at every call site, so a typo silently no-ops instead of failing to compile, and a schema rename requires editing every occurrence by hand. Note CachedChat already has toDbRow() (used at line 897) but no copyWith(). + + +**Решение:** Add CachedChat.copyWith(...) and a single private helper such as `static Future _updateChat(accountId, chatId, CachedChat Function(CachedChat) mutate)` that loads the row, converts to CachedChat, applies a typed mutation via copyWith, converts back with toDbRow() once, saves, and bumps. Rewrite the call sites to use it, eliminating the repeated stringly-typed Map editing. + +
+ +
+🟠 MED · ДУБЛЬ · [S] — JSON payload decode-with-fallback duplicated; manual re-implementation of messagePreviewElements + + +**Где:** `lib/backend/modules/chats.dart:623-634`, `lib/backend/modules/chats.dart:729-738`, `lib/backend/modules/chats.dart:842-851` + + +**Проблема:** The pattern `final raw = ...['payload']; if (raw is String && raw.isNotEmpty) { try { Map.from(jsonDecode(raw) as Map) } catch(_) { fallback } }` is written out separately in _handleNotifMessage edit-merge (623-634), _reconcileLastMessage (719-738), and _handleNotifMsgReactionsChanged (842-851). Additionally, _reconcileLastMessage at 729-738 re-implements inline exactly what the existing static helper messagePreviewElements(msg) already does (extract elements list and jsonEncode it when text is non-empty). + + +**Решение:** Extract a single `static Map? _decodePayload(dynamic raw)` helper used by all decode sites, and replace the manual element extraction in _reconcileLastMessage (729-738) with a direct call to the existing messagePreviewElements(payload) helper. + +
+ +
+🟠 MED · ДУБЛЬ · [M] — Folder JSON parsing logic duplicated across ChatFolder and FoldersModule + + +**Где:** `lib/backend/models/chat_folder.dart:31-37`, `lib/backend/models/chat_folder.dart:60-66`, `lib/backend/models/chat_folder.dart:74-80`, `lib/backend/modules/folders.dart:96-104`, `lib/backend/modules/folders.dart:138-150`, `lib/backend/modules/folders.dart:167-179`, `lib/backend/modules/folders.dart:216-224` + + +**Проблема:** ChatFolder.fromJson repeats the identical int-list-parsing lambda (e is int ? e : (e is String ? int.tryParse(e) ?? 0 : 0)) verbatim for include, favorites and options. Separately, FoldersModule.loadFolders (96-104), applyPayload (138-150) and applyFromLoginConfig (167-179) each re-implement the same 'decode JSON list -> map to ChatFolder.fromJson (with per-item Map cast/try-catch) -> collect' block, and setFolderFavorites (216-224) re-decodes the raw sync snapshot into a folder list by hand instead of reusing loadFolders(accountId). + + +**Решение:** Add a private `static List _parseIntList(dynamic raw)` used by include/favorites/options, a private `static List _parseFolderList(List json)` used by loadFolders/applyPayload/applyFromLoginConfig, and have setFolderFavorites call loadFolders(accountId) (noting it also sorts, which is harmless before re-persist) instead of duplicating the decode-and-parse logic. + +
+ +
+🟠 MED · ДУБЛЬ · [M] — DraftStore and ChatWallpaperStore reimplement the same per-chat SharedPreferences-JSON store + + +**Где:** `lib/core/storage/draft_store.dart:6-57`, `lib/core/storage/chat_wallpaper_store.dart:85-191` + + +**Проблема:** Both classes independently implement the same shell: a `'$accountId/$chatId'` composite key, a `_loaded`-guarded in-memory map hydrated once from a single JSON blob in SharedPreferences (jsonDecode wrapped in silent try/catch), a `ValueNotifier revision` bumped on every mutation, and a save path that re-serializes the whole map with jsonEncode. Verified byte-for-byte in structure; the only real differences are the value type (String vs ChatWallpaper) and the wallpaper store's extra file bookkeeping (_deleteImage). + + +**Решение:** Factor the common shell into a generic `abstract class PerChatJsonStore` owning `_key`, `_loaded`, `revision`, `load()` and the persist path, parameterized by `T? Function(Object?) fromJson` / `Object? Function(T) toJson`. DraftStore and ChatWallpaperStore become thin subclasses supplying (de)serialization and any side effects (file cleanup for wallpapers). No code comments per conventions. + +
+ +
+🟠 MED · ДУБЛЬ · [S] — countries.dart parses and discards a full duplicate phone-metadata table for every country + + +**Где:** `lib/core/config/countries.dart:62-89`, `lib/core/config/countries.dart:287-288` + + +**Проблема:** `_countriesRuJson` (declared at line 287) is a full ~195-entry JSON blob carrying `phoneCode`, `phoneDigits`, `phoneMask`, `phoneGroupSizes` and `phoneGroupSeparators` for every country — a structural duplicate of `_countriesEnJson`. But `_buildCountries()` only reads `item['alpha2']` and `item['name']` from `ruList` (countries.dart:68-71) to build `ruByCode`; all the phone fields in the Russian blob are decoded by `jsonDecode` and then thrown away. Maintaining two full phone tables means any mask/group-size fix must be applied twice or the tables silently drift apart. + + +**Решение:** Shrink `_countriesRuJson` to just `{alpha2, name}` pairs, or better, merge into one dataset shaped `[{alpha2, en, ru, phoneCode, phoneDigits, phoneMask, phoneGroupSizes, phoneGroupSeparators}]` so there is a single source of truth per country instead of two lists that must be kept in sync by hand. + +
+ +
+🟠 MED · ДУБЛЬ · [M] — The render-to-JPEG-file pipeline is copy-pasted across all three photo editors + + +**Где:** `lib/frontend/widgets/attachment/photo_editor.dart:376`, `lib/frontend/widgets/attachment/photo_editor.dart:998`, `lib/frontend/widgets/attachment/photo_editor.dart:2322` + + +**Проблема:** PhotoCropEditor._bake, PhotoDrawEditor._bake and PhotoAdjustEditor._bake each independently repeat the identical tail: picture.toImage(w,h) -> picture.dispose() -> toByteData(rawRgba) -> image.dispose() -> null-check -> encodeRgbaToJpeg -> null-check -> getTemporaryDirectory() -> build a `komet__.jpg` path -> writeAsBytes. Any change to JPEG quality, the bd==null handling, temp naming, or EXIF orientation must be made in three places. + + +**Решение:** Extract a single `Future rasterPictureToJpegFile(ui.Picture picture, int width, int height, {required String prefix})` helper in a core/media utility that performs toImage -> toByteData -> encodeRgbaToJpeg -> write-temp-file once, called by all three editors with their own prefix and computed dimensions. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — Pending-request bookkeeping is split across two maps keyed by the same seq + + +**Где:** `lib/core/transport/dispatcher.dart:15`, `lib/core/transport/dispatcher.dart:16`, `lib/core/transport/dispatcher.dart:55`, `lib/core/transport/dispatcher.dart:56`, `lib/core/transport/dispatcher.dart:91`, `lib/core/transport/dispatcher.dart:134` + + +**Проблема:** `_pendingRequests` (Completer per seq, dispatcher.dart:15) and `_requestTimestamps` (DateTime per seq, dispatcher.dart:16) are two separate maps kept in lockstep by hand at every call site: `registerPending` inserts into both (55-56), `dispatch()` removes from both (91-92), `_cleanupStaleRequests` removes from both (134-135), `clearPending` clears both (149-150). Every future change must remember to touch both maps, and any missed pairing silently leaks or desynchronizes entries. + + +**Решение:** Merge into a single `Map` where `_PendingRequest` holds `{Completer completer, DateTime sentAt}`, halving map operations on every register/complete/timeout/clear path and removing the risk of the two maps disagreeing. + +
+ +
+🟡 LOW · ДУБЛЬ · [L] — Opcode numeric values and their human-readable names are hand-duplicated in two parallel structures + + +**Где:** `lib/core/protocol/opcode_map.dart:9`, `lib/core/protocol/opcode_map.dart:204`, `lib/core/protocol/opcode_map.dart:207`, `lib/core/protocol/opcode_map.dart:209` + + +**Проблема:** Each of the ~140 opcode constants (opcode_map.dart:9-204) has an independently hand-typed entry in the `_names` map (209-365) mapping the same value to a label. Nothing enforces the two stay in sync; a new/renamed opcode missing from `_names` falls back to `'UNKNOWN($opcode)'` (line 207) silently. The failure mode is purely cosmetic (log labels), hence low severity, but it is genuine duplication across ~140 entries. + + +**Решение:** Model this as a single source of truth, e.g. a Dart enhanced enum `enum Opcode { ping(1, 'PING'), debug(2, 'DEBUG'), ... }` carrying both wire value and label, with a lookup-by-value helper for decoding, making code/name drift structurally impossible. Note this is a large change because `Opcode.` is used as a raw int at many call sites; if that migration is too costly, a lighter alternative is an assertion/test that verifies every declared opcode has a `_names` entry. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — initiateCall and joinByLink duplicate internal-params parsing + + +**Где:** `lib/backend/modules/calls.dart:95-108`, `lib/backend/modules/calls.dart:160-171` + + +**Проблема:** Both methods independently JSON-decode a string field (internalCallerParams vs internalParams), extract endpoint with identical null-throw handling, and dig id['internal'] for callsUserId. Any protocol fix must be applied twice and is easy to miss in one path (e.g. joinByLink never extracts peerExternalId the way initiateCall does at line 107-108). + + +**Решение:** Extract a private helper such as _parseCallerEndpoint(Map payload, String key) returning endpoint/callsUserId/external, used by both methods, throwing one typed exception on a missing endpoint. + +
+ +
+🟡 LOW · ДУБЛЬ · [M] — Sticker set/item batch-fetch-and-cache logic duplicated + + +**Где:** `lib/backend/modules/stickers.dart:120-137`, `lib/backend/modules/stickers.dart:139-160` + + +**Проблема:** _ensureSetMetas and ensureStickers are structurally identical: filter already-cached ids, chunk by 100, call Opcode.assetsGetByIds, guard on isOk/payload shape, iterate the response list, and populate a cache map — differing only in the 'type' string, the response list key ('stickerSets' vs 'stickers'), and the model factory (StickerSet.fromMap vs StickerItem.fromMap). A protocol change to this fetch shape must be edited in both. + + +**Решение:** Factor out a generic _fetchAndCache({required String type, required List ids, required String listKey, required T Function(Map) fromMap, required Map cache}) used by both methods. + +
+ +
+🟡 LOW · ДУБЛЬ · [M] — connect() duplicates its failure-cleanup sequence across two catch blocks + + +**Где:** `lib/backend/api.dart:113-122`, `lib/backend/api.dart:152-163` + + +**Проблема:** The connect-failure catch (113-122) and the handshake-failure catch (152-163) repeat nearly the same sequence: _cleanup(), _setSessionState(disconnected), _armBypassIfPossible(...), _scheduleReconnect() — the handshake path additionally awaits _connection.disconnect(). Adding a new failure path means re-deriving this sequence by hand, which is easy to get subtly wrong. + + +**Решение:** Extract a shared private _handleConnectFailure(Object error, {required String phase}) that performs the cleanup/bypass-arming/reconnect-scheduling once (with socket disconnect where needed), and call it from both catch blocks. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — Architecture-from-Platform.version parsing duplicated verbatim for Linux/Windows + + +**Где:** `lib/backend/api.dart:222-225`, `lib/backend/api.dart:238-241` + + +**Проблема:** The identical fragile substring parse of Platform.version (substring(indexOf('_') + 1, length - 1)) to extract CPU architecture is repeated verbatim in the Linux and Windows branches of sendHandshake. + + +**Решение:** Extract a small _archFromPlatformVersion() helper called from both branches so the fragile parsing exists in one place. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — Attachment-to-CloudFile extraction loop duplicated between fetchFiles and fetchLatestFile + + +**Где:** `lib/backend/modules/cloud_storage.dart:116-140`, `lib/backend/modules/cloud_storage.dart:143-168` + + +**Проблема:** Both methods contain the identical nested loop — for each message, for each attachment, check `a is FileAttachment && a.name != null`, then build a CloudFile with the same seven fields — differing only in whether they collect all matches or return the first (optionally id-matching) one. + + +**Решение:** Extract a shared helper (e.g. CloudFile _toCloudFile(Message msg, FileAttachment a, int chatId, int accountId), or an Iterable generator over a message list) used by fetchFiles (collect all) and fetchLatestFile (early return), keeping the CloudFile construction in one place. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — Duplicated pseudo-random filename and multipart-boundary generation + + +**Где:** `lib/backend/modules/file_uploader.dart:174-175`, `lib/backend/modules/file_uploader.dart:397-398`, `lib/backend/modules/file_uploader.dart:263`, `lib/backend/modules/file_uploader.dart:323` + + +**Проблема:** `(DateTime.now().microsecondsSinceEpoch & 0x7FFFFFFF).toString()` is repeated verbatim to synthesize an upload filename in uploadMediaFile and uploadVideoFile, and `'----KometBoundary${DateTime.now().microsecondsSinceEpoch}'` is repeated verbatim to build a multipart boundary in uploadImage and uploadPhoto. + + +**Решение:** Factor both into small private helpers String _syntheticFilename() and String _multipartBoundary(), used from all four call sites, so the generation scheme lives in one place. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — Spoofed third-party User-Agent literal hardcoded and duplicated across header builders + + +**Где:** `lib/backend/modules/file_uploader.dart:252`, `lib/backend/modules/file_uploader.dart:516` + + +**Проблема:** The exact string 'OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)' is hardcoded twice, in _writeHeaders and _writeImageHeaders, and can silently drift out of sync when the spoofed version is bumped. The third header builder, _okCdnRequest (lines 477-489), sends no User-Agent at all — the same inconsistency in the other direction. + + +**Решение:** Hoist the User-Agent into a single shared constant referenced by all header builders (including _okCdnRequest, which currently omits it). If the app already maintains a device/spoofing fingerprint for the main transport connection, source it from there so the CDN UA and the protocol UA stay consistent — but verify that layer actually owns this string before wiring it, rather than assuming it does. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — Enum <-> String parsing reimplemented ad hoc and inconsistently per settings class + + +**Где:** `lib/core/config/app_bubble_behavior.dart:24-27`, `lib/core/config/app_bubble_shape.dart:24-27`, `lib/core/config/app_message_actions_style.dart:23-26`, `lib/core/config/app_theme_mode.dart:23-34`, `lib/core/config/app_chat_chrome.dart:11-31`, `lib/core/config/app_visual_style.dart:11-25` + + +**Проблема:** Six settings classes each hand-write their own enum<->String round trip with no shared helper: three compare against `Enum.x.name` one member at a time (bubble behavior/shape, message actions), AppThemeMode switches on raw string literals `'light'/'dark'/'schedule'` instead of `.name` (23-34), AppChatChrome uses a manual switch on raw literals `'color'/'blur'/'none'` for both parse and encode (11-31) rather than `.name`, and AppVisualStyle compares/encodes the raw literal `'glossy'`/`'materialYou'` (11-25). Where raw literals are used they can silently drift from the enum's `.name`, and every added member requires updating separate hand-written mappings in lockstep. + + +**Решение:** Add one generic helper `T enumFromName(List values, String? raw, T fallback) => values.firstWhere((v) => v.name == raw, orElse: () => fallback);`, paired with `.name` for encoding, across all enum-backed settings. This folds naturally into the `PersistedEnum` abstraction from the load/save duplication finding. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — FormatRange flattening loop duplicated inside RichMessageController + + +**Где:** `lib/frontend/widgets/rich_message_controller.dart:57`, `lib/frontend/widgets/rich_message_controller.dart:208` + + +**Проблема:** `elementsForSend()` (57-69) and `buildTextSpan()` (208-219) contain the identical `_intervals.forEach((format, list) { for (final interval in list) ranges.add(FormatRange(format: format, start: interval.start, length: interval.end - interval.start)); })` loop to flatten the `_intervals` map into a `List` — once to serialize for sending, once to feed the span builder. + + +**Решение:** Extract a private `List _toFormatRanges()` and call it from both. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — Photo-grid corner-radius logic duplicated verbatim between two-photo and grid layouts + + +**Где:** `lib/frontend/widgets/message_bubble.dart:1665`, `lib/frontend/widgets/message_bubble.dart:1698` + + +**Проблема:** `_buildTwoPhotos` (1665-1674) and `_buildPhotoGrid` (1698-1707) contain byte-identical matchTop/matchBottom + topR/bottomL/bottomR corner-radius derivation. A future bubble-shape tweak applied to one and missed on the other would silently diverge. (Note: the candidate also listed `_buildSinglePhoto` at 1553, but that method uses a genuinely different formula — `_smallRadius` fallbacks and an isMe-conditional bottomL — so it is not a true duplicate and a single 3-boolean helper cannot cover all three.) + + +**Решение:** Extract `BorderRadius _multiPhotoCornerRadius({required bool matchTop, required bool matchBottom, required bool isMe})` shared by `_buildTwoPhotos` and `_buildPhotoGrid`. Leave `_buildSinglePhoto` as-is (or give it its own clearly-named helper) since its corner formula differs. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — Country display-name selection logic duplicated instead of centralized on the model + + +**Где:** `lib/frontend/screens/auth/login_screen.dart:152-155`, `lib/frontend/screens/auth/select_country_screen.dart:56,130` + + +**Проблема:** The `lang == 'ru' ? country.ru : country.en` ternary for picking a localized country display name is written in `LoginScreen._countryDisplayName` and again inline in `SelectCountryScreen.build` (lang computed at 56, ternary at 130), rather than living once on `CountryName`. If a third locale or a fallback rule is added, the two sites can be updated inconsistently. + + +**Решение:** Add a `String displayName(String languageCode)` method to `CountryName` in core/config/countries.dart and call `country.displayName(lang)` from both screens. + +
+ +
+🟡 LOW · ОПТ · [S] — Poll.withStateMap re-derives state by serializing back to a Map and re-parsing instead of merging directly + + +**Где:** `lib/models/poll.dart:58-69`, `lib/models/poll.dart:71-114` + + +**Проблема:** withStateMap (called from polls.dart:81 after every vote) rebuilds a throwaway List from the already-typed `answers`, wraps it with the new stateMap, and calls Poll.fromServerMap again, repeating the entire resultsById construction and answer-merge loop. It works only because the merge logic is duplicated through a Map round trip rather than factored out. Minor (small data, infrequent), hence low. + + +**Решение:** Extract the resultsById build + answer->PollAnswer merge from fromServerMap into a private static helper taking the raw answer list (or typed answers) plus the state map, and have both fromServerMap and withStateMap call it directly, dropping the intermediate Map re-encoding. + +
+ +
+🟡 LOW · СОМНИТ · [L] — Attachment-kind classification re-derived independently in several places + + +**Где:** `lib/frontend/widgets/message_bubble.dart:173`, `lib/frontend/widgets/message_bubble.dart:178`, `lib/frontend/widgets/message_bubble.dart:185`, `lib/frontend/widgets/message_bubble.dart:217`, `lib/frontend/widgets/message_bubble.dart:772`, `lib/frontend/widgets/message_bubble.dart:1214` + + +**Проблема:** 'What kind of content is this message' is answered independently by `_hasShareAttachment` (173), `_isVideoNote` (178), `_isSticker` (185), `_computeContentType` (217, a first-item switch), `_reactionsUnderBubble` (772, its own first/whereType checks) and `_buildAttachmentContent` (1214, a third independent whereType dispatch chain in a different priority order than `_computeContentType`). All share the 'first non-keyboard attachment determines the kind' assumption but each encodes it slightly differently, so a new attachment type or priority rule must be updated in lock-step across all of them. (The candidate's claim that `_computeContentType` and `_buildAttachmentContent` could disagree is overstated — `_buildAttachmentContent` only runs when contentType is already `attachment` — so the risk is maintenance drift, not a live rendering mismatch, hence low severity.) + + +**Решение:** Introduce one `_MessageKind` computed once per message from a single attachment scan, exposing `.isShare`/`.isVideoNote`/`.isSticker`/`.contentType`/`.reactionsUnderBubble`, so exactly one place encodes the attachment priority order and every getter/builder reads from it. + +
+ + +### No-comments convention violations (5 — 0 high) + +_Explicit project rule forbids code comments, yet several files carry TODOs, banners, and explanatory comments._ + +
+🟡 LOW · КОСТЫЛЬ · [S] — Comments left in code violate the project's explicit no-comments convention + + +**Где:** `lib/frontend/screens/calls/call_screen.dart:867`, `lib/frontend/screens/calls/calls_tab.dart:151`, `lib/frontend/screens/contacts/contacts_tab.dart:107` + + +**Проблема:** CLAUDE.md/AGENTS.md mandate 'No comments in code,' but a garbled inline TODO ('Бля иконку кометы в код дайтtе' мориарти 00. ал.о', line 867) sits above the Komet call-info icon button, and two other files carry throwaway comments ('// Open call details or initiate call' at calls_tab.dart:151, '// Sort contacts by first name' at contacts_tab.dart:107). All three confirmed. + + +**Решение:** Remove the comments; track the unfinished-work TODO in an issue tracker instead of an inline note, and rely on self-documenting names for the rest. + +
+ +
+🟡 LOW · СОМНИТ · [S] — Leftover TODOs and explanatory comments violate the no-comments convention + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:4350`, `lib/frontend/screens/chats/chat_screen.dart:1332` + + +**Проблема:** CLAUDE.md/AGENTS.md state 'No comments in code — write self-documenting code instead,' yet the render path carries unresolved `// TODO: Локализация` / `// TODO: Cклонения` markers (4350-4351), and other spots carry explanatory comments (e.g. 1332-1335). The TODOs also flag known-incomplete localization work sitting directly in build(). + + +**Решение:** Remove the comments per the project convention, expressing intent through naming/structure, and track the localization gap as an issue rather than an in-code TODO. + +
+ +
+🟡 LOW · СОМНИТ · [S] — Existing code comments violate the project's no-comments convention + + +**Где:** `lib/frontend/screens/chats/chat_list_screen.dart:207-209`, `lib/frontend/screens/chats/chat_list_screen.dart:1182`, `lib/frontend/screens/chats/chat_list_screen.dart:1555-1556` + + +**Проблема:** Confirmed against CLAUDE.md's 'No comments in code' rule. A doc comment above `_getChatsBody` explains the memoization hack (207-209), an inline comment annotates the nav-tab haptic (1182), and a two-line comment explains the `ContactCache.isOfficial` vs `chat.isOfficial` distinction (1555-1556). + + +**Решение:** Remove the comments and express intent through structure/naming: a named getter such as `bool _isVerifiedContact(int secondId, CachedChat chat)` around the verified-badge check makes the two-source distinction self-documenting; the `_getChatsBody` comment is subsumed by the memoization rewrite. + +
+ +
+🟡 LOW · СОМНИТ · [S] — chat_info_screen.dart uses banner/inline comments, violating the project's 'no comments in code' convention + + +**Где:** `lib/frontend/screens/chats/chat_info_screen.dart:67`, `lib/frontend/screens/chats/chat_info_screen.dart:75`, `lib/frontend/screens/chats/chat_info_screen.dart:222`, `lib/frontend/screens/chats/chat_info_screen.dart:298`, `lib/frontend/screens/chats/chat_info_screen.dart:323`, `lib/frontend/screens/chats/chat_info_screen.dart:1155` + + +**Проблема:** CLAUDE.md/AGENTS.md mandate 'No comments in code. Write self-documenting code instead.' This file carries roughly a dozen '// ─── SECTION ───' banner comments plus inline '// DIALOG' / '// CHAT' field-group comments; it is the only file in this audit set that does so (search_screen, create_group_flow, poll_create_screen, scheduled_messages_screen contain none). + + +**Решение:** Remove the banner and inline comments. The section methods (_subtitle, _buildActions, _memberTile, _formatLastSeen, etc.) are already self-naming, so no widget-splitting is required to preserve readability. + +
+ +
+🟡 LOW · СОМНИТ · [S] — Custom painter contains explanatory comments, violating the project's no-comments convention + + +**Где:** `lib/frontend/screens/profile/settings_tab.dart:868`, `lib/frontend/screens/profile/settings_tab.dart:877`, `lib/frontend/screens/profile/settings_tab.dart:880` + + +**Проблема:** `_SpoilerPainter.paint()` has three inline comments ('// Draw the background', '// Draw "noisy" particles', '// Simple noise effect with dots using animation value for movement'). The project convention (AGENTS.md via CLAUDE.md) is 'No comments in code — write self-documenting code instead'. Trivial, but it is an explicit documented rule and this is the sole violation in the audited unit. + + +**Решение:** Remove the comments and split paint() into small named private methods (e.g. `_paintBackground(canvas, size, paint)`, `_paintNoise(canvas, size)`) so naming documents the intent. + +
+ + +### Прочее / Uncategorized (7 — 0 high) + +
+🟠 MED · КОСТЫЛЬ · [L] — Fragile hand-rolled widget-tree memoization keyed on identityHashCode + + +**Где:** `lib/frontend/screens/chats/chat_list_screen.dart:207-231`, `lib/frontend/screens/chats/chat_list_screen.dart:810-841` + + +**Проблема:** Confirmed. `_getChatsBody()` (210-231) and `_chatsForPageIndex()` (810-841) build manual cache keys via `Object.hashAll`/`Object.hash` over `identityHashCode(_chats)`/`identityHashCode(_folders)` plus hand-picked fields, then reuse a cached `Widget`/`List`. This only stays correct because `_chats`/`_folders` are always replaced wholesale and because every field the cached subtree reads must be manually added to the key. `_enteringChatIds` is read inside the cached subtree (line 2260, `isNew: _enteringChatIds.contains(id)`) but is NOT part of the `_getChatsBody` key (211-225); it only works because it is coincidentally mutated in the same `setState` as `_chats` (696). Any future change that updates a read field without updating the key list silently serves a stale subtree. (Downgraded from high: currently correct, so this is a maintainability hazard, not a live bug.) + + +**Решение:** Extract the folder header/list into real `StatelessWidget`/`StatefulWidget` classes with typed constructor params (`chats`, `folders`, `selectedFolderId`, `enteringChatIds`, ...) and rely on Flutter's own element diffing plus `const`/`RepaintBoundary` instead of a custom identity-hash cache that must be kept in sync by hand. + +
+ +
+🟠 MED · ДУБЛЬ · [S] — DIALOG peer-id derivation (chatId XOR myId) duplicated instead of using the existing _resolveOtherId helper + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:2158`, `lib/frontend/screens/chats/chat_screen.dart:2170`, `lib/frontend/screens/chats/chat_screen.dart:2180`, `lib/frontend/screens/chats/chat_screen.dart:3065`, `lib/frontend/screens/chats/chat_screen.dart:3119` + + +**Проблема:** `widget.chatId ^ _myId` — the undocumented assumption that a DIALOG chat id encodes both participant ids via XOR — is re-derived inline in _loadOtherPresence, _onPresenceChanged, _withOnlineDot, _startCall and _seedPresenceFromChat, each with slightly different `<= 0` / `> 0` guards. The screen already has _resolveOtherId() (line 3440) which encapsulates the DIALOG check, the XOR and the positivity guard, but these sites bypass it. + + +**Решение:** Route all sites through _resolveOtherId(); better, move the encoding into the model/module layer (e.g. CachedChat.otherParticipantId(myId)) so the assumption is documented and owned by one layer instead of copy-pasted through the UI. + +
+ +
+🟠 MED · ДУБЛЬ · [S] — Folder-page-index resolution from PageController re-implemented three times + + +**Где:** `lib/frontend/screens/chats/chat_list_screen.dart:864-873`, `lib/frontend/screens/chats/chat_list_screen.dart:925-936`, `lib/frontend/screens/chats/chat_list_screen.dart:1480-1493` + + +**Проблема:** Confirmed. `_isChatScrollControllerActive` (864-873), `_activeChatScrollController` (925-936), and the `NotificationListener.onNotification` closure (1480-1493) each independently reimplement 'if `_folderPageController` has no clients fall back to `_selectedFolderIndex`, else read `.page`, round, and clamp'. The three copies already differ slightly in clamp bounds (`_folderPageCount - 1` vs `_folderChatScrollControllers.length - 1`), which is exactly the drift this invites. + + +**Решение:** Extract one `int _currentFolderPageIndex()` returning the resolved, clamped page index and have all three sites call it instead of re-deriving `p.round().clamp(...)` inline. + +
+ +
+🟡 LOW · ДУБЛЬ · [S] — DIALOG 'other participant' lookup duplicated in two places + + +**Где:** `lib/frontend/screens/chats/chat_list_screen.dart:774-780`, `lib/frontend/screens/chats/chat_list_screen.dart:1545-1551` + + +**Проблема:** Confirmed. Both `_prefetchContactsForChats` (774-780) and the item builder (1545-1551) hand-roll the same 'find the participant id that isn't me, break on first match' loop over `chat.participants.entries`. Not verbatim (one accumulates into a set, one picks a single id) but the core participant-resolution logic is duplicated, so any change to participant semantics must be edited in two unrelated sites. + + +**Решение:** Add a helper on `CachedChat`, e.g. `int otherParticipantId(int myId) => participants.keys.firstWhere((k) => k != myId, orElse: () => myId);`, and use it from both sites. + +
+ +
+🟡 LOW · ДУБЛЬ · [M] — MessageAttachment.toMap() is unused dead code that has already drifted out of sync with fromMap + + +**Где:** `lib/models/attachment.dart:74`, `lib/models/attachment.dart:109-119`, `lib/models/attachment.dart:751-761`, `lib/models/attachment.dart:769-770` + + +**Проблема:** Verified via grep that `toMap()` has zero call sites outside attachment.dart; the only invocations are three internal nested calls (preview.toMap at 281, image.toMap at 586, button.toMap at 671), whose enclosing toMap() methods are themselves never called externally. So the abstract requirement (74) and all twelve implementations are effectively dead. It also already drifted: ForwardedMessageAttachment.toMap() (751-761) omits originalAttachments and originalContact, so if this were ever wired up for caching/outbox a forwarded message would lose its nested data on the round trip. + + +**Решение:** If nothing serializes attachments back to a Map (current state), delete the abstract `toMap()` and the twelve implementations to remove the unused, unverified surface. If offline outbox/cache persistence is planned, first fix ForwardedMessageAttachment.toMap() to include originalAttachments/originalContact and add a fromMap(toMap(x)) round-trip test. + +
+ +
+🟡 LOW · СОМНИТ · [S] — Send/record button uses four-deep nested ValueListenableBuilders + + +**Где:** `lib/frontend/screens/chats/chat_screen.dart:5851-5938` + + +**Проблема:** The send/mic/video button nests four `ValueListenableBuilder`s (`_hasText` -> `_voiceLocked` -> `_isRecordingVoice` -> `_videoNoteMode`) whose final visual/behavior depends on reading all four together. This is primarily a readability/nesting smell rather than a real perf win (a merged listener does not reduce rebuild count meaningfully), but the four-level indentation obscures the button logic. + + +**Решение:** Collapse into a single `ListenableBuilder(listenable: Listenable.merge([_hasText, _voiceLocked, _isRecordingVoice, _videoNoteMode]))` reading `.value` from each once — matching the merge pattern already used at line 4581. + +
+ +
+🟡 LOW · СОМНИТ · [L] — KometAppState mixes theming/locale, network-notification listening, and call routing in one root State + + +**Где:** `lib/main.dart:285-396`, `lib/main.dart:361-395` + + +**Проблема:** KometAppState owns theme/locale/font handling plus VPN-bypass and server-error stream subscriptions with their debounce/display logic (361-395) plus incoming-call listening/routing (324-326). Per the project's documented layered architecture (state/ holds ChangeNotifier state consumed by UI; business logic shouldn't live in widgets), the stream-subscription/notification/call-gating logic is UI-layer code doing service work. Factually accurate, but this is a large refactor of a currently-working root widget, so low priority. + + +**Решение:** Optionally split the unrelated concerns into focused units under state/ or core/: a NotificationRouter service owning the VPN-bypass and server-error subscriptions plus debounce, and an IncomingCallPresenter owning the incoming-call subscription/routing, consumed by KometAppState as listenables so it stays focused on theming/locale/MaterialApp wiring. Weigh against the effort since the current code works. + +
diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 693822e..8852943 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -51,6 +51,8 @@ class Api { String? spoofScope; + static bool _tzInitialized = false; + List? _registrationCountries; List get registrationCountries => @@ -99,8 +101,9 @@ class Api { final useBypass = _bypassActive && bypassArmed; // Попытку через VPN ограничиваем по времени, чтобы быстро понять, // что туннель не пропускает, и переключиться на обход. - final attemptTimeout = - bypassArmed && !useBypass ? const Duration(seconds: 8) : null; + final attemptTimeout = bypassArmed && !useBypass + ? const Duration(seconds: 8) + : null; try { final endpoint = await ServerConfig.loadEndpoint(); @@ -111,13 +114,13 @@ class Api { timeout: attemptTimeout, ); } catch (e) { - logger.e('Не удалось подключиться: $e'); - if (_sessionState != SessionState.disconnected) { - _cleanup(); - _setSessionState(SessionState.disconnected); - _armBypassIfPossible(bypassArmed, useBypass, 'подключение не удалось'); - _scheduleReconnect(); - } + await _handleConnectFailure( + e, + phase: 'Не удалось подключиться', + bypassArmed: bypassArmed, + useBypass: useBypass, + bypassWhy: 'подключение не удалось', + ); return; } @@ -150,16 +153,32 @@ class Api { logger.e('Хэндшейк отклонён: ${response.payload}'); } } catch (e) { - logger.e('Ошибка хэндшейка: $e'); - // Сокет подключился (через VPN), но сервер не ответил на хэндшейк — - // путь нерабочий: рвём соединение и пробуем мимо VPN. - if (_sessionState != SessionState.disconnected) { - _cleanup(); - await _connection.disconnect(); - _setSessionState(SessionState.disconnected); - _armBypassIfPossible(bypassArmed, useBypass, 'хэндшейк не прошёл'); - _scheduleReconnect(); - } + await _handleConnectFailure( + e, + phase: 'Ошибка хэндшейка', + bypassArmed: bypassArmed, + useBypass: useBypass, + bypassWhy: 'хэндшейк не прошёл', + disconnectSocket: true, + ); + } + } + + Future _handleConnectFailure( + Object error, { + required String phase, + required bool bypassArmed, + required bool useBypass, + required String bypassWhy, + bool disconnectSocket = false, + }) async { + logger.e('$phase: $error'); + if (_sessionState != SessionState.disconnected) { + _cleanup(); + if (disconnectSocket) await _connection.disconnect(); + _setSessionState(SessionState.disconnected); + _armBypassIfPossible(bypassArmed, useBypass, bypassWhy); + _scheduleReconnect(); } } @@ -206,7 +225,10 @@ class Api { int buildNumber = SpoofingService.hardcodedBuildNumber; String screen = '420dpi 420dpi 1080x2340'; - tz.initializeTimeZones(); + if (!_tzInitialized) { + tz.initializeTimeZones(); + _tzInitialized = true; + } final timeZoneName = await FlutterTimezone.getLocalTimezone(); String timezone = timeZoneName.identifier; String locale = 'ru'; @@ -219,10 +241,7 @@ class Api { if (Platform.isLinux) { final linuxInfo = await deviceInfo.linuxInfo; osVersion = linuxInfo.name; - architecture = Platform.version.substring( - Platform.version.indexOf('_') + 1, - Platform.version.length - 1, - ); + architecture = _archFromPlatformVersion(); } else if (Platform.isIOS) { final iosInfo = await deviceInfo.iosInfo; osVersion = iosInfo.systemVersion; @@ -235,10 +254,7 @@ class Api { } else if (Platform.isWindows) { final windowsInfo = await deviceInfo.windowsInfo; osVersion = windowsInfo.productName; - architecture = Platform.version.substring( - Platform.version.indexOf('_') + 1, - Platform.version.length - 1, - ); + architecture = _archFromPlatformVersion(); } final spoofed = await SpoofingService.getSpoofedSessionData( @@ -339,6 +355,29 @@ class Api { ); } + Future?> sendRequestMap( + int opcode, + Map payload, + ) async { + final response = await sendRequest(opcode, payload); + if (!response.isOk || response.payload is! Map) return null; + return response.payload as Map; + } + + Future sendRequestOk(int opcode, Map payload) async { + final response = await sendRequest(opcode, payload); + return response.isOk; + } + + Future sendRequestOrThrow( + int opcode, + Map payload, + ) async { + final response = await sendRequest(opcode, payload); + throwIfPacketError(response); + return response; + } + /// Вешает обработчик на пуши с указанным опкодом. void registerPushHandler(int opcode, void Function(Packet) handler) { _dispatcher.registerHandler(opcode, handler); @@ -373,7 +412,16 @@ class Api { } Future _onDataReceived(Uint8List data) async { - final rawPackets = _receiver.feed(data); + final List rawPackets; + try { + rawPackets = _receiver.feed(data); + } on ReceiverOverflowException catch (e) { + logger.e('$e — форсируем реконнект'); + if (_sessionState != SessionState.disconnected) { + unawaited(_forceReconnect()); + } + return; + } for (final raw in rawPackets) { final Packet packet; try { @@ -383,10 +431,7 @@ class Api { continue; } TrafficMonitor.instance.recordIncoming(packet, raw.length); - if (packet.isError && - packet.payload is Map && - (packet.payload['message'] == 'FAIL_LOGIN_TOKEN' || - packet.payload['message'] == 'FAIL_WRONG_PASSWORD')) { + if (packet.isError && isSessionExpiredPayload(packet.payload)) { _sessionExpiredController.add( SessionExpiredException(messageFromErrorPayload(packet.payload)), ); @@ -465,6 +510,11 @@ class Api { } } + static String _archFromPlatformVersion() { + final v = Platform.version; + return v.substring(v.indexOf('_') + 1, v.length - 1); + } + static List? _parseRegistrationCountries(dynamic payload) { if (payload is! Map) return null; final raw = payload['reg-country-code']; diff --git a/lib/backend/models/chat_folder.dart b/lib/backend/models/chat_folder.dart index 8392a6c..a5b3d02 100644 --- a/lib/backend/models/chat_folder.dart +++ b/lib/backend/models/chat_folder.dart @@ -23,47 +23,39 @@ class ChatFolder { this.options, }); + static List? _parseIntList(dynamic raw) { + return (raw as List?)?.map((e) { + if (e is int) return e; + if (e is String) return int.tryParse(e) ?? 0; + return 0; + }).toList(); + } + factory ChatFolder.fromJson(Map json) { return ChatFolder( id: json['id']?.toString() ?? '', title: json['title']?.toString() ?? '', emoji: json['emoji']?.toString(), - include: (json['include'] as List?) - ?.map((e) { - if (e is int) return e; - if (e is String) return int.tryParse(e) ?? 0; - return 0; - }) - .toList(), + include: _parseIntList(json['include']), filters: - (json['filters'] as List?) - ?.map((e) { - if (e is int) return e; - if (e is String) return int.tryParse(e) ?? e; - return e; - }) - .toList() ?? + (json['filters'] as List?)?.map((e) { + if (e is int) return e; + if (e is String) return int.tryParse(e) ?? e; + return e; + }).toList() ?? [], hideEmpty: json['hideEmpty'] ?? false, widgets: - (json['widgets'] as List?) - ?.map((w) { - if (w is Map) { - return ChatFolderWidget.fromJson(w); - } - return ChatFolderWidget.fromJson( - Map.from(w as Map), - ); - }) - .toList() ?? + (json['widgets'] as List?)?.map((w) { + if (w is Map) { + return ChatFolderWidget.fromJson(w); + } + return ChatFolderWidget.fromJson( + Map.from(w as Map), + ); + }).toList() ?? [], - favorites: (json['favorites'] as List?) - ?.map((e) { - if (e is int) return e; - if (e is String) return int.tryParse(e) ?? 0; - return 0; - }) - .toList(), + favorites: _parseIntList(json['favorites']), filterSubjects: json['filterSubjects'] is Map ? json['filterSubjects'] as Map : (json['filterSubjects'] is Map @@ -71,13 +63,7 @@ class ChatFolder { (json['filterSubjects'] as Map).cast(), ) : null), - options: (json['options'] as List?) - ?.map((e) { - if (e is int) return e; - if (e is String) return int.tryParse(e) ?? 0; - return 0; - }) - .toList(), + options: _parseIntList(json['options']), ); } diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 3d9a37f..be7507a 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -11,11 +11,19 @@ import '../../core/storage/spoofing_service.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; import 'chats.dart'; +import 'complaints.dart'; import 'contacts.dart'; import 'folders.dart'; import 'messages.dart'; import 'webapp.dart'; +import 'account/account_models.dart'; +import 'account/privacy_module.dart'; +import 'account/profile_module.dart'; +import 'account/sessions_module.dart'; +import 'account/two_factor_module.dart'; +export 'account/account_models.dart'; + String _normalizeAuthPhone(String phone) { final digits = phone.replaceAll(RegExp(r'\D'), ''); return '+$digits'; @@ -26,427 +34,12 @@ String _maskPhone(String phone) { return '${phone.substring(0, 3)}***${phone.substring(phone.length - 2)}'; } -class PrivacyConfig { - final String searchByPhone; - final String incomingCall; - final bool doubleTapReactionDisabled; - final bool safeModeNoPin; - final String? doubleTapReactionValue; - final String familyProtection; - final bool pushDetails; - final bool hidden; - final String chatsInvite; - final bool pushNewContacts; - final bool unsafeFiles; - final String phoneNumberPrivacy; - final String inactiveTtl; - final bool showReadMark; - final bool altKeyboard; - final bool contentLevelAccess; - final String stickersSuggest; - final bool safeMode; - final bool audioTranscriptionEnabled; - final String chatsPushNotification; - final String mCallPushNotification; - final String pushSound; - final String chatsPushSound; - final String hash; - - const PrivacyConfig({ - required this.searchByPhone, - required this.incomingCall, - required this.doubleTapReactionDisabled, - required this.safeModeNoPin, - this.doubleTapReactionValue, - required this.familyProtection, - required this.pushDetails, - required this.hidden, - required this.chatsInvite, - required this.pushNewContacts, - required this.unsafeFiles, - required this.phoneNumberPrivacy, - required this.inactiveTtl, - required this.showReadMark, - required this.altKeyboard, - required this.contentLevelAccess, - required this.stickersSuggest, - required this.safeMode, - required this.audioTranscriptionEnabled, - required this.chatsPushNotification, - required this.mCallPushNotification, - required this.pushSound, - required this.chatsPushSound, - required this.hash, - }); - - factory PrivacyConfig.fromMap(Map map) { - return PrivacyConfig( - searchByPhone: map['SEARCH_BY_PHONE']?.toString() ?? 'ALL', - incomingCall: map['INCOMING_CALL']?.toString() ?? 'CONTACTS', - doubleTapReactionDisabled: map['DOUBLE_TAP_REACTION_DISABLED'] ?? false, - safeModeNoPin: map['SAFE_MODE_NO_PIN'] ?? false, - doubleTapReactionValue: map['DOUBLE_TAP_REACTION_VALUE']?.toString(), - familyProtection: map['FAMILY_PROTECTION']?.toString() ?? 'OFF', - pushDetails: map['PUSH_DETAILS'] ?? false, - hidden: map['HIDDEN'] ?? true, - chatsInvite: map['CHATS_INVITE']?.toString() ?? 'CONTACTS', - pushNewContacts: map['PUSH_NEW_CONTACTS'] ?? false, - unsafeFiles: map['UNSAFE_FILES'] ?? true, - phoneNumberPrivacy: map['PHONE_NUMBER_PRIVACY']?.toString() ?? 'ALL', - inactiveTtl: map['INACTIVE_TTL']?.toString() ?? '6M', - showReadMark: map['SHOW_READ_MARK'] ?? true, - altKeyboard: map['ALT_KEYBOARD'] ?? false, - contentLevelAccess: map['CONTENT_LEVEL_ACCESS'] ?? false, - stickersSuggest: map['STICKERS_SUGGEST']?.toString() ?? 'ON', - safeMode: map['SAFE_MODE'] ?? false, - audioTranscriptionEnabled: map['AUDIO_TRANSCRIPTION_ENABLED'] ?? true, - chatsPushNotification: map['CHATS_PUSH_NOTIFICATION']?.toString() ?? 'ON', - mCallPushNotification: map['M_CALL_PUSH_NOTIFICATION']?.toString() ?? 'ON', - pushSound: map['PUSH_SOUND']?.toString() ?? 'oki.aiff', - chatsPushSound: map['CHATS_PUSH_SOUND']?.toString() ?? 'oki.aiff', - hash: map['hash']?.toString() ?? '', - ); - } - - String toJson() => jsonEncode({ - 'SEARCH_BY_PHONE': searchByPhone, - 'INCOMING_CALL': incomingCall, - 'DOUBLE_TAP_REACTION_DISABLED': doubleTapReactionDisabled, - 'SAFE_MODE_NO_PIN': safeModeNoPin, - 'DOUBLE_TAP_REACTION_VALUE': doubleTapReactionValue, - 'FAMILY_PROTECTION': familyProtection, - 'PUSH_DETAILS': pushDetails, - 'HIDDEN': hidden, - 'CHATS_INVITE': chatsInvite, - 'PUSH_NEW_CONTACTS': pushNewContacts, - 'UNSAFE_FILES': unsafeFiles, - 'PHONE_NUMBER_PRIVACY': phoneNumberPrivacy, - 'INACTIVE_TTL': inactiveTtl, - 'SHOW_READ_MARK': showReadMark, - 'ALT_KEYBOARD': altKeyboard, - 'CONTENT_LEVEL_ACCESS': contentLevelAccess, - 'STICKERS_SUGGEST': stickersSuggest, - 'SAFE_MODE': safeMode, - 'AUDIO_TRANSCRIPTION_ENABLED': audioTranscriptionEnabled, - 'CHATS_PUSH_NOTIFICATION': chatsPushNotification, - 'M_CALL_PUSH_NOTIFICATION': mCallPushNotification, - 'PUSH_SOUND': pushSound, - 'CHATS_PUSH_SOUND': chatsPushSound, - 'hash': hash, - }); - - factory PrivacyConfig.fromJson(String json) { - try { - final map = jsonDecode(json) as Map; - return PrivacyConfig.fromMap(map); - } catch (_) { - return PrivacyConfig.empty(); - } - } - - static PrivacyConfig empty() { - return const PrivacyConfig( - searchByPhone: 'ALL', - incomingCall: 'CONTACTS', - doubleTapReactionDisabled: false, - safeModeNoPin: false, - familyProtection: 'OFF', - pushDetails: false, - hidden: true, - chatsInvite: 'CONTACTS', - pushNewContacts: false, - unsafeFiles: true, - phoneNumberPrivacy: 'ALL', - inactiveTtl: '6M', - showReadMark: true, - altKeyboard: false, - contentLevelAccess: false, - stickersSuggest: 'ON', - safeMode: false, - audioTranscriptionEnabled: true, - chatsPushNotification: 'ON', - mCallPushNotification: 'ON', - pushSound: 'oki.aiff', - chatsPushSound: 'oki.aiff', - hash: '', - ); - } -} - -class BlockedContact { - final int id; - final String? firstName; - final String? lastName; - final String? baseUrl; - final int? photoId; - final String status; - final int registrationTime; - final int updateTime; - - const BlockedContact({ - required this.id, - this.firstName, - this.lastName, - this.baseUrl, - this.photoId, - required this.status, - required this.registrationTime, - required this.updateTime, - }); - - factory BlockedContact.fromMap(Map map) { - String? firstName; - String? lastName; - final names = map['names'] as List?; - if (names != null && names.isNotEmpty) { - for (final n in names) { - if (n is Map) { - firstName = n['firstName'] as String?; - lastName = n['lastName'] as String?; - if (n['type'] == 'ONEME') break; - } - } - } - - return BlockedContact( - id: map['id'] as int? ?? 0, - firstName: firstName, - lastName: lastName, - baseUrl: map['baseUrl'] as String?, - photoId: map['photoId'] as int?, - status: map['status']?.toString() ?? 'BLOCKED', - registrationTime: map['registrationTime'] as int? ?? 0, - updateTime: map['updateTime'] as int? ?? 0, - ); - } -} - -class TwoFactorDetails { - final bool enabled; - final String? email; - final String? hint; - - const TwoFactorDetails({required this.enabled, this.email, this.hint}); -} - -enum AuthRequestType { - startAuth('START_AUTH'), - resend('RESEND'), - checkCode('CHECK_CODE'), - register('REGISTER'); - - const AuthRequestType(this.value); - final String value; -} - -enum LoginStatus { idle, loading, success, error } - -class WrongDeviceTokenException implements Exception { - const WrongDeviceTokenException(); - @override - String toString() => 'WrongDeviceTokenException'; -} - -class RequestCodeResult { - final String token; - - const RequestCodeResult({required this.token}); -} - -class PresetAvatar { - final int id; - final String url; - - const PresetAvatar({required this.id, required this.url}); -} - -class PresetAvatarCategory { - final String name; - final List avatars; - - const PresetAvatarCategory({required this.name, required this.avatars}); -} - -class VerifyCodeResult { - final Map payload; - - const VerifyCodeResult({required this.payload}); - - String? get loginToken => _nestedToken('LOGIN'); - - String? get registerToken => _nestedToken('REGISTER'); - - bool get isRegistration => registerToken != null && loginToken == null; - - List get presetAvatars { - final raw = payload['presetAvatars']; - if (raw is! List) return const []; - final categories = []; - for (final cat in raw) { - if (cat is! Map) continue; - final avatarsRaw = cat['avatars']; - if (avatarsRaw is! List) continue; - final avatars = []; - for (final a in avatarsRaw) { - if (a is! Map) continue; - final id = a['id']; - final url = a['url']; - if (id is int && url is String && url.isNotEmpty) { - avatars.add(PresetAvatar(id: id, url: url)); - } - } - if (avatars.isNotEmpty) { - categories.add( - PresetAvatarCategory( - name: cat['name']?.toString() ?? '', - avatars: avatars, - ), - ); - } - } - return categories; - } - - bool get requiresPassword => payload['passwordChallenge'] != null; - - Map? get passwordChallenge { - final c = payload['passwordChallenge']; - return c is Map ? c.cast() : null; - } - - String? get challengeTrackId => passwordChallenge?['trackId'] as String?; - - String? get challengeHint => passwordChallenge?['hint'] as String?; - - int? get accountId { - final profileData = payload['profile']; - if (profileData is! Map) return null; - final contact = profileData['contact']; - if (contact is! Map) return null; - return contact['id'] as int?; - } - - String? _nestedToken(String key) { - final attrs = payload['tokenAttrs']; - if (attrs is! Map) return null; - final entry = attrs[key]; - if (entry is! Map) return null; - return entry['token'] as String?; - } -} - -class TwoFactorResult { - final String loginToken; - - const TwoFactorResult({required this.loginToken}); -} - -class LoginSyncParams { - final int chatsSync; - final int contactsSync; - final int callsSync; - final int draftsSync; - final int bannersSync; - final int presenceSync; - final int lastLogin; - final String? configHash; - final String? chatCacheFingerprint; - - const LoginSyncParams({ - required this.chatsSync, - required this.contactsSync, - required this.callsSync, - required this.draftsSync, - required this.bannersSync, - required this.presenceSync, - required this.lastLogin, - this.configHash, - this.chatCacheFingerprint, - }); - - static Future fromDatabase(int accountId) async { - final values = await AppDatabase.getAllSyncValues(accountId); - final lastLogin = values[SyncKey.lastLogin]; - if (lastLogin == null) return null; - - return LoginSyncParams( - chatsSync: int.tryParse(values[SyncKey.chatsSync] ?? '') ?? 0, - contactsSync: int.tryParse(values[SyncKey.contactsSync] ?? '') ?? 0, - callsSync: int.tryParse(values[SyncKey.callsSync] ?? '') ?? 0, - draftsSync: int.tryParse(values[SyncKey.draftsSync] ?? '') ?? 0, - bannersSync: int.tryParse(values[SyncKey.bannersSync] ?? '') ?? 0, - presenceSync: int.tryParse(values[SyncKey.presenceSync] ?? '') ?? -1, - lastLogin: int.tryParse(lastLogin) ?? 0, - configHash: values[SyncKey.configHash], - chatCacheFingerprint: values[SyncKey.chatCacheFingerprint], - ); - } -} - -class SessionInfo { - final int? id; - final String client; - final String location; - final bool current; - final int time; - final String info; - - const SessionInfo({ - this.id, - required this.client, - required this.location, - required this.current, - required this.time, - required this.info, - }); - - factory SessionInfo.fromMap(Map map) { - return SessionInfo( - id: map['id'] is int - ? map['id'] - : (int.tryParse(map['id']?.toString() ?? '')), - client: map['client'] ?? '', - location: map['location'] ?? '', - current: map['current'] ?? false, - time: map['time'] ?? 0, - info: map['info'] ?? '', - ); - } - - int get uniqueId => Object.hash(id, client, time, info); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SessionInfo && - runtimeType == other.runtimeType && - id == other.id && - client == other.client && - location == other.location && - current == other.current && - time == other.time && - info == other.info; - - @override - int get hashCode => Object.hash(id, client, location, current, time, info); -} - -class LoginResult { - final ProfileData profile; - final String? updatedToken; - final int serverTime; - final Map raw; - - const LoginResult({ - required this.profile, - required this.updatedToken, - required this.serverTime, - required this.raw, - }); -} - class AccountModule { final Api _api; + late final SessionsModule _sessions = SessionsModule(_api); + late final PrivacyModule _privacy = PrivacyModule(_api); + late final ProfileModule _profile = ProfileModule(_api); + late final TwoFactorModule _twoFactor = TwoFactorModule(_api, _profile); final _loginStatusController = StreamController.broadcast(); bool _loggedIn = false; @@ -462,416 +55,99 @@ class AccountModule { /// login (opcode 19), а не просто после хэндшейка (opcode 6). bool get isLoggedIn => _loggedIn; - Future getPrivacyConfig() async { - final accountId = await TokenStorage.getActiveAccountId(); - if (accountId != null) { - final saved = await AppDatabase.getPrivacyConfig(accountId); - if (saved != null) return PrivacyConfig.fromJson(saved); - } - return PrivacyConfig.empty(); - } + Future getPrivacyConfig() => _privacy.getPrivacyConfig(); - Future> getBlockedContacts() async { - _ensureOnline(); - final packet = await _api.sendRequest(Opcode.contactList, { - 'status': 'BLOCKED', - 'count': 100, - 'from': 0, - }); - _checkPacketError(packet, 'getBlockedContacts'); - final data = packet.payload; - if (data is! Map) { - throw Exception( - 'getBlockedContacts: неожиданный тип payload: ${data.runtimeType}', - ); - } - final contacts = data['contacts'] as List?; - if (contacts == null) return []; - return contacts - .whereType() - .map((c) => BlockedContact.fromMap(c.cast())) - .toList(); - } + Future> getBlockedContacts() => + _privacy.getBlockedContacts(); - Future updatePrivacyConfig( - Map settings, - ) async { - _ensureOnline(); - final payload = { - 'settings': {'user': settings}, - }; - final packet = await _api.sendRequest(Opcode.config, payload); - _checkPacketError(packet, 'updatePrivacyConfig'); - final data = packet.payload; - if (data is! Map) { - throw Exception( - 'updatePrivacyConfig: неожиданный тип payload: ${data.runtimeType}', - ); - } - final user = data['user']; - if (user is! Map) { - throw Exception('updatePrivacyConfig: отсутствует user в payload'); - } - final config = PrivacyConfig.fromMap(user.cast()); - final accountId = await TokenStorage.getActiveAccountId(); - if (accountId != null) { - await AppDatabase.savePrivacyConfig(accountId, config.toJson()); - } - return config; - } + Future updatePrivacyConfig(Map settings) => + _privacy.updatePrivacyConfig(settings); - Future registerPushToken(String pushToken) async { - _ensureOnline(); - final packet = await _api.sendRequest(Opcode.config, { - 'pushToken': pushToken, - 'pushOptions': 0, - }); - if (packet.isError) { - final msg = messageFromErrorPayload(packet.payload).toUpperCase(); - if (msg.contains('WRONG_DEVICE_TOKEN') || - msg.contains('WRONG.DEVICE.TOKEN')) { - throw const WrongDeviceTokenException(); - } - throw PacketError(messageFromErrorPayload(packet.payload)); - } - } + Future setChatsPushNotification(bool value) => + _privacy.setChatsPushNotification(value); - Future unregisterPushToken(String pushToken) async { - if (_api.state != SessionState.online) return; - final accountId = await TokenStorage.getActiveAccountId(); - if (accountId == null) return; - final authToken = await TokenStorage.readToken(accountId); - if (authToken == null) return; - await _api.sendRequest(Opcode.logout, { - 'token': authToken, - 'pushToken': pushToken, - }); - } + Future setMessagePreview(bool value) => + _privacy.setMessagePreview(value); - Future updateProfileName(String firstName, String? lastName) async { - _ensureOnline(); - final payload = { - 'firstName': firstName, - }; - if (lastName != null) payload['lastName'] = lastName; - final packet = await _api.sendRequest(Opcode.profile, payload); - if (packet.isError) { - throw Exception(packet.payload?.toString() ?? 'Server error'); - } - final data = packet.payload as Map?; - if (data == null) throw Exception('Empty response'); - final profile = data['profile'] as Map?; - if (profile == null) throw Exception('No profile in response'); - final contact = profile['contact'] as Map?; - if (contact == null) throw Exception('No contact in response'); - final newProfile = ProfileData.fromServerMap(contact.cast()); - await AppDatabase.saveProfile(newProfile, isActive: true); - return newProfile; - } + Future setNotificationSound(bool value) => + _privacy.setNotificationSound(value); - Future updateProfileAvatar(String photoToken, {String avatarType = 'USER_AVATAR'}) async { - _ensureOnline(); - final packet = await _api.sendRequest(Opcode.profile, { - 'photoToken': photoToken, - 'avatarType': avatarType, - }); - if (packet.isError) { - throw Exception(packet.payload?.toString() ?? 'Server error'); - } - final data = packet.payload as Map?; - if (data == null) throw Exception('Empty response'); - final profile = data['profile'] as Map?; - if (profile == null) throw Exception('No profile in response'); - final contact = profile['contact'] as Map?; - if (contact == null) throw Exception('No contact in response'); - final newProfile = ProfileData.fromServerMap(contact.cast()); - await AppDatabase.saveProfile(newProfile, isActive: true); - return newProfile; - } + Future setCallNotifications(bool value) => + _privacy.setCallNotifications(value); - Future getAvatarUploadUrl() async { - _ensureOnline(); - final packet = await _api.sendRequest(Opcode.photoUpload, { - 'count': 1, - 'profile': true, - }); - if (packet.isError) { - throw Exception(packet.payload?.toString() ?? 'Server error'); - } - final data = packet.payload as Map?; - if (data == null) throw Exception('Empty response'); - final url = data['url'] as String?; - if (url == null) throw Exception('No url in response'); - return url; - } + Future setNewContacts(bool value) => + _privacy.setNewContacts(value); - Future removeProfilePhoto(int photoId) async { - _ensureOnline(); - final packet = await _api.sendRequest(Opcode.removeContactPhoto, { - 'photoId': photoId, - }); - if (packet.isError) { - throw Exception(packet.payload?.toString() ?? 'Server error'); - } - final data = packet.payload as Map?; - if (data == null) throw Exception('Empty response'); - final profile = data['profile'] as Map?; - if (profile == null) throw Exception('No profile in response'); - final contact = profile['contact'] as Map?; - if (contact == null) throw Exception('No contact in response'); - final newProfile = ProfileData.fromServerMap(contact.cast()); - await AppDatabase.saveProfile(newProfile, isActive: true); - return newProfile; - } + Future registerPushToken(String pushToken) => + _privacy.registerPushToken(pushToken); - // 2FA Creation (when not set) - Future create2faTrack() async { - _ensureOnline(); - final packet = await _api.sendRequest(Opcode.authCreateTrack, {'type': 0}); - _checkPacketError(packet, 'create2faTrack'); - final data = packet.payload; - if (data is! Map) { - throw Exception( - 'create2faTrack: неожиданный тип payload: ${data.runtimeType}', - ); - } - final trackId = data['trackId'] as String?; - if (trackId == null) { - throw Exception('create2faTrack: отсутствует trackId'); - } - return trackId; - } + Future unregisterPushToken(String pushToken) => + _privacy.unregisterPushToken(pushToken); - Future set2faPassword(String trackId, String password) async { - _ensureOnline(); - final packet = await _api.sendRequest(Opcode.authValidatePassword, { - 'trackId': trackId, - 'password': password, - }); - _checkPacketError(packet, 'set2faPassword'); - if (packet.payload != null && packet.payload is! Map) { - throw Exception('set2faPassword: неожиданный ответ'); - } - } + Future updateProfileName(String firstName, String? lastName) => + _profile.updateProfileName(firstName, lastName); - Future set2faHint(String trackId, String hint) async { - _ensureOnline(); - final packet = await _api.sendRequest(Opcode.authValidateHint, { - 'trackId': trackId, - 'hint': hint, - }); - _checkPacketError(packet, 'set2faHint'); - if (packet.payload != null && packet.payload is! Map) { - throw Exception('set2faHint: неожиданный ответ'); - } - } + Future updateProfileAvatar( + String photoToken, { + String avatarType = 'USER_AVATAR', + }) => _profile.updateProfileAvatar(photoToken, avatarType: avatarType); - Future verify2faEmail(String trackId, String email) async { - _ensureOnline(); - final packet = await _api.sendRequest(Opcode.authVerifyEmail, { - 'trackId': trackId, - 'email': email, - }); - _checkPacketError(packet, 'verify2faEmail'); - final data = packet.payload; - if (data is! Map) { - throw Exception( - 'verify2faEmail: неожиданный тип payload: ${data.runtimeType}', - ); - } - final blockingDuration = data['blockingDuration'] as int? ?? 60; - return blockingDuration; - } + Future getAvatarUploadUrl() => _profile.getAvatarUploadUrl(); - Future verify2faCode(String trackId, String code) async { - _ensureOnline(); - final packet = await _api.sendRequest(Opcode.authCheckEmail, { - 'trackId': trackId, - 'verifyCode': code, - }); - _checkPacketError(packet, 'verify2faCode'); - final data = packet.payload; - if (data is! Map) { - throw Exception( - 'verify2faCode: неожиданный тип payload: ${data.runtimeType}', - ); - } - final email = data['email'] as String? ?? ''; - return email; - } + Future removeProfilePhoto(int photoId) => + _profile.removeProfilePhoto(photoId); + + Future create2faTrack() => _twoFactor.create2faTrack(); + + Future set2faPassword(String trackId, String password) => + _twoFactor.set2faPassword(trackId, password); + + Future set2faHint(String trackId, String hint) => + _twoFactor.set2faHint(trackId, hint); + + Future verify2faEmail(String trackId, String email) => + _twoFactor.verify2faEmail(trackId, email); + + Future verify2faCode(String trackId, String code) => + _twoFactor.verify2faCode(trackId, code); Future confirm2fa({ required String trackId, required String password, String? hint, bool withEmail = true, - }) async { - _ensureOnline(); - final capabilities = [0, if (hint != null) 3, if (withEmail) 4]; - final payload = { - 'expectedCapabilities': capabilities, - 'trackId': trackId, - 'password': password, - }; - if (hint != null) payload['hint'] = hint; - return _processProfileUpdate( - _api.sendRequest(Opcode.authSet2fa, payload), - 'confirm2fa', - ); - } + }) => _twoFactor.confirm2fa( + trackId: trackId, + password: password, + hint: hint, + withEmail: withEmail, + ); - // 2FA Management (when already set) - Future enter2faPanel() async { - _ensureOnline(); - final packet = await _api.sendRequest(Opcode.authCreateTrack, {'type': 0}); - _checkPacketError(packet, 'enter2faPanel'); - final data = packet.payload; - if (data is! Map) { - throw Exception( - 'enter2faPanel: неожиданный тип payload: ${data.runtimeType}', - ); - } - final trackId = data['trackId'] as String?; - if (trackId == null) { - throw Exception('enter2faPanel: отсутствует trackId'); - } - return trackId; - } + Future enter2faPanel() => _twoFactor.enter2faPanel(); - Future get2faDetails(String trackId) async { - _ensureOnline(); - final packet = await _api.sendRequest(Opcode.auth2faDetails, { - 'trackId': trackId, - }); - _checkPacketError(packet, 'get2faDetails'); - final data = packet.payload; - if (data is! Map) { - throw Exception( - 'get2faDetails: неожиданный тип payload: ${data.runtimeType}', - ); - } - final password = data['password'] as Map?; - return TwoFactorDetails( - enabled: password?['enabled'] ?? false, - email: password?['email'] as String?, - hint: password?['hint'] as String?, - ); - } + Future get2faDetails(String trackId) => + _twoFactor.get2faDetails(trackId); - Future get2faStatus() async { - final trackId = await enter2faPanel(); - return get2faDetails(trackId); - } + Future get2faStatus() => _twoFactor.get2faStatus(); - Future check2faPassword(String trackId, String password) async { - _ensureOnline(); - final packet = await _api.sendRequest(Opcode.authCheckPassword, { - 'trackId': trackId, - 'password': password, - }); - _checkPacketError(packet, 'check2faPassword'); - final data = packet.payload; - if (data is Map && data['error'] != null) { - throw Exception('Неверный пароль'); - } - } + Future check2faPassword(String trackId, String password) => + _twoFactor.check2faPassword(trackId, password); Future update2faPassword({ required String trackId, required String newPassword, String? hint, - }) async { - _ensureOnline(); - final validatePacket = await _api.sendRequest(Opcode.authValidatePassword, { - 'trackId': trackId, - 'password': newPassword, - }); - _checkPacketError(validatePacket, 'update2faPassword: validate'); - if (validatePacket.payload != null && validatePacket.payload is! Map) { - throw Exception('update2faPassword: неожиданный ответ при валидации'); - } + }) => _twoFactor.update2faPassword( + trackId: trackId, + newPassword: newPassword, + hint: hint, + ); - if (hint != null) { - final hintPacket = await _api.sendRequest(Opcode.authValidateHint, { - 'trackId': trackId, - 'hint': hint, - }); - _checkPacketError(hintPacket, 'update2faPassword: hint'); - } + Future commit2faEmailChange(String trackId) => + _twoFactor.commit2faEmailChange(trackId); - final payload = { - 'expectedCapabilities': [1, if (hint != null) 3], - 'trackId': trackId, - 'password': newPassword, - }; - if (hint != null) payload['hint'] = hint; - - return _processProfileUpdate( - _api.sendRequest(Opcode.authSet2fa, payload), - 'update2faPassword', - ); - } - - Future commit2faEmailChange(String trackId) async { - _ensureOnline(); - final payload = { - 'expectedCapabilities': [4], - 'trackId': trackId, - }; - return _processProfileUpdate( - _api.sendRequest(Opcode.authSet2fa, payload), - 'commit2faEmailChange', - ); - } - - Future remove2fa(String trackId) async { - _ensureOnline(); - final payload = { - 'expectedCapabilities': [5], - 'trackId': trackId, - 'remove2fa': true, - }; - return _processProfileUpdate( - _api.sendRequest(Opcode.authSet2fa, payload), - 'remove2fa', - ); - } - - Future _processProfileUpdate( - Future requestFuture, - String tag, - ) async { - final completer = Completer(); - final sub = _api.pushStream - .where((p) => p.opcode == Opcode.notifProfile) - .listen((push) { - if (completer.isCompleted) return; - final payload = push.payload; - if (payload is! Map) return; - final profile = payload['profile']; - if (profile is! Map) return; - final contact = profile['contact']; - if (contact is! Map) return; - completer.complete( - ProfileData.fromServerMap(contact.cast()), - ); - }); - final timer = Timer(const Duration(seconds: 15), () { - if (!completer.isCompleted) { - completer.completeError( - Exception('Таймаут ожидания обновления профиля'), - ); - } - }); - try { - final packet = await requestFuture; - _checkPacketError(packet, tag); - return await completer.future; - } finally { - timer.cancel(); - await sub.cancel(); - } - } + Future remove2fa(String trackId) => + _twoFactor.remove2fa(trackId); Future requestCode( String phone, { @@ -896,14 +172,7 @@ class AccountModule { final packet = await _api.sendRequest(Opcode.auth, payload); - _checkPacketError(packet, 'verifyCode'); - - final data = packet.payload; - if (data is! Map) { - throw Exception( - 'verifyCode: неожиданный тип payload: ${data.runtimeType}', - ); - } + final data = _requireMapPayload(packet, 'verifyCode'); final result = VerifyCodeResult(payload: data.cast()); @@ -944,14 +213,7 @@ class AccountModule { final packet = await _api.sendRequest(Opcode.authConfirm, payload); - _checkPacketError(packet, 'completeRegistration'); - - final data = packet.payload; - if (data is! Map) { - throw Exception( - 'completeRegistration: неожиданный тип payload: ${data.runtimeType}', - ); - } + final data = _requireMapPayload(packet, 'completeRegistration'); final profileMap = data['profile']; if (profileMap is! Map) { @@ -996,18 +258,13 @@ class AccountModule { } } - final requestPayload = _buildLoginPayload(authToken, syncParams); + final requestPayload = buildLoginPayload(authToken, sync: syncParams); _loginStatusController.add(LoginStatus.loading); try { final packet = await _api.sendRequest(Opcode.login, requestPayload); - _checkPacketError(packet, 'login'); - - final data = packet.payload; - if (data is! Map) { - throw Exception('login: неожиданный тип payload: ${data.runtimeType}'); - } + final data = _requireMapPayload(packet, 'login'); final dataMap = data.cast(); @@ -1037,39 +294,12 @@ class AccountModule { } } - Future> getSessions() async { - _ensureOnline(); - final packet = await _api.sendRequest(Opcode.sessionsInfo, {}); - _checkPacketError(packet, 'getSessions'); - final data = packet.payload; - if (data is! Map || data['sessions'] is! List) return []; - final sessions = data['sessions'] as List; - return sessions - .map((s) => SessionInfo.fromMap(s as Map)) - .toList(); - } + Future> getSessions() => _sessions.getSessions(); - Future terminateOtherSessions() async { - _ensureOnline(); - final packet = await _api.sendRequest(Opcode.sessionsClose, {}); - _checkPacketError(packet, 'terminateOtherSessions'); - } + Future terminateOtherSessions() => _sessions.terminateOtherSessions(); - Future authorizeWebQrLogin(String qrLink) async { - _ensureOnline(); - final link = qrLink.trim(); - if (link.isEmpty) { - throw ArgumentError('Пустая ссылка из QR'); - } - - await _api.sendRequest(Opcode.ping, {'interactive': true}); - await _api.sendRequest(Opcode.sessionsInfo, {}); - await Future.delayed(const Duration(milliseconds: 300)); - final packet = await _api.sendRequest(Opcode.authQrApprove, { - 'qrLink': link, - }); - _checkPacketError(packet, 'authorizeWebQrLogin'); - } + Future authorizeWebQrLogin(String qrLink) => + _sessions.authorizeWebQrLogin(qrLink); Future beginAddAccount() async { final existing = await AppDatabase.loadAllProfiles(); @@ -1085,7 +315,8 @@ class AccountModule { ContactCache.clear(); TranscriptionCache.clear(); - ChatsModule.resetForAccountSwitch(); + ComplaintsModule.clear(); + chats.resetForAccountSwitch(); logger.i('Добавление аккаунта: сессия сброшена, активный аккаунт очищен'); } @@ -1098,7 +329,8 @@ class AccountModule { ContactCache.clear(); TranscriptionCache.clear(); - ChatsModule.resetForAccountSwitch(); + ComplaintsModule.clear(); + chats.resetForAccountSwitch(); await _api.connect(); if (_api.state != SessionState.online) { @@ -1128,12 +360,24 @@ class AccountModule { ContactCache.clear(); TranscriptionCache.clear(); - ChatsModule.resetForAccountSwitch(); + ComplaintsModule.clear(); + chats.resetForAccountSwitch(); await ContactsModule.primeCacheFromDb(accountId); try { await _api.connect(); - } catch (_) {} + } catch (e) { + logger.e( + 'switchAccount: ошибка соединения при переключении на $accountId: $e', + ); + throw StateError('switchAccount: не удалось подключиться к серверу'); + } + if (_api.state != SessionState.online) { + logger.w( + 'switchAccount: нет соединения с сервером после переключения на $accountId', + ); + throw StateError('switchAccount: нет соединения с сервером'); + } logger.i('Активный аккаунт переключён на $accountId'); return profile; @@ -1150,6 +394,20 @@ class AccountModule { logger.i('Аккаунт $accountId удалён локально'); } + Future logout() async { + try { + await _api.disconnect(); + } catch (_) {} + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await removeAccount(accountId); + } + ContactCache.clear(); + TranscriptionCache.clear(); + ComplaintsModule.clear(); + chats.resetForAccountSwitch(); + } + Future checkPassword({ required String password, required String trackId, @@ -1168,14 +426,7 @@ class AccountModule { payload, ); - _checkPacketError(packet, 'checkPassword'); - - final data = packet.payload; - if (data is! Map) { - throw Exception( - 'checkPassword: неожиданный тип payload: ${data.runtimeType}', - ); - } + final data = _requireMapPayload(packet, 'checkPassword'); if (data['error'] != null) { throw Exception('checkPassword: неверный пароль'); @@ -1200,13 +451,14 @@ class AccountModule { return TwoFactorResult(loginToken: loginToken); } - Map _buildLoginPayload( - String token, + Map buildLoginPayload( + String token, { LoginSyncParams? sync, - ) { + bool? interactive, + }) { final payload = { 'token': token, - 'interactive': !KometSettings.ghostMode.value, + 'interactive': interactive ?? !KometSettings.ghostMode.value, 'exp': { 'chatsCountGroups': Uint8List.fromList([0x0b, 0x32]), }, @@ -1215,8 +467,10 @@ class AccountModule { final callsSeed = _api.callsSeed; final deviceId = _api.deviceId; if (callsSeed != null && deviceId != null) { - payload['chatCacheFingerprint'] = - ChatCacheFingerprint.compute(callsSeed, deviceId); + payload['chatCacheFingerprint'] = ChatCacheFingerprint.compute( + callsSeed, + deviceId, + ); } if (sync != null) { @@ -1261,7 +515,7 @@ class AccountModule { await _saveSyncState(data, serverTime, profile.id); await ContactsModule.syncFromLoginPayload(data, profile.id); - await ChatsModule.syncFromLoginPayload(data, profile.id, profile.id); + await chats.syncFromLoginPayload(data, profile.id, profile.id); final config = data['config']; if (config is Map) { @@ -1271,10 +525,7 @@ class AccountModule { ); final userConfig = config['user']; if (userConfig is Map) { - await AppDatabase.savePrivacyConfig( - profile.id, - jsonEncode(userConfig), - ); + await AppDatabase.savePrivacyConfig(profile.id, jsonEncode(userConfig)); } } try { @@ -1323,10 +574,7 @@ class AccountModule { } } - Future _saveLoginInfo( - Map data, - int accountId, - ) async { + Future _saveLoginInfo(Map data, int accountId) async { final contact = data['profile']?['contact'] as Map?; final videoChatHistory = data['videoChatHistory']; final chats = data['chats'] as List?; @@ -1338,7 +586,8 @@ class AccountModule { } final yMap = serverConfig?['y-map'] as Map?; final whiteListLinks = serverConfig?['white-list-links'] as List?; - final fileUploadUnsupported = serverConfig?['file-upload-unsupported-types'] as List?; + final fileUploadUnsupported = + serverConfig?['file-upload-unsupported-types'] as List?; final time = data['time'] as int?; final info = { @@ -1352,7 +601,12 @@ class AccountModule { : null, 'time': time, 'server': serverConfig != null - ? _extractServerInfo(serverConfig, yMap, whiteListLinks, fileUploadUnsupported) + ? _extractServerInfo( + serverConfig, + yMap, + whiteListLinks, + fileUploadUnsupported, + ) : null, 'user': userConfig != null ? _extractUserConfig(userConfig) : null, }; @@ -1380,7 +634,11 @@ class AccountModule { } } for (final entry in resolved.entries) { - await AppDatabase.setSyncValue(accountId, entry.key, entry.value.toString()); + await AppDatabase.setSyncValue( + accountId, + entry.key, + entry.value.toString(), + ); } } @@ -1388,7 +646,8 @@ class AccountModule { int? latestTime; for (final chat in chats) { final lastEventTime = chat['lastEventTime'] as int?; - if (lastEventTime != null && (latestTime == null || lastEventTime > latestTime)) { + if (lastEventTime != null && + (latestTime == null || lastEventTime > latestTime)) { latestTime = lastEventTime; } } @@ -1418,13 +677,16 @@ class AccountModule { 'image-quality': serverConfig['image-quality'], 'unsafe-files-alert': serverConfig['unsafe-files-alert'], 'account-nickname-enabled': serverConfig['account-nickname-enabled'], - 'mentions_entity_names_limit': serverConfig['mentions_entity_names_limit'], + 'mentions_entity_names_limit': + serverConfig['mentions_entity_names_limit'], 'reactions-enabled': serverConfig['reactions-enabled'], - 'y-map': yMap != null ? { - 'tile': yMap['tile'], - 'geocoder': yMap['geocoder'], - 'static': yMap['static'], - } : null, + 'y-map': yMap != null + ? { + 'tile': yMap['tile'], + 'geocoder': yMap['geocoder'], + 'static': yMap['static'], + } + : null, 'white-list-links': whiteListLinks, 'file-upload-unsupported-types': fileUploadUnsupported, }; @@ -1441,7 +703,8 @@ class AccountModule { 'AUDIO_TRANSCRIPTION_ENABLED': userConfig['AUDIO_TRANSCRIPTION_ENABLED'], 'SEARCH_BY_PHONE': userConfig['SEARCH_BY_PHONE'], 'INCOMING_CALL': userConfig['INCOMING_CALL'], - 'DOUBLE_TAP_REACTION_DISABLED': userConfig['DOUBLE_TAP_REACTION_DISABLED'], + 'DOUBLE_TAP_REACTION_DISABLED': + userConfig['DOUBLE_TAP_REACTION_DISABLED'], 'SAFE_MODE_NO_PIN': userConfig['SAFE_MODE_NO_PIN'], 'CHATS_PUSH_SOUND': userConfig['CHATS_PUSH_SOUND'], 'DOUBLE_TAP_REACTION_VALUE': userConfig['DOUBLE_TAP_REACTION_VALUE'], @@ -1480,18 +743,13 @@ class AccountModule { payload['mode'] = ChatCacheFingerprint.compute(callsSeed, deviceId); } - logger.i('Запрос OTP-кода: phone=${_maskPhone(normalizedPhone)} type=${type.value}'); + logger.i( + 'Запрос OTP-кода: phone=${_maskPhone(normalizedPhone)} type=${type.value}', + ); final packet = await _api.sendRequest(Opcode.authRequest, payload); - _checkPacketError(packet, 'requestCode'); - - final data = packet.payload; - if (data is! Map) { - throw Exception( - 'requestCode: неожиданный тип payload: ${data.runtimeType}', - ); - } + final data = _requireMapPayload(packet, 'requestCode'); final token = data['token']; if (token is! String || token.isEmpty) { @@ -1510,15 +768,16 @@ class AccountModule { } } - void _checkPacketError(Packet packet, String method) { - if (packet.isError) { - final payload = packet.payload; - if (payload is Map && - (payload['message'] == 'FAIL_LOGIN_TOKEN' || - payload['message'] == 'FAIL_WRONG_PASSWORD')) { - throw SessionExpiredException(messageFromErrorPayload(payload)); - } - throw PacketError(messageFromErrorPayload(payload)); + Map _requireMapPayload(Packet packet, String method) { + _checkPacketError(packet, method); + final data = packet.payload; + if (data is! Map) { + throw Exception('$method: неожиданный тип payload: ${data.runtimeType}'); } + return data; + } + + void _checkPacketError(Packet packet, String method) { + throwIfPacketError(packet); } } diff --git a/lib/backend/modules/account/account_base.dart b/lib/backend/modules/account/account_base.dart new file mode 100644 index 0000000..53a65e6 --- /dev/null +++ b/lib/backend/modules/account/account_base.dart @@ -0,0 +1,28 @@ +import '../../api.dart'; +import '../../../core/protocol/packet.dart'; + +abstract class AccountApiBase { + final Api api; + const AccountApiBase(this.api); + + void ensureOnline() { + if (api.state != SessionState.online) { + throw StateError( + 'AccountModule: сессия не онлайн (текущее состояние: ${api.state.name})', + ); + } + } + + void checkPacketError(Packet packet, String method) { + throwIfPacketError(packet); + } + + Map requireMapPayload(Packet packet, String method) { + checkPacketError(packet, method); + final data = packet.payload; + if (data is! Map) { + throw Exception('$method: неожиданный тип payload: ${data.runtimeType}'); + } + return data; + } +} diff --git a/lib/backend/modules/account/account_models.dart b/lib/backend/modules/account/account_models.dart new file mode 100644 index 0000000..989a4f2 --- /dev/null +++ b/lib/backend/modules/account/account_models.dart @@ -0,0 +1,423 @@ +import 'dart:convert'; + +import '../../../core/storage/app_database.dart'; + +class PrivacyConfig { + final String searchByPhone; + final String incomingCall; + final bool doubleTapReactionDisabled; + final bool safeModeNoPin; + final String? doubleTapReactionValue; + final String familyProtection; + final bool pushDetails; + final bool hidden; + final String chatsInvite; + final bool pushNewContacts; + final bool unsafeFiles; + final String phoneNumberPrivacy; + final String inactiveTtl; + final bool showReadMark; + final bool altKeyboard; + final bool contentLevelAccess; + final String stickersSuggest; + final bool safeMode; + final bool audioTranscriptionEnabled; + final String chatsPushNotification; + final String mCallPushNotification; + final String pushSound; + final String chatsPushSound; + final String hash; + + const PrivacyConfig({ + required this.searchByPhone, + required this.incomingCall, + required this.doubleTapReactionDisabled, + required this.safeModeNoPin, + this.doubleTapReactionValue, + required this.familyProtection, + required this.pushDetails, + required this.hidden, + required this.chatsInvite, + required this.pushNewContacts, + required this.unsafeFiles, + required this.phoneNumberPrivacy, + required this.inactiveTtl, + required this.showReadMark, + required this.altKeyboard, + required this.contentLevelAccess, + required this.stickersSuggest, + required this.safeMode, + required this.audioTranscriptionEnabled, + required this.chatsPushNotification, + required this.mCallPushNotification, + required this.pushSound, + required this.chatsPushSound, + required this.hash, + }); + + factory PrivacyConfig.fromMap(Map map) { + return PrivacyConfig( + searchByPhone: map['SEARCH_BY_PHONE']?.toString() ?? 'ALL', + incomingCall: map['INCOMING_CALL']?.toString() ?? 'CONTACTS', + doubleTapReactionDisabled: map['DOUBLE_TAP_REACTION_DISABLED'] ?? false, + safeModeNoPin: map['SAFE_MODE_NO_PIN'] ?? false, + doubleTapReactionValue: map['DOUBLE_TAP_REACTION_VALUE']?.toString(), + familyProtection: map['FAMILY_PROTECTION']?.toString() ?? 'OFF', + pushDetails: map['PUSH_DETAILS'] ?? false, + hidden: map['HIDDEN'] ?? true, + chatsInvite: map['CHATS_INVITE']?.toString() ?? 'CONTACTS', + pushNewContacts: map['PUSH_NEW_CONTACTS'] ?? false, + unsafeFiles: map['UNSAFE_FILES'] ?? true, + phoneNumberPrivacy: map['PHONE_NUMBER_PRIVACY']?.toString() ?? 'ALL', + inactiveTtl: map['INACTIVE_TTL']?.toString() ?? '6M', + showReadMark: map['SHOW_READ_MARK'] ?? true, + altKeyboard: map['ALT_KEYBOARD'] ?? false, + contentLevelAccess: map['CONTENT_LEVEL_ACCESS'] ?? false, + stickersSuggest: map['STICKERS_SUGGEST']?.toString() ?? 'ON', + safeMode: map['SAFE_MODE'] ?? false, + audioTranscriptionEnabled: map['AUDIO_TRANSCRIPTION_ENABLED'] ?? true, + chatsPushNotification: map['CHATS_PUSH_NOTIFICATION']?.toString() ?? 'ON', + mCallPushNotification: + map['M_CALL_PUSH_NOTIFICATION']?.toString() ?? 'ON', + pushSound: map['PUSH_SOUND']?.toString() ?? 'oki.aiff', + chatsPushSound: map['CHATS_PUSH_SOUND']?.toString() ?? 'oki.aiff', + hash: map['hash']?.toString() ?? '', + ); + } + + String toJson() => jsonEncode({ + 'SEARCH_BY_PHONE': searchByPhone, + 'INCOMING_CALL': incomingCall, + 'DOUBLE_TAP_REACTION_DISABLED': doubleTapReactionDisabled, + 'SAFE_MODE_NO_PIN': safeModeNoPin, + 'DOUBLE_TAP_REACTION_VALUE': doubleTapReactionValue, + 'FAMILY_PROTECTION': familyProtection, + 'PUSH_DETAILS': pushDetails, + 'HIDDEN': hidden, + 'CHATS_INVITE': chatsInvite, + 'PUSH_NEW_CONTACTS': pushNewContacts, + 'UNSAFE_FILES': unsafeFiles, + 'PHONE_NUMBER_PRIVACY': phoneNumberPrivacy, + 'INACTIVE_TTL': inactiveTtl, + 'SHOW_READ_MARK': showReadMark, + 'ALT_KEYBOARD': altKeyboard, + 'CONTENT_LEVEL_ACCESS': contentLevelAccess, + 'STICKERS_SUGGEST': stickersSuggest, + 'SAFE_MODE': safeMode, + 'AUDIO_TRANSCRIPTION_ENABLED': audioTranscriptionEnabled, + 'CHATS_PUSH_NOTIFICATION': chatsPushNotification, + 'M_CALL_PUSH_NOTIFICATION': mCallPushNotification, + 'PUSH_SOUND': pushSound, + 'CHATS_PUSH_SOUND': chatsPushSound, + 'hash': hash, + }); + + factory PrivacyConfig.fromJson(String json) { + try { + final map = jsonDecode(json) as Map; + return PrivacyConfig.fromMap(map); + } catch (_) { + return PrivacyConfig.empty(); + } + } + + static PrivacyConfig empty() { + return const PrivacyConfig( + searchByPhone: 'ALL', + incomingCall: 'CONTACTS', + doubleTapReactionDisabled: false, + safeModeNoPin: false, + familyProtection: 'OFF', + pushDetails: false, + hidden: true, + chatsInvite: 'CONTACTS', + pushNewContacts: false, + unsafeFiles: true, + phoneNumberPrivacy: 'ALL', + inactiveTtl: '6M', + showReadMark: true, + altKeyboard: false, + contentLevelAccess: false, + stickersSuggest: 'ON', + safeMode: false, + audioTranscriptionEnabled: true, + chatsPushNotification: 'ON', + mCallPushNotification: 'ON', + pushSound: 'oki.aiff', + chatsPushSound: 'oki.aiff', + hash: '', + ); + } +} + +class BlockedContact { + final int id; + final String? firstName; + final String? lastName; + final String? baseUrl; + final int? photoId; + final String status; + final int registrationTime; + final int updateTime; + + const BlockedContact({ + required this.id, + this.firstName, + this.lastName, + this.baseUrl, + this.photoId, + required this.status, + required this.registrationTime, + required this.updateTime, + }); + + factory BlockedContact.fromMap(Map map) { + String? firstName; + String? lastName; + final names = map['names'] as List?; + if (names != null && names.isNotEmpty) { + for (final n in names) { + if (n is Map) { + firstName = n['firstName'] as String?; + lastName = n['lastName'] as String?; + if (n['type'] == 'ONEME') break; + } + } + } + + return BlockedContact( + id: map['id'] as int? ?? 0, + firstName: firstName, + lastName: lastName, + baseUrl: map['baseUrl'] as String?, + photoId: map['photoId'] as int?, + status: map['status']?.toString() ?? 'BLOCKED', + registrationTime: map['registrationTime'] as int? ?? 0, + updateTime: map['updateTime'] as int? ?? 0, + ); + } +} + +class TwoFactorDetails { + final bool enabled; + final String? email; + final String? hint; + + const TwoFactorDetails({required this.enabled, this.email, this.hint}); +} + +enum AuthRequestType { + startAuth('START_AUTH'), + resend('RESEND'), + checkCode('CHECK_CODE'), + register('REGISTER'); + + const AuthRequestType(this.value); + final String value; +} + +enum LoginStatus { idle, loading, success, error } + +class WrongDeviceTokenException implements Exception { + const WrongDeviceTokenException(); + @override + String toString() => 'WrongDeviceTokenException'; +} + +class RequestCodeResult { + final String token; + + const RequestCodeResult({required this.token}); +} + +class PresetAvatar { + final int id; + final String url; + + const PresetAvatar({required this.id, required this.url}); +} + +class PresetAvatarCategory { + final String name; + final List avatars; + + const PresetAvatarCategory({required this.name, required this.avatars}); +} + +class VerifyCodeResult { + final Map payload; + + const VerifyCodeResult({required this.payload}); + + String? get loginToken => _nestedToken('LOGIN'); + + String? get registerToken => _nestedToken('REGISTER'); + + bool get isRegistration => registerToken != null && loginToken == null; + + List get presetAvatars { + final raw = payload['presetAvatars']; + if (raw is! List) return const []; + final categories = []; + for (final cat in raw) { + if (cat is! Map) continue; + final avatarsRaw = cat['avatars']; + if (avatarsRaw is! List) continue; + final avatars = []; + for (final a in avatarsRaw) { + if (a is! Map) continue; + final id = a['id']; + final url = a['url']; + if (id is int && url is String && url.isNotEmpty) { + avatars.add(PresetAvatar(id: id, url: url)); + } + } + if (avatars.isNotEmpty) { + categories.add( + PresetAvatarCategory( + name: cat['name']?.toString() ?? '', + avatars: avatars, + ), + ); + } + } + return categories; + } + + bool get requiresPassword => payload['passwordChallenge'] != null; + + Map? get passwordChallenge { + final c = payload['passwordChallenge']; + return c is Map ? c.cast() : null; + } + + String? get challengeTrackId => passwordChallenge?['trackId'] as String?; + + String? get challengeHint => passwordChallenge?['hint'] as String?; + + int? get accountId { + final profileData = payload['profile']; + if (profileData is! Map) return null; + final contact = profileData['contact']; + if (contact is! Map) return null; + return contact['id'] as int?; + } + + String? _nestedToken(String key) { + final attrs = payload['tokenAttrs']; + if (attrs is! Map) return null; + final entry = attrs[key]; + if (entry is! Map) return null; + return entry['token'] as String?; + } +} + +class TwoFactorResult { + final String loginToken; + + const TwoFactorResult({required this.loginToken}); +} + +class LoginSyncParams { + final int chatsSync; + final int contactsSync; + final int callsSync; + final int draftsSync; + final int bannersSync; + final int presenceSync; + final int lastLogin; + final String? configHash; + final String? chatCacheFingerprint; + + const LoginSyncParams({ + required this.chatsSync, + required this.contactsSync, + required this.callsSync, + required this.draftsSync, + required this.bannersSync, + required this.presenceSync, + required this.lastLogin, + this.configHash, + this.chatCacheFingerprint, + }); + + static Future fromDatabase(int accountId) async { + final values = await AppDatabase.getAllSyncValues(accountId); + final lastLogin = values[SyncKey.lastLogin]; + if (lastLogin == null) return null; + + return LoginSyncParams( + chatsSync: int.tryParse(values[SyncKey.chatsSync] ?? '') ?? 0, + contactsSync: int.tryParse(values[SyncKey.contactsSync] ?? '') ?? 0, + callsSync: int.tryParse(values[SyncKey.callsSync] ?? '') ?? 0, + draftsSync: int.tryParse(values[SyncKey.draftsSync] ?? '') ?? 0, + bannersSync: int.tryParse(values[SyncKey.bannersSync] ?? '') ?? 0, + presenceSync: int.tryParse(values[SyncKey.presenceSync] ?? '') ?? -1, + lastLogin: int.tryParse(lastLogin) ?? 0, + configHash: values[SyncKey.configHash], + chatCacheFingerprint: values[SyncKey.chatCacheFingerprint], + ); + } +} + +class SessionInfo { + final int? id; + final String client; + final String location; + final bool current; + final int time; + final String info; + + const SessionInfo({ + this.id, + required this.client, + required this.location, + required this.current, + required this.time, + required this.info, + }); + + factory SessionInfo.fromMap(Map map) { + return SessionInfo( + id: map['id'] is int + ? map['id'] + : (int.tryParse(map['id']?.toString() ?? '')), + client: map['client'] ?? '', + location: map['location'] ?? '', + current: map['current'] ?? false, + time: map['time'] ?? 0, + info: map['info'] ?? '', + ); + } + + int get uniqueId => Object.hash(id, client, time, info); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SessionInfo && + runtimeType == other.runtimeType && + id == other.id && + client == other.client && + location == other.location && + current == other.current && + time == other.time && + info == other.info; + + @override + int get hashCode => Object.hash(id, client, location, current, time, info); +} + +class LoginResult { + final ProfileData profile; + final String? updatedToken; + final int serverTime; + final Map raw; + + const LoginResult({ + required this.profile, + required this.updatedToken, + required this.serverTime, + required this.raw, + }); +} diff --git a/lib/backend/modules/account/privacy_module.dart b/lib/backend/modules/account/privacy_module.dart new file mode 100644 index 0000000..37803e3 --- /dev/null +++ b/lib/backend/modules/account/privacy_module.dart @@ -0,0 +1,105 @@ +import '../../api.dart'; +import '../../../core/protocol/opcode_map.dart'; +import '../../../core/protocol/packet.dart'; +import '../../../core/storage/app_database.dart'; +import '../../../core/storage/token_storage.dart'; +import 'account_base.dart'; +import 'account_models.dart'; + +class PrivacyModule extends AccountApiBase { + PrivacyModule(super.api); + + static const String _defaultPushSound = 'oki.aiff'; + + Future getPrivacyConfig() async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + final saved = await AppDatabase.getPrivacyConfig(accountId); + if (saved != null) return PrivacyConfig.fromJson(saved); + } + return PrivacyConfig.empty(); + } + + Future> getBlockedContacts() async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.contactList, { + 'status': 'BLOCKED', + 'count': 100, + 'from': 0, + }); + final data = requireMapPayload(packet, 'getBlockedContacts'); + final contacts = data['contacts'] as List?; + if (contacts == null) return []; + return contacts + .whereType() + .map((c) => BlockedContact.fromMap(c.cast())) + .toList(); + } + + Future updatePrivacyConfig( + Map settings, + ) async { + ensureOnline(); + final payload = { + 'settings': {'user': settings}, + }; + final packet = await api.sendRequest(Opcode.config, payload); + final data = requireMapPayload(packet, 'updatePrivacyConfig'); + final user = data['user']; + if (user is! Map) { + throw Exception('updatePrivacyConfig: отсутствует user в payload'); + } + final config = PrivacyConfig.fromMap(user.cast()); + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await AppDatabase.savePrivacyConfig(accountId, config.toJson()); + } + return config; + } + + Future setChatsPushNotification(bool value) => + updatePrivacyConfig({'CHATS_PUSH_NOTIFICATION': value ? 'ON' : 'OFF'}); + + Future setMessagePreview(bool value) => + updatePrivacyConfig({'PUSH_DETAILS': value}); + + Future setNotificationSound(bool value) => + updatePrivacyConfig({ + 'PUSH_SOUND': value ? _defaultPushSound : '', + 'CHATS_PUSH_SOUND': value ? _defaultPushSound : '', + }); + + Future setCallNotifications(bool value) => + updatePrivacyConfig({'M_CALL_PUSH_NOTIFICATION': value ? 'ON' : 'OFF'}); + + Future setNewContacts(bool value) => + updatePrivacyConfig({'PUSH_NEW_CONTACTS': value}); + + Future registerPushToken(String pushToken) async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.config, { + 'pushToken': pushToken, + 'pushOptions': 0, + }); + if (packet.isError) { + final msg = messageFromErrorPayload(packet.payload).toUpperCase(); + if (msg.contains('WRONG_DEVICE_TOKEN') || + msg.contains('WRONG.DEVICE.TOKEN')) { + throw const WrongDeviceTokenException(); + } + throw PacketError(messageFromErrorPayload(packet.payload)); + } + } + + Future unregisterPushToken(String pushToken) async { + if (api.state != SessionState.online) return; + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + final authToken = await TokenStorage.readToken(accountId); + if (authToken == null) return; + await api.sendRequest(Opcode.logout, { + 'token': authToken, + 'pushToken': pushToken, + }); + } +} diff --git a/lib/backend/modules/account/profile_module.dart b/lib/backend/modules/account/profile_module.dart new file mode 100644 index 0000000..28deb0b --- /dev/null +++ b/lib/backend/modules/account/profile_module.dart @@ -0,0 +1,110 @@ +import 'dart:async'; + +import '../../../core/protocol/opcode_map.dart'; +import '../../../core/protocol/packet.dart'; +import '../../../core/storage/app_database.dart'; +import 'account_base.dart'; + +class ProfileModule extends AccountApiBase { + ProfileModule(super.api); + + Future _applyProfileResponse(Packet packet) async { + if (packet.isError) { + throw Exception(packet.payload?.toString() ?? 'Server error'); + } + final data = packet.payload as Map?; + if (data == null) throw Exception('Empty response'); + final profile = data['profile'] as Map?; + if (profile == null) throw Exception('No profile in response'); + final contact = profile['contact'] as Map?; + if (contact == null) throw Exception('No contact in response'); + final newProfile = ProfileData.fromServerMap( + contact.cast(), + ); + await AppDatabase.saveProfile(newProfile, isActive: true); + return newProfile; + } + + Future updateProfileName( + String firstName, + String? lastName, + ) async { + ensureOnline(); + final payload = {'firstName': firstName}; + if (lastName != null) payload['lastName'] = lastName; + final packet = await api.sendRequest(Opcode.profile, payload); + return _applyProfileResponse(packet); + } + + Future updateProfileAvatar( + String photoToken, { + String avatarType = 'USER_AVATAR', + }) async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.profile, { + 'photoToken': photoToken, + 'avatarType': avatarType, + }); + return _applyProfileResponse(packet); + } + + Future getAvatarUploadUrl() async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.photoUpload, { + 'count': 1, + 'profile': true, + }); + if (packet.isError) { + throw Exception(packet.payload?.toString() ?? 'Server error'); + } + final data = packet.payload as Map?; + if (data == null) throw Exception('Empty response'); + final url = data['url'] as String?; + if (url == null) throw Exception('No url in response'); + return url; + } + + Future removeProfilePhoto(int photoId) async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.removeContactPhoto, { + 'photoId': photoId, + }); + return _applyProfileResponse(packet); + } + + Future processProfileUpdate( + Future requestFuture, + String tag, + ) async { + final completer = Completer(); + final sub = api.pushStream + .where((p) => p.opcode == Opcode.notifProfile) + .listen((push) { + if (completer.isCompleted) return; + final payload = push.payload; + if (payload is! Map) return; + final profile = payload['profile']; + if (profile is! Map) return; + final contact = profile['contact']; + if (contact is! Map) return; + completer.complete( + ProfileData.fromServerMap(contact.cast()), + ); + }); + final timer = Timer(const Duration(seconds: 15), () { + if (!completer.isCompleted) { + completer.completeError( + Exception('Таймаут ожидания обновления профиля'), + ); + } + }); + try { + final packet = await requestFuture; + checkPacketError(packet, tag); + return await completer.future; + } finally { + timer.cancel(); + await sub.cancel(); + } + } +} diff --git a/lib/backend/modules/account/sessions_module.dart b/lib/backend/modules/account/sessions_module.dart new file mode 100644 index 0000000..4b2d2a4 --- /dev/null +++ b/lib/backend/modules/account/sessions_module.dart @@ -0,0 +1,41 @@ +import '../../../core/protocol/opcode_map.dart'; +import 'account_base.dart'; +import 'account_models.dart'; + +class SessionsModule extends AccountApiBase { + SessionsModule(super.api); + + Future> getSessions() async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.sessionsInfo, {}); + checkPacketError(packet, 'getSessions'); + final data = packet.payload; + if (data is! Map || data['sessions'] is! List) return []; + final sessions = data['sessions'] as List; + return sessions + .map((s) => SessionInfo.fromMap(s as Map)) + .toList(); + } + + Future terminateOtherSessions() async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.sessionsClose, {}); + checkPacketError(packet, 'terminateOtherSessions'); + } + + Future authorizeWebQrLogin(String qrLink) async { + ensureOnline(); + final link = qrLink.trim(); + if (link.isEmpty) { + throw ArgumentError('Пустая ссылка из QR'); + } + + await api.sendRequest(Opcode.ping, {'interactive': true}); + await api.sendRequest(Opcode.sessionsInfo, {}); + await Future.delayed(const Duration(milliseconds: 300)); + final packet = await api.sendRequest(Opcode.authQrApprove, { + 'qrLink': link, + }); + checkPacketError(packet, 'authorizeWebQrLogin'); + } +} diff --git a/lib/backend/modules/account/two_factor_module.dart b/lib/backend/modules/account/two_factor_module.dart new file mode 100644 index 0000000..e6f7b47 --- /dev/null +++ b/lib/backend/modules/account/two_factor_module.dart @@ -0,0 +1,191 @@ +import '../../../core/protocol/opcode_map.dart'; +import '../../../core/storage/app_database.dart'; +import 'account_base.dart'; +import 'account_models.dart'; +import 'profile_module.dart'; + +class TwoFactorModule extends AccountApiBase { + final ProfileModule _profile; + TwoFactorModule(super.api, this._profile); + + Future create2faTrack() async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.authCreateTrack, {'type': 0}); + final data = requireMapPayload(packet, 'create2faTrack'); + final trackId = data['trackId'] as String?; + if (trackId == null) { + throw Exception('create2faTrack: отсутствует trackId'); + } + return trackId; + } + + Future set2faPassword(String trackId, String password) async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.authValidatePassword, { + 'trackId': trackId, + 'password': password, + }); + checkPacketError(packet, 'set2faPassword'); + if (packet.payload != null && packet.payload is! Map) { + throw Exception('set2faPassword: неожиданный ответ'); + } + } + + Future set2faHint(String trackId, String hint) async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.authValidateHint, { + 'trackId': trackId, + 'hint': hint, + }); + checkPacketError(packet, 'set2faHint'); + if (packet.payload != null && packet.payload is! Map) { + throw Exception('set2faHint: неожиданный ответ'); + } + } + + Future verify2faEmail(String trackId, String email) async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.authVerifyEmail, { + 'trackId': trackId, + 'email': email, + }); + final data = requireMapPayload(packet, 'verify2faEmail'); + final blockingDuration = data['blockingDuration'] as int? ?? 60; + return blockingDuration; + } + + Future verify2faCode(String trackId, String code) async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.authCheckEmail, { + 'trackId': trackId, + 'verifyCode': code, + }); + final data = requireMapPayload(packet, 'verify2faCode'); + final email = data['email'] as String? ?? ''; + return email; + } + + Future confirm2fa({ + required String trackId, + required String password, + String? hint, + bool withEmail = true, + }) async { + ensureOnline(); + final capabilities = [0, if (hint != null) 3, if (withEmail) 4]; + final payload = { + 'expectedCapabilities': capabilities, + 'trackId': trackId, + 'password': password, + }; + if (hint != null) payload['hint'] = hint; + return _profile.processProfileUpdate( + api.sendRequest(Opcode.authSet2fa, payload), + 'confirm2fa', + ); + } + + Future enter2faPanel() async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.authCreateTrack, {'type': 0}); + final data = requireMapPayload(packet, 'enter2faPanel'); + final trackId = data['trackId'] as String?; + if (trackId == null) { + throw Exception('enter2faPanel: отсутствует trackId'); + } + return trackId; + } + + Future get2faDetails(String trackId) async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.auth2faDetails, { + 'trackId': trackId, + }); + final data = requireMapPayload(packet, 'get2faDetails'); + final password = data['password'] as Map?; + return TwoFactorDetails( + enabled: password?['enabled'] ?? false, + email: password?['email'] as String?, + hint: password?['hint'] as String?, + ); + } + + Future get2faStatus() async { + final trackId = await enter2faPanel(); + return get2faDetails(trackId); + } + + Future check2faPassword(String trackId, String password) async { + ensureOnline(); + final packet = await api.sendRequest(Opcode.authCheckPassword, { + 'trackId': trackId, + 'password': password, + }); + checkPacketError(packet, 'check2faPassword'); + final data = packet.payload; + if (data is Map && data['error'] != null) { + throw Exception('Неверный пароль'); + } + } + + Future update2faPassword({ + required String trackId, + required String newPassword, + String? hint, + }) async { + ensureOnline(); + final validatePacket = await api.sendRequest(Opcode.authValidatePassword, { + 'trackId': trackId, + 'password': newPassword, + }); + checkPacketError(validatePacket, 'update2faPassword: validate'); + if (validatePacket.payload != null && validatePacket.payload is! Map) { + throw Exception('update2faPassword: неожиданный ответ при валидации'); + } + + if (hint != null) { + final hintPacket = await api.sendRequest(Opcode.authValidateHint, { + 'trackId': trackId, + 'hint': hint, + }); + checkPacketError(hintPacket, 'update2faPassword: hint'); + } + + final payload = { + 'expectedCapabilities': [1, if (hint != null) 3], + 'trackId': trackId, + 'password': newPassword, + }; + if (hint != null) payload['hint'] = hint; + + return _profile.processProfileUpdate( + api.sendRequest(Opcode.authSet2fa, payload), + 'update2faPassword', + ); + } + + Future commit2faEmailChange(String trackId) async { + ensureOnline(); + final payload = { + 'expectedCapabilities': [4], + 'trackId': trackId, + }; + return _profile.processProfileUpdate( + api.sendRequest(Opcode.authSet2fa, payload), + 'commit2faEmailChange', + ); + } + + Future remove2fa(String trackId) async { + ensureOnline(); + final payload = { + 'expectedCapabilities': [5], + 'trackId': trackId, + 'remove2fa': true, + }; + return _profile.processProfileUpdate( + api.sendRequest(Opcode.authSet2fa, payload), + 'remove2fa', + ); + } +} diff --git a/lib/backend/modules/calls.dart b/lib/backend/modules/calls.dart index 286cd14..0b0299a 100644 --- a/lib/backend/modules/calls.dart +++ b/lib/backend/modules/calls.dart @@ -1,21 +1,18 @@ -// Backend module for parsing calls from Komet platform import 'dart:convert'; -import 'dart:math'; import 'contacts.dart'; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; +import '../../core/utils/ids.dart'; +import '../../core/utils/logger.dart'; enum CallStatus { missed, canceled, outgoing, incoming } -/// Параметры подключения для исходящего звонка (ответ opcode 78). class OutgoingCallParams { final String conversationId; - /// Полный ws2 URL с уже вшитым токеном (`internalCallerParams.endpoint`). final String endpoint; - /// Наш id в системе звонков (`internalCallerParams.id.internal`). final int callsUserId; final int peerExternalId; @@ -68,70 +65,92 @@ class CallLogEntry { }); } +class _CallerEndpointMissingException implements Exception { + final String message; + + const _CallerEndpointMissingException(this.message); + + @override + String toString() => 'Exception: $message'; +} + +typedef _CallerEndpoint = ({String endpoint, int callsUserId, int? external}); + class CallsModule { final Api _api; CallsModule(this._api); - /// Инициирует исходящий 1:1 звонок (opcode 78). + _CallerEndpoint _parseCallerEndpoint( + Map payload, + String key, { + required String context, + }) { + final raw = payload[key]; + final parsed = raw is String + ? jsonDecode(raw) as Map + : const {}; + + final endpoint = parsed['endpoint'] as String?; + if (endpoint == null) { + throw _CallerEndpointMissingException('$context: no endpoint'); + } + + final id = parsed['id']; + final callsUserId = (id is Map ? id['internal'] as int? : null) ?? 0; + final external = id is Map ? int.tryParse('${id['external']}') : null; + + return (endpoint: endpoint, callsUserId: callsUserId, external: external); + } + Future initiateCall( int calleeId, { bool isVideo = false, }) async { - final conversationId = _uuidV4(); + final conversationId = uuidV4(); - final response = await _api.sendRequest(Opcode.videoChatStartActive, { + final payload = await _api.sendRequestMap(Opcode.videoChatStartActive, { 'conversationId': conversationId, 'calleeIds': [calleeId], 'internalParams': _internalParams(), 'isVideo': isVideo, }); - if (!response.isOk || response.payload is! Map) { + if (payload == null) { throw Exception('initiateCall: bad response'); } - final payload = response.payload as Map; - final icpRaw = payload['internalCallerParams']; - final icp = icpRaw is String - ? jsonDecode(icpRaw) as Map - : const {}; - - final endpoint = icp['endpoint'] as String?; - if (endpoint == null) { - throw Exception('initiateCall: no endpoint'); - } - - final id = icp['id']; - final callsUserId = (id is Map ? id['internal'] as int? : null) ?? 0; - final external = - (id is Map ? int.tryParse('${id['external']}') : null) ?? calleeId; + final parsed = _parseCallerEndpoint( + payload, + 'internalCallerParams', + context: 'initiateCall', + ); return OutgoingCallParams( conversationId: (payload['conversationId'] as String?) ?? conversationId, - endpoint: endpoint, - callsUserId: callsUserId, - peerExternalId: external, + endpoint: parsed.endpoint, + callsUserId: parsed.callsUserId, + peerExternalId: parsed.external ?? calleeId, isVideo: isVideo, ); } String _internalParams() => jsonEncode({ - 'platform': 'ANDROID', - 'sdkVersion': '0.1.16.4', - 'clientAppKey': 'CGPGAGLGDIHBABABA', - 'deviceId': _api.deviceId ?? '', - 'protocolVersion': 5, - 'onlyAdminCanRecord': false, - 'waitForAdmin': false, - 'capabilities': '3c03f', - }); + 'platform': 'ANDROID', + 'sdkVersion': '0.1.16.4', + 'clientAppKey': 'CGPGAGLGDIHBABABA', + 'deviceId': _api.deviceId ?? '', + 'protocolVersion': 5, + 'onlyAdminCanRecord': false, + 'waitForAdmin': false, + 'capabilities': '3c03f', + }); Future resolveCallLink(String url) async { - final response = await _api.sendRequest(Opcode.linkInfo, {'link': url}); - if (!response.isOk || response.payload is! Map) return null; + final payload = await _api.sendRequestMap(Opcode.linkInfo, {'link': url}); + if (payload == null) return null; - final vc = (response.payload as Map)['videoConference']; + final vc = payload['videoConference']; if (vc is! Map) return null; return CallLinkPreview( @@ -146,59 +165,38 @@ class CallsModule { String token, { bool isVideo = false, }) async { - final response = await _api.sendRequest(Opcode.videoChatJoinByLink, { + final payload = await _api.sendRequestMap(Opcode.videoChatJoinByLink, { 'joinLink': token, 'internalParams': _internalParams(), 'isVideo': isVideo, }); - if (!response.isOk || response.payload is! Map) { + if (payload == null) { throw Exception('joinByLink: bad response'); } - final payload = response.payload as Map; - final ipRaw = payload['internalParams']; - final ip = ipRaw is String - ? jsonDecode(ipRaw) as Map - : const {}; - - final endpoint = ip['endpoint'] as String?; - if (endpoint == null) { - throw Exception('joinByLink: no endpoint'); - } - - final id = ip['id']; - final callsUserId = (id is Map ? id['internal'] as int? : null) ?? 0; + final parsed = _parseCallerEndpoint( + payload, + 'internalParams', + context: 'joinByLink', + ); return OutgoingCallParams( conversationId: (payload['conversationId'] as String?) ?? '', - endpoint: endpoint, - callsUserId: callsUserId, + endpoint: parsed.endpoint, + callsUserId: parsed.callsUserId, peerExternalId: 0, isVideo: isVideo, ); } - static String _uuidV4() { - final r = Random(); - final b = List.generate(16, (_) => r.nextInt(256)); - b[6] = (b[6] & 0x0f) | 0x40; - b[8] = (b[8] & 0x3f) | 0x80; - String hex(int i) => b[i].toRadixString(16).padLeft(2, '0'); - final s = List.generate(16, hex).join(); - return '${s.substring(0, 8)}-${s.substring(8, 12)}-${s.substring(12, 16)}' - '-${s.substring(16, 20)}-${s.substring(20)}'; - } - - /// Fetch call history from opcode 79 Future> fetchHistory( int accountId, int currentUserId, ) async { - final response = await _api.sendRequest(Opcode.videoChatHistory, {}); - if (!response.isOk || response.payload is! Map) return []; + final payload = await _api.sendRequestMap(Opcode.videoChatHistory, {}); + if (payload == null) return []; - final payload = response.payload as Map; return parseHistoryPayload( payload, accountId, @@ -224,19 +222,19 @@ class CallsModule { } } } - } catch (_) {} + } catch (e) { + logger.w('resolveContacts: $e'); + } return out; } Future deleteHistory(List historyIds) async { if (historyIds.isEmpty) return true; - final response = await _api.sendRequest(Opcode.videoChatDeleteHistory, { + return _api.sendRequestOk(Opcode.videoChatDeleteHistory, { 'historyIds': historyIds, }); - return response.isOk; } - /// Парсинг истории звонков (opcode 79: videoChatHistory) static Future> parseHistoryPayload( Map payload, int accountId, @@ -249,8 +247,7 @@ class CallsModule { final recentContacts = await ContactsModule.getContacts(accountId); final contactsMap = {for (final c in recentContacts) c.id: c}; - final parsed = - <({int peerId, CallStatus status, int time, String id})>[]; + final parsed = <({int peerId, CallStatus status, int time, String id})>[]; for (final item in history.whereType()) { final msg = item['message']; diff --git a/lib/backend/modules/chat_parsing.dart b/lib/backend/modules/chat_parsing.dart new file mode 100644 index 0000000..e92089f --- /dev/null +++ b/lib/backend/modules/chat_parsing.dart @@ -0,0 +1,293 @@ +import '../../core/utils/logger.dart'; +import 'chat_preview.dart'; +import 'chats.dart'; + +Map> buildContactsMap(dynamic contacts) { + if (contacts is! List) return {}; + final result = >{}; + for (final c in contacts.whereType()) { + final id = c['id']; + if (id is int) result[id] = c.cast(); + } + return result; +} + +CachedChat? parseChatRow( + Map chat, + int accountId, + int currentUserId, + Map> contactsMap, + Map chatsConfig, + Map presenceMap, + Map existing, + int cachedAt, +) { + try { + final id = chat['id']; + if (id is! int) return null; + + final type = (chat['type'] as String?) ?? 'DIALOG'; + final otherId = type == 'DIALOG' + ? _otherParticipantId(chat['participants'], currentUserId) + : null; + + final titleIcon = _resolveTitleAndIcon( + chat, + id, + type, + otherId, + contactsMap, + existing, + ); + final lastMessage = _resolveLastMessage(chat['lastMessage']); + final muteFav = _resolveMuteAndFavorite(chatsConfig, id, existing); + final presence = _resolvePresence(type, otherId, presenceMap); + final adminsOwner = _resolveAdmins(chat); + + return CachedChat( + id: id, + accountId: accountId, + type: type, + title: titleIcon.title, + iconUrl: titleIcon.iconUrl, + lastMsgId: lastMessage.id, + lastMsgTime: lastMessage.time, + lastMsgText: lastMessage.text, + lastMsgElements: lastMessage.elements, + lastMsgSenderId: lastMessage.senderId, + unreadCount: (chat['newMessages'] as int?) ?? 0, + lastEventTime: (chat['lastEventTime'] as int?) ?? 0, + cachedAt: cachedAt, + favIndex: muteFav.favIndex, + dontDisturbUntil: muteFav.dontDisturbUntil, + isOnline: presence.isOnline, + seenTime: presence.seenTime, + participants: parseParticipants(chat['participants']), + options: titleIcon.options, + owner: adminsOwner.owner, + admins: adminsOwner.admins, + ); + } catch (e) { + logger.e("Ошибка при парсинге чата: $e"); + return null; + } +} + +({String? title, String? iconUrl, Set options}) _resolveTitleAndIcon( + Map chat, + int id, + String type, + int? otherId, + Map> contactsMap, + Map existing, +) { + if (type == 'DIALOG') { + final contact = otherId != null ? contactsMap[otherId] : null; + if (contact != null) { + Set options = const {}; + final contactOpts = contact['options']; + if (contactOpts is List) { + options = contactOpts.whereType().toSet(); + } + return ( + title: _nameFromContact(contact), + iconUrl: contact['baseUrl'] as String?, + options: options, + ); + } + return ( + title: existing[id]?.title, + iconUrl: existing[id]?.iconUrl, + options: existing[id]?.options ?? const {}, + ); + } + Set options = const {}; + final chatOpts = chat['options']; + if (chatOpts is Map) { + options = { + for (final entry in chatOpts.entries) + if (entry.value == true && entry.key is String) entry.key as String, + }; + } + return ( + title: chat['title'] as String?, + iconUrl: chat['baseIconUrl'] as String?, + options: options, + ); +} + +({int? id, int? time, String? text, String? elements, int? senderId}) +_resolveLastMessage(dynamic lastMsg) { + if (lastMsg is! Map) { + return (id: null, time: null, text: null, elements: null, senderId: null); + } + return ( + id: lastMsg['id'] as int?, + time: lastMsg['time'] as int?, + text: messagePreviewText(lastMsg), + elements: messagePreviewElements(lastMsg), + senderId: lastMsg['sender'] as int?, + ); +} + +({int? favIndex, int dontDisturbUntil}) _resolveMuteAndFavorite( + Map chatsConfig, + int id, + Map existing, +) { + final config = chatsConfig[id.toString()] ?? chatsConfig[id]; + if (config is Map) { + return ( + favIndex: config['favIndex'] as int?, + dontDisturbUntil: (config['dontDisturbUntil'] as int?) ?? 0, + ); + } + final ex = existing[id]; + if (ex != null) { + return (favIndex: ex.favIndex, dontDisturbUntil: ex.dontDisturbUntil); + } + return (favIndex: null, dontDisturbUntil: 0); +} + +({int seenTime, bool isOnline}) _resolvePresence( + String type, + int? otherId, + Map presenceMap, +) { + if (type != 'DIALOG' || otherId == null) { + return (seenTime: 0, isOnline: false); + } + final presence = presenceMap[otherId.toString()] ?? presenceMap[otherId]; + if (presence is Map) { + return ( + seenTime: (presence['seen'] as int?) ?? 0, + isOnline: (presence['status'] as int?) == 1, + ); + } + return (seenTime: 0, isOnline: false); +} + +({int? owner, Set admins}) _resolveAdmins(Map chat) { + int? owner; + final ownerRaw = chat['owner']; + if (ownerRaw is int) { + owner = ownerRaw; + } else if (ownerRaw is String) { + owner = int.tryParse(ownerRaw); + } + + Set admins = const {}; + final adminsRaw = chat['admins']; + if (adminsRaw is List) { + admins = adminsRaw + .map((e) => e is int ? e : int.tryParse(e.toString())) + .whereType() + .toSet(); + } else { + final adminParticipants = chat['adminParticipants']; + if (adminParticipants is Map) { + admins = adminParticipants.keys + .map((k) => k is int ? k : int.tryParse(k.toString())) + .whereType() + .toSet(); + } + } + return (owner: owner, admins: admins); +} + +int? _otherParticipantId(dynamic participants, int currentUserId) { + if (participants is! Map) return null; + for (final key in participants.keys) { + final id = key is int ? key : int.tryParse(key.toString()); + if (id != null && id != currentUserId) return id; + } + return null; +} + +String? _nameFromContact(Map contact) { + final names = contact['names']; + if (names is! List || names.isEmpty) return null; + final nameRaw = names.firstWhere( + (n) => n is Map && n['type'] == 'ONEME', + orElse: () => names.firstWhere((n) => n is Map, orElse: () => null), + ); + if (nameRaw is! Map) return null; + final name = nameRaw; + return name['name'] as String?; +} + +List parseSearchResult(dynamic payload) { + final result = (payload as Map?)?['result']; + if (result is! List) return const []; + final hits = []; + for (final item in result) { + if (item is! Map) continue; + final chat = item['chat']; + if (chat is! Map) continue; + final id = chat['id']; + if (id is! int) continue; + final last = chat['lastMessage']; + final link = chat['link']; + hits.add( + ChatSearchHit( + id: id, + type: (chat['type'] as String?) ?? 'CHAT', + title: chat['title'] as String?, + avatarUrl: chat['baseIconUrl'] as String?, + subtitle: link is String && link.isNotEmpty + ? '@$link' + : (last is Map ? last['text'] as String? : null), + ), + ); + } + return hits; +} + +List parseMessageResult(dynamic payload) { + final result = (payload as Map?)?['result']; + if (result is! List) return const []; + final hits = []; + for (final item in result) { + if (item is! Map) continue; + final message = item['message']; + if (message is! Map) continue; + final chatId = item['chatId']; + if (chatId is! int || chatId == 0) continue; + hits.add( + MessageSearchHit( + chatId: chatId, + messageId: message['id']?.toString(), + text: message['text'] as String?, + time: (message['time'] as int?) ?? 0, + senderId: (message['sender'] as int?) ?? 0, + ), + ); + } + return hits; +} + +bool sameChatContent(CachedChat a, CachedChat b) { + if (a.title != b.title) return false; + if (a.iconUrl != b.iconUrl) return false; + if (a.owner != b.owner) return false; + if (a.dontDisturbUntil != b.dontDisturbUntil) return false; + if (a.favIndex != b.favIndex) return false; + if (a.lastMsgId != b.lastMsgId) return false; + if (a.lastMsgTime != b.lastMsgTime) return false; + if (a.lastMsgText != b.lastMsgText) return false; + if (a.lastMsgElements != b.lastMsgElements) return false; + if (a.lastMsgSenderId != b.lastMsgSenderId) return false; + if (a.unreadCount != b.unreadCount) return false; + if (a.lastEventTime != b.lastEventTime) return false; + if (a.isOnline != b.isOnline) return false; + if (a.seenTime != b.seenTime) return false; + if (a.admins.length != b.admins.length) return false; + if (!a.admins.containsAll(b.admins)) return false; + if (a.options.length != b.options.length) return false; + if (!a.options.containsAll(b.options)) return false; + if (a.participants.length != b.participants.length) return false; + for (final e in a.participants.entries) { + if (b.participants[e.key] != e.value) return false; + } + return true; +} diff --git a/lib/backend/modules/chat_preview.dart b/lib/backend/modules/chat_preview.dart new file mode 100644 index 0000000..38b0497 --- /dev/null +++ b/lib/backend/modules/chat_preview.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; + +String? attachPreviewLabel(dynamic attaches) { + if (attaches is! List || attaches.isEmpty) return null; + final first = attaches.first; + if (first is! Map) return null; + final type = (first['_type'] as String? ?? '').toUpperCase(); + switch (type) { + case 'PHOTO': + return 'Фото'; + case 'VIDEO': + return 'Видео'; + case 'AUDIO': + return 'Голосовое сообщение'; + case 'FILE': + final name = first['name']?.toString(); + return name != null && name.isNotEmpty ? 'Файл: $name' : 'Файл'; + case 'STICKER': + return 'Стикер'; + case 'SHARE': + final title = first['title']?.toString(); + return title != null && title.isNotEmpty ? 'Ссылка: $title' : 'Ссылка'; + case 'POLL': + final title = first['title']?.toString(); + return title != null && title.isNotEmpty ? 'Опрос: $title' : 'Опрос'; + case 'LOCATION': + return 'Геопозиция'; + case 'CONTACT': + return 'Контакт'; + case 'CONTROL': + return _controlPreviewLabel(first); + case 'INLINE_KEYBOARD': + return null; + case 'CALL': + final video = first['callType']?.toString().toUpperCase() == 'VIDEO'; + final dur = (first['duration'] as num?)?.toInt() ?? 0; + final hangup = first['hangupType']?.toString(); + final failed = + dur == 0 || + hangup == 'CANCELED' || + hangup == 'REJECTED' || + hangup == 'MISSED'; + if (first['joinLink'] != null) { + return video ? 'Групповой видеозвонок' : 'Групповой звонок'; + } + if (failed) { + return video ? 'Пропущенный видеозвонок' : 'Пропущенный звонок'; + } + return video ? 'Видеозвонок' : 'Звонок'; + default: + return 'Вложение'; + } +} + +String? _controlPreviewLabel(Map c) { + final title = c['title']?.toString(); + if (title != null && title.isNotEmpty) return title; + final short = c['shortMessage']?.toString(); + if (short != null && short.isNotEmpty) return short; + switch (c['event']?.toString()) { + case 'new': + return 'Чат создан'; + case 'add': + case 'joinByLink': + return 'Новый участник'; + case 'leave': + return 'Участник вышел'; + case 'remove': + return 'Участник удалён'; + case 'pin': + return 'Закреплённое сообщение'; + case 'changeTitle': + return 'Название чата изменено'; + case 'changeIcon': + return 'Фото чата обновлено'; + default: + return 'Системное сообщение'; + } +} + +String? messagePreviewText(Map msg) { + final link = msg['link']; + if (link is Map && link['type']?.toString().toUpperCase() == 'FORWARD') { + final original = link['message']; + final inner = original is Map ? _bodyPreviewText(original) : null; + return inner != null && inner.isNotEmpty + ? '↪ $inner' + : '↪ Пересланное сообщение'; + } + return _bodyPreviewText(msg); +} + +String? _bodyPreviewText(Map msg) { + final text = msg['text']?.toString(); + if (text != null && text.isNotEmpty) return text; + return attachPreviewLabel(msg['attaches']); +} + +String? messagePreviewElements(Map msg) { + final text = msg['text']; + if (text is! String || text.isEmpty) return null; + final elements = msg['elements']; + if (elements is List && elements.isNotEmpty) return jsonEncode(elements); + return null; +} diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 10fe4fd..f2fdc14 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -13,17 +13,21 @@ import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; import '../../core/utils/text_format.dart'; import '../api.dart'; +import 'chat_parsing.dart'; +import 'chat_preview.dart'; import 'folders.dart'; import 'messages.dart' show ContactCache, CachedMessage; -Map _parseParticipants(dynamic raw) { +Map parseParticipants(dynamic raw) { try { final decoded = raw is String ? jsonDecode(raw) : raw; if (decoded is Map) { - return decoded.map((k, v) => MapEntry( - k is int ? k : int.parse(k.toString()), - v is int ? v : int.tryParse(v.toString()) ?? 0, - )); + return decoded.map( + (k, v) => MapEntry( + k is int ? k : int.parse(k.toString()), + v is int ? v : int.tryParse(v.toString()) ?? 0, + ), + ); } } catch (e) { logger.e('Failed to parse participants: $e'); @@ -80,8 +84,8 @@ class CachedChat { this.owner, this.admins = const {}, }) : lastMsgTextOneLine = lastMsgText != null && lastMsgText.contains('\n') - ? lastMsgText.replaceAll('\n', ' ') - : lastMsgText; + ? lastMsgText.replaceAll('\n', ' ') + : lastMsgText; bool get isOfficial => options.contains('OFFICIAL'); @@ -112,6 +116,8 @@ class CachedChat { return dontDisturbUntil > DateTime.now().millisecondsSinceEpoch; } + bool get isLastMsgDeleted => lastMsgText == ChatsModule.lastMsgPlaceholder; + factory CachedChat.fromDbRow(Map row) => CachedChat( id: row['id'] as int, accountId: row['account_id'] as int, @@ -131,7 +137,7 @@ class CachedChat { dontDisturbUntil: row['dont_disturb_until'] as int, isOnline: (row['is_online'] as int) == 1, seenTime: row['seen_time'] as int, - participants: _parseParticipants(row['participants']), + participants: parseParticipants(row['participants']), options: _decodeOptions(row['options']), owner: row['owner'] as int?, admins: _decodeAdmins(row['admins']), @@ -170,11 +176,75 @@ class CachedChat { 'dont_disturb_until': dontDisturbUntil, 'is_online': isOnline ? 1 : 0, 'seen_time': seenTime, - 'participants': jsonEncode(participants.map((k, v) => MapEntry(k.toString(), v))), + 'participants': jsonEncode( + participants.map((k, v) => MapEntry(k.toString(), v)), + ), 'options': options.isEmpty ? null : options.join(','), 'owner': owner, 'admins': admins.isEmpty ? null : admins.join(','), }; + + static const Object _keep = Object(); + + CachedChat copyWith({ + String? type, + Object? title = _keep, + Object? iconUrl = _keep, + Object? lastMsgId = _keep, + Object? lastMsgTime = _keep, + Object? lastMsgText = _keep, + Object? lastMsgElements = _keep, + Object? lastMsgSenderId = _keep, + Object? lastMsgStatus = _keep, + int? unreadCount, + int? lastEventTime, + int? cachedAt, + Object? favIndex = _keep, + int? dontDisturbUntil, + bool? isOnline, + int? seenTime, + Map? participants, + Set? options, + Object? owner = _keep, + Set? admins, + }) { + return CachedChat( + id: id, + accountId: accountId, + type: type ?? this.type, + title: identical(title, _keep) ? this.title : title as String?, + iconUrl: identical(iconUrl, _keep) ? this.iconUrl : iconUrl as String?, + lastMsgId: identical(lastMsgId, _keep) + ? this.lastMsgId + : lastMsgId as int?, + lastMsgTime: identical(lastMsgTime, _keep) + ? this.lastMsgTime + : lastMsgTime as int?, + lastMsgText: identical(lastMsgText, _keep) + ? this.lastMsgText + : lastMsgText as String?, + lastMsgElements: identical(lastMsgElements, _keep) + ? this.lastMsgElements + : lastMsgElements as String?, + lastMsgSenderId: identical(lastMsgSenderId, _keep) + ? this.lastMsgSenderId + : lastMsgSenderId as int?, + lastMsgStatus: identical(lastMsgStatus, _keep) + ? this.lastMsgStatus + : lastMsgStatus as String?, + unreadCount: unreadCount ?? this.unreadCount, + lastEventTime: lastEventTime ?? this.lastEventTime, + cachedAt: cachedAt ?? this.cachedAt, + favIndex: identical(favIndex, _keep) ? this.favIndex : favIndex as int?, + dontDisturbUntil: dontDisturbUntil ?? this.dontDisturbUntil, + isOnline: isOnline ?? this.isOnline, + seenTime: seenTime ?? this.seenTime, + participants: participants ?? this.participants, + options: options ?? this.options, + owner: identical(owner, _keep) ? this.owner : owner as int?, + admins: admins ?? this.admins, + ); + } } class ChatSearchHit { @@ -237,7 +307,11 @@ class MessageMarkedDeletedEvent extends MessageEvent { class MessageReactionsChangedEvent extends MessageEvent { final String messageId; final Map? reactionInfo; - const MessageReactionsChangedEvent(super.chatId, this.messageId, this.reactionInfo); + const MessageReactionsChangedEvent( + super.chatId, + this.messageId, + this.reactionInfo, + ); } class MessageSentEvent extends MessageEvent { @@ -254,117 +328,16 @@ class ChatsModule { /// а кеша истории нет — UI должен отрисовать курсивную плашку. static const String lastMsgPlaceholder = '__komet_lastmsg_placeholder__'; - static String? attachPreviewLabel(dynamic attaches) { - if (attaches is! List || attaches.isEmpty) return null; - final first = attaches.first; - if (first is! Map) return null; - final type = (first['_type'] as String? ?? '').toUpperCase(); - switch (type) { - case 'PHOTO': - return 'Фото'; - case 'VIDEO': - return 'Видео'; - case 'AUDIO': - return 'Голосовое сообщение'; - case 'FILE': - final name = first['name']?.toString(); - return name != null && name.isNotEmpty ? 'Файл: $name' : 'Файл'; - case 'STICKER': - return 'Стикер'; - case 'SHARE': - final title = first['title']?.toString(); - return title != null && title.isNotEmpty ? 'Ссылка: $title' : 'Ссылка'; - case 'POLL': - final title = first['title']?.toString(); - return title != null && title.isNotEmpty ? 'Опрос: $title' : 'Опрос'; - case 'LOCATION': - return 'Геопозиция'; - case 'CONTACT': - return 'Контакт'; - case 'CONTROL': - return _controlPreviewLabel(first); - case 'INLINE_KEYBOARD': - return null; - case 'CALL': - final video = first['callType']?.toString().toUpperCase() == 'VIDEO'; - final dur = (first['duration'] as num?)?.toInt() ?? 0; - final hangup = first['hangupType']?.toString(); - final failed = dur == 0 || - hangup == 'CANCELED' || - hangup == 'REJECTED' || - hangup == 'MISSED'; - if (first['joinLink'] != null) { - return video ? 'Групповой видеозвонок' : 'Групповой звонок'; - } - if (failed) return video ? 'Пропущенный видеозвонок' : 'Пропущенный звонок'; - return video ? 'Видеозвонок' : 'Звонок'; - default: - return 'Вложение'; - } - } + ChatsModule._(); - static String? _controlPreviewLabel(Map c) { - final title = c['title']?.toString(); - if (title != null && title.isNotEmpty) return title; - final short = c['shortMessage']?.toString(); - if (short != null && short.isNotEmpty) return short; - switch (c['event']?.toString()) { - case 'new': - return 'Чат создан'; - case 'add': - case 'joinByLink': - return 'Новый участник'; - case 'leave': - return 'Участник вышел'; - case 'remove': - return 'Участник удалён'; - case 'pin': - return 'Закреплённое сообщение'; - case 'changeTitle': - return 'Название чата изменено'; - case 'changeIcon': - return 'Фото чата обновлено'; - default: - return 'Системное сообщение'; - } - } + final _messageEventsController = StreamController.broadcast(); + Stream get messageEvents => _messageEventsController.stream; - static String? messagePreviewText(Map msg) { - final link = msg['link']; - if (link is Map && link['type']?.toString().toUpperCase() == 'FORWARD') { - final original = link['message']; - final inner = original is Map ? _bodyPreviewText(original) : null; - return inner != null && inner.isNotEmpty - ? '↪ $inner' - : '↪ Пересланное сообщение'; - } - return _bodyPreviewText(msg); - } - - static String? _bodyPreviewText(Map msg) { - final text = msg['text']?.toString(); - if (text != null && text.isNotEmpty) return text; - return attachPreviewLabel(msg['attaches']); - } - - static String? messagePreviewElements(Map msg) { - final text = msg['text']; - if (text is! String || text.isEmpty) return null; - final elements = msg['elements']; - if (elements is List && elements.isNotEmpty) return jsonEncode(elements); - return null; - } - - static final _messageEventsController = - StreamController.broadcast(); - static Stream get messageEvents => - _messageEventsController.stream; - - static void emitMessageSent(int chatId, String tempId, CachedMessage message) { + void emitMessageSent(int chatId, String tempId, CachedMessage message) { _messageEventsController.add(MessageSentEvent(chatId, tempId, message)); } - static Future markRead( + Future markRead( Api api, int accountId, int chatId, @@ -393,12 +366,7 @@ class ChatsModule { _bump(); } - static Future markUnread( - Api api, - int accountId, - int chatId, - int mark, - ) async { + Future markUnread(Api api, int accountId, int chatId, int mark) async { int? unread; try { final resp = await api.sendRequest(Opcode.chatMark, { @@ -413,17 +381,15 @@ class ChatsModule { } if (unread == null) return null; - final rows = await AppDatabase.loadChat(accountId, chatId); - if (rows.isNotEmpty) { - final row = Map.from(rows.first); - row['unread_count'] = unread; - await AppDatabase.saveChats([row]); - _bump(); - } + await _updateChat( + accountId, + chatId, + (chat) => chat.copyWith(unreadCount: unread), + ); return unread; } - static Future applyOutgoing( + Future applyOutgoing( int accountId, int chatId, { required String messageId, @@ -432,47 +398,80 @@ class ChatsModule { required String status, List>? elements, }) async { - final rows = await AppDatabase.loadChat(accountId, chatId); - if (rows.isEmpty) return; - final row = Map.from(rows.first); final thisId = int.tryParse(messageId); - final existingTime = (row['last_msg_time'] as int?) ?? 0; - final existingId = row['last_msg_id'] as int?; - if (time < existingTime && existingId != thisId) return; - row['last_msg_id'] = thisId; - row['last_msg_text'] = text; - row['last_msg_elements'] = (elements != null && elements.isNotEmpty) - ? jsonEncode(elements) - : null; - row['last_msg_time'] = time; - row['last_event_time'] = time; - row['last_msg_sender'] = accountId; - row['last_msg_status'] = status; - await AppDatabase.saveChats([row]); - _bump(); + await _updateChat(accountId, chatId, (chat) { + final existingTime = chat.lastMsgTime ?? 0; + if (time < existingTime && chat.lastMsgId != thisId) return null; + return chat.copyWith( + lastMsgId: thisId, + lastMsgText: text, + lastMsgElements: (elements != null && elements.isNotEmpty) + ? jsonEncode(elements) + : null, + lastMsgTime: time, + lastEventTime: time, + lastMsgSenderId: accountId, + lastMsgStatus: status, + ); + }); } - static final ValueNotifier chatsChanged = ValueNotifier(0); - static void _bump() => chatsChanged.value = chatsChanged.value + 1; + final ValueNotifier chatsChanged = ValueNotifier(0); + void _bump() => chatsChanged.value = chatsChanged.value + 1; - static StreamSubscription? _globalPushSub; - static StreamSubscription? _globalStateSub; - static Future _pushQueue = Future.value(); + Future _updateChat( + int accountId, + int chatId, + CachedChat? Function(CachedChat chat) mutate, + ) async { + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) return false; + final updated = mutate(CachedChat.fromDbRow(rows.first)); + if (updated == null) return false; + final row = Map.from(rows.first) + ..addAll(updated.toDbRow()); + await AppDatabase.saveChats([row]); + _bump(); + return true; + } - static final Set _historyFetched = {}; + static Map? _decodePayload(dynamic raw) { + if (raw is String && raw.isNotEmpty) { + try { + return Map.from(jsonDecode(raw) as Map); + } catch (_) {} + } + return null; + } - static bool wasHistoryFetched(int chatId) => - _historyFetched.contains(chatId); - static void markHistoryFetched(int chatId) => _historyFetched.add(chatId); + StreamSubscription? _globalPushSub; + StreamSubscription? _globalStateSub; + Future _pushQueue = Future.value(); - static void attachGlobalPushHandlers(Api api) { + final Set _historyFetched = {}; + + bool wasHistoryFetched(int chatId) => _historyFetched.contains(chatId); + void markHistoryFetched(int chatId) => _historyFetched.add(chatId); + + void attachGlobalPushHandlers(Api api) { _globalPushSub?.cancel(); _globalStateSub?.cancel(); _globalPushSub = api.pushStream.listen(_enqueueGlobalPush); _globalStateSub = api.stateStream.listen(_handleSessionState); } - static void _handleSessionState(SessionState state) { + void dispose() { + _globalPushSub?.cancel(); + _globalStateSub?.cancel(); + _globalPushSub = null; + _globalStateSub = null; + _contactFlushTimer?.cancel(); + _contactFlushTimer = null; + _messageEventsController.close(); + chatsChanged.dispose(); + } + + void _handleSessionState(SessionState state) { if (state == SessionState.disconnected) { ContactInfoFetch.clear(); PresenceFetch.clear(); @@ -481,22 +480,22 @@ class ChatsModule { } } - static void resetForAccountSwitch() { + void resetForAccountSwitch() { _historyFetched.clear(); ContactInfoFetch.clear(); PresenceFetch.clear(); ChatInfoFetch.clear(); } - static void _enqueueGlobalPush(Packet packet) { - _pushQueue = _pushQueue - .then((_) => _handleGlobalPush(packet)) - .catchError((Object e) { - logger.w('Ошибка обработки пуша: $e'); - }); + void _enqueueGlobalPush(Packet packet) { + _pushQueue = _pushQueue.then((_) => _handleGlobalPush(packet)).catchError(( + Object e, + ) { + logger.w('Ошибка обработки пуша: $e'); + }); } - static Future _handleGlobalPush(Packet packet) async { + Future _handleGlobalPush(Packet packet) async { switch (packet.opcode) { case Opcode.notifMessage: await _handleNotifMessage(packet); @@ -511,7 +510,7 @@ class ChatsModule { } } - static void _handlePresence(Packet packet) { + void _handlePresence(Packet packet) { final payload = packet.payload; if (payload is! Map) return; final userId = payload['userId']; @@ -521,7 +520,7 @@ class ChatsModule { PresenceFetch.apply(userId, Map.from(presence)); } - static Future _handleNotifMsgDelete(Packet packet) async { + Future _handleNotifMsgDelete(Packet packet) async { final payload = packet.payload; if (payload is! Map) return; final accountId = await TokenStorage.getActiveAccountId(); @@ -555,7 +554,7 @@ class ChatsModule { _bump(); } - static Future _handleNotifMessage(Packet packet) async { + Future _handleNotifMessage(Packet packet) async { final payload = packet.payload; if (payload is! Map) return; final chatId = payload['chatId']; @@ -581,10 +580,12 @@ class ChatsModule { try { final chatInfo = await ChatInfoFetch.get(chatId); if (chatInfo != null) { - await cacheServerChat(chatInfo, accountId); + await cacheServerChat(chatInfo.raw, accountId); } } catch (e) { - logger.w('notifMessage: fetch info for unknown chat $chatId failed: $e'); + logger.w( + 'notifMessage: fetch info for unknown chat $chatId failed: $e', + ); return; } rows = await AppDatabase.loadChat(accountId, chatId); @@ -600,7 +601,12 @@ class ChatsModule { } final cachedChat = CachedChat.fromDbRow(rows.first); if (cachedChat.lastMsgId == msgIdInt) { - await _reconcileLastMessage(accountId, chatId, rows.first, unread: unread); + await _reconcileLastMessage( + accountId, + chatId, + rows.first, + unread: unread, + ); } else if (unread != null) { final newRow = Map.from(rows.first); newRow['unread_count'] = unread; @@ -617,21 +623,15 @@ class ChatsModule { CachedMessage? emittedMessage; if (status == 'EDITED' && msgIdStr != null) { - final existing = await AppDatabase.loadMessage(accountId, chatId, msgIdStr); + final existing = await AppDatabase.loadMessage( + accountId, + chatId, + msgIdStr, + ); if (existing != null) { - Map mergedPayload; - final existingPayloadRaw = existing['payload']; - if (existingPayloadRaw is String && existingPayloadRaw.isNotEmpty) { - try { - mergedPayload = Map.from( - jsonDecode(existingPayloadRaw) as Map, - ); - } catch (_) { - mergedPayload = Map.from(msg); - } - } else { - mergedPayload = Map.from(msg); - } + final mergedPayload = + _decodePayload(existing['payload']) ?? + Map.from(msg); for (final entry in msg.entries) { if (entry.key == 'reactionInfo') continue; mergedPayload[entry.key.toString()] = entry.value; @@ -655,10 +655,16 @@ class ChatsModule { newRow['payload'] = jsonEncode(mergedPayload); await AppDatabase.saveMessages([newRow]); emittedMessage = CachedMessage.fromDbRow(newRow); - _messageEventsController.add(MessageEditedEvent(chatId, emittedMessage)); + _messageEventsController.add( + MessageEditedEvent(chatId, emittedMessage), + ); } } else if (msgIdStr != null) { - final existing = await AppDatabase.loadMessage(accountId, chatId, msgIdStr); + final existing = await AppDatabase.loadMessage( + accountId, + chatId, + msgIdStr, + ); if (existing == null) { final cached = CachedMessage.fromPushPayload(accountId, chatId, msg); await AppDatabase.saveMessages([cached.toDbRow()]); @@ -668,7 +674,8 @@ class ChatsModule { } final cached = CachedChat.fromDbRow(rows.first); - final isStaleLast = status != 'REMOVED' && + final isStaleLast = + status != 'REMOVED' && msgIdInt != null && cached.lastMsgId == msgIdInt && status != 'EDITED'; @@ -697,7 +704,7 @@ class ChatsModule { _bump(); } - static Future _reconcileLastMessage( + Future _reconcileLastMessage( int accountId, int chatId, Map chatRow, { @@ -715,27 +722,11 @@ class ChatsModule { final rawText = m['text']?.toString(); String? previewText = rawText; String? elementsJson; + final payload = _decodePayload(m['payload']); if (rawText == null || rawText.isEmpty) { - final payloadRaw = m['payload']; - if (payloadRaw is String && payloadRaw.isNotEmpty) { - try { - final payload = jsonDecode(payloadRaw); - if (payload is Map) { - previewText = messagePreviewText(payload); - } - } catch (_) {} - } + if (payload != null) previewText = messagePreviewText(payload); } else { - final payloadRaw = m['payload']; - if (payloadRaw is String && payloadRaw.isNotEmpty) { - try { - final payload = jsonDecode(payloadRaw); - if (payload is Map) { - final els = payload['elements']; - if (els is List && els.isNotEmpty) elementsJson = jsonEncode(els); - } - } catch (_) {} - } + if (payload != null) elementsJson = messagePreviewElements(payload); } newRow['last_msg_id'] = int.tryParse(m['id']?.toString() ?? ''); newRow['last_msg_text'] = previewText ?? m['text']; @@ -757,26 +748,26 @@ class ChatsModule { /// Вызывается после успешного фетча истории чата — /// если в превью был placeholder, заменяем его на актуальное /// последнее сообщение из кеша. - static Future reconcileLastMessageIfPlaceholder( + Future reconcileLastMessageIfPlaceholder( int accountId, int chatId, ) async { final rows = await AppDatabase.loadChat(accountId, chatId); if (rows.isEmpty) return; final chat = CachedChat.fromDbRow(rows.first); - if (chat.lastMsgText != lastMsgPlaceholder) return; + if (!chat.isLastMsgDeleted) return; await _reconcileLastMessage(accountId, chatId, rows.first); _bump(); } - static Future reconcileLastMessage(int accountId, int chatId) async { + Future reconcileLastMessage(int accountId, int chatId) async { final rows = await AppDatabase.loadChat(accountId, chatId); if (rows.isEmpty) return; await _reconcileLastMessage(accountId, chatId, rows.first); _bump(); } - static Future> reconcileDeletedFromFetch( + Future> reconcileDeletedFromFetch( int accountId, int chatId, List serverMessages, @@ -824,7 +815,7 @@ class ChatsModule { return newlyDeleted; } - static Future _handleNotifMsgReactionsChanged(Packet packet) async { + Future _handleNotifMsgReactionsChanged(Packet packet) async { final payload = packet.payload; if (payload is! Map) return; final chatId = payload['chatId']; @@ -835,20 +826,15 @@ class ChatsModule { final accountId = await TokenStorage.getActiveAccountId(); if (accountId == null) return; - final existing = await AppDatabase.loadMessage(accountId, chatId, messageId); + final existing = await AppDatabase.loadMessage( + accountId, + chatId, + messageId, + ); if (existing == null) return; - Map payloadMap; - final raw = existing['payload']; - if (raw is String && raw.isNotEmpty) { - try { - payloadMap = Map.from(jsonDecode(raw) as Map); - } catch (_) { - payloadMap = {}; - } - } else { - payloadMap = {}; - } + final payloadMap = + _decodePayload(existing['payload']) ?? {}; final counters = payload['counters']; final totalCount = payload['totalCount']; @@ -859,7 +845,8 @@ class ChatsModule { } if (counters is List) reactionInfo['counters'] = counters; if (totalCount is int) reactionInfo['totalCount'] = totalCount; - if (reactionInfo['counters'] == null || (counters is List && counters.isEmpty)) { + if (reactionInfo['counters'] == null || + (counters is List && counters.isEmpty)) { payloadMap.remove('reactionInfo'); } else { payloadMap['reactionInfo'] = reactionInfo; @@ -875,7 +862,7 @@ class ChatsModule { _bump(); } - static Future _handleNotifMark(Packet packet) async { + Future _handleNotifMark(Packet packet) async { final payload = packet.payload; if (payload is! Map) return; final chatId = payload['chatId']; @@ -898,19 +885,19 @@ class ChatsModule { _bump(); } - static final Set _pendingContactUpdates = {}; - static Timer? _contactFlushTimer; - static Future? _contactFlushFuture; + final Set _pendingContactUpdates = {}; + Timer? _contactFlushTimer; + Future? _contactFlushFuture; static const _contactFlushDelay = Duration(milliseconds: 250); - static void applyContactUpdate(int contactId) { + void applyContactUpdate(int contactId) { _pendingContactUpdates.add(contactId); if (_contactFlushTimer != null) return; if (_contactFlushFuture != null) return; _contactFlushTimer = Timer(_contactFlushDelay, _kickFlush); } - static void _kickFlush() { + void _kickFlush() { _contactFlushTimer = null; if (_contactFlushFuture != null) return; _contactFlushFuture = _flushContactUpdates().whenComplete(() { @@ -921,7 +908,7 @@ class ChatsModule { }); } - static Future _flushContactUpdates() async { + Future _flushContactUpdates() async { if (_pendingContactUpdates.isEmpty) return; final ids = _pendingContactUpdates.toList(); _pendingContactUpdates.clear(); @@ -936,9 +923,10 @@ class ChatsModule { final cached = CachedChat.fromDbRow(row); for (final pid in cached.participants.keys) { if (pid == accountId) continue; - byParticipant - .putIfAbsent(pid, () => []) - .add((row: row, cached: cached)); + byParticipant.putIfAbsent(pid, () => []).add(( + row: row, + cached: cached, + )); } } @@ -955,7 +943,8 @@ class ChatsModule { final cached = entry.cached; final sameTitle = cached.title == name; final sameAvatar = (cached.iconUrl ?? '') == (avatar ?? ''); - final sameOptions = cached.options.length == options.length && + final sameOptions = + cached.options.length == options.length && cached.options.containsAll(options); if (sameTitle && sameAvatar && sameOptions) continue; final newRow = Map.from(row); @@ -971,7 +960,7 @@ class ChatsModule { } } - static Future cacheServerChat( + Future cacheServerChat( Map chat, int accountId, { Map? preloadedExisting, @@ -988,7 +977,7 @@ class ChatsModule { existing = {id: CachedChat.fromDbRow(rows.first)}; } } - final parsed = _parseChat( + final parsed = parseChatRow( chat, accountId, accountId, @@ -1003,7 +992,7 @@ class ChatsModule { return null; } final ex = existing[parsed.id]; - if (ex != null && _sameContent(ex, parsed)) { + if (ex != null && sameChatContent(ex, parsed)) { return parsed; } final row = parsed.toDbRow(); @@ -1013,38 +1002,12 @@ class ChatsModule { return parsed; } - static bool _sameContent(CachedChat a, CachedChat b) { - if (a.title != b.title) return false; - if (a.iconUrl != b.iconUrl) return false; - if (a.owner != b.owner) return false; - if (a.dontDisturbUntil != b.dontDisturbUntil) return false; - if (a.favIndex != b.favIndex) return false; - if (a.lastMsgId != b.lastMsgId) return false; - if (a.lastMsgTime != b.lastMsgTime) return false; - if (a.lastMsgText != b.lastMsgText) return false; - if (a.lastMsgElements != b.lastMsgElements) return false; - if (a.lastMsgSenderId != b.lastMsgSenderId) return false; - if (a.unreadCount != b.unreadCount) return false; - if (a.lastEventTime != b.lastEventTime) return false; - if (a.isOnline != b.isOnline) return false; - if (a.seenTime != b.seenTime) return false; - if (a.admins.length != b.admins.length) return false; - if (!a.admins.containsAll(b.admins)) return false; - if (a.options.length != b.options.length) return false; - if (!a.options.containsAll(b.options)) return false; - if (a.participants.length != b.participants.length) return false; - for (final e in a.participants.entries) { - if (b.participants[e.key] != e.value) return false; - } - return true; - } - /// Парсит и кэширует чаты из payload opcode 19. /// /// Для диалогов разрезолвит имя и аватар из списка [contacts] того же /// ответа. На warm start контакты не приходят — используется существующий /// кэш. - static Future syncFromLoginPayload( + Future syncFromLoginPayload( Map data, int accountId, int currentUserId, @@ -1053,7 +1016,7 @@ class ChatsModule { final chats = data['chats']; if (chats is! List || chats.isEmpty) return; - final contactsMap = _buildContactsMap(data['contacts']); + final contactsMap = buildContactsMap(data['contacts']); // Config contains mute setup and fav indexes: config -> chats -> id final configMap = data['config'] is Map ? data['config'] as Map : {}; final chatsConfig = configMap['chats'] is Map @@ -1061,7 +1024,9 @@ class ChatsModule { : {}; // Presence for online statuses - final presenceMap = data['presence'] is Map ? data['presence'] as Map : {}; + final presenceMap = data['presence'] is Map + ? data['presence'] as Map + : {}; PresenceFetch.primeAll(presenceMap); final cachedAt = DateTime.now().millisecondsSinceEpoch; @@ -1074,7 +1039,7 @@ class ChatsModule { final rows = chats .whereType() .map( - (c) => _parseChat( + (c) => parseChatRow( c.cast(), accountId, currentUserId, @@ -1098,7 +1063,7 @@ class ChatsModule { } } - static Future> getChats(int accountId) async { + Future> getChats(int accountId) async { try { final rows = await AppDatabase.loadChats(accountId); final chats = rows.map(CachedChat.fromDbRow).toList(); @@ -1108,10 +1073,11 @@ class ChatsModule { return []; } } - static Future> getChat(int accountId, int chatId) async { + + Future> getChat(int accountId, int chatId) async { try { final rows = await AppDatabase.loadChat(accountId, chatId); - + return rows.map(CachedChat.fromDbRow).toList(); } catch (e) { logger.e("Ошибка при получении чата: $e"); @@ -1120,186 +1086,10 @@ class ChatsModule { } } - static Future clearCache(int accountId) => + Future clearCache(int accountId) => AppDatabase.clearChatsCache(accountId); - static Map> _buildContactsMap(dynamic contacts) { - if (contacts is! List) return {}; - final result = >{}; - for (final c in contacts.whereType()) { - final id = c['id']; - if (id is int) result[id] = c.cast(); - } - return result; - } - - static CachedChat? _parseChat( - Map chat, - int accountId, - int currentUserId, - Map> contactsMap, - Map chatsConfig, - Map presenceMap, - Map existing, - int cachedAt, - ) { - try { - final id = chat['id']; - if (id is! int) return null; - - final type = (chat['type'] as String?) ?? 'DIALOG'; - int? otherId; - - String? title; - String? iconUrl; - Set options = const {}; - - if (type == 'DIALOG') { - otherId = _otherParticipantId(chat['participants'], currentUserId); - final contact = otherId != null ? contactsMap[otherId] : null; - - if (contact != null) { - title = _nameFromContact(contact); - iconUrl = contact['baseUrl'] as String?; - final contactOpts = contact['options']; - if (contactOpts is List) { - options = contactOpts.whereType().toSet(); - } - } else { - title = existing[id]?.title; - iconUrl = existing[id]?.iconUrl; - options = existing[id]?.options ?? const {}; - } - } else { - title = chat['title'] as String?; - iconUrl = chat['baseIconUrl'] as String?; - final chatOpts = chat['options']; - if (chatOpts is Map) { - options = { - for (final entry in chatOpts.entries) - if (entry.value == true && entry.key is String) entry.key as String, - }; - } - } - - final lastMsg = chat['lastMessage']; - int? lastMsgId; - int? lastMsgTime; - String? lastMsgText; - String? lastMsgElements; - int? lastMsgSenderId; - - if (lastMsg is Map) { - lastMsgId = lastMsg['id'] as int?; - lastMsgTime = lastMsg['time'] as int?; - lastMsgText = messagePreviewText(lastMsg); - lastMsgElements = messagePreviewElements(lastMsg); - lastMsgSenderId = lastMsg['sender'] as int?; - } - - final config = chatsConfig[id.toString()] ?? chatsConfig[id]; - int? favIndex; - int dontDisturbUntil = 0; - if (config is Map) { - favIndex = config['favIndex'] as int?; - dontDisturbUntil = (config['dontDisturbUntil'] as int?) ?? 0; - } else { - final ex = existing[id]; - if (ex != null) { - favIndex = ex.favIndex; - dontDisturbUntil = ex.dontDisturbUntil; - } - } - - - int seenTime = 0; - bool isOnline = false; - if (type == 'DIALOG' && otherId != null) { - final presence = presenceMap[otherId.toString()] ?? presenceMap[otherId]; - if (presence is Map) { - seenTime = (presence['seen'] as int?) ?? 0; - isOnline = (presence['status'] as int?) == 1; - } - } - Map participants = _parseParticipants(chat['participants']); - - int? owner; - final ownerRaw = chat['owner']; - if (ownerRaw is int) { - owner = ownerRaw; - } else if (ownerRaw is String) { - owner = int.tryParse(ownerRaw); - } - - Set admins = const {}; - final adminsRaw = chat['admins']; - if (adminsRaw is List) { - admins = adminsRaw - .map((e) => e is int ? e : int.tryParse(e.toString())) - .whereType() - .toSet(); - } else { - final adminParticipants = chat['adminParticipants']; - if (adminParticipants is Map) { - admins = adminParticipants.keys - .map((k) => k is int ? k : int.tryParse(k.toString())) - .whereType() - .toSet(); - } - } - - return CachedChat( - id: id, - accountId: accountId, - type: type, - title: title, - iconUrl: iconUrl, - lastMsgId: lastMsgId, - lastMsgTime: lastMsgTime, - lastMsgText: lastMsgText, - lastMsgElements: lastMsgElements, - lastMsgSenderId: lastMsgSenderId, - unreadCount: (chat['newMessages'] as int?) ?? 0, - lastEventTime: (chat['lastEventTime'] as int?) ?? 0, - cachedAt: cachedAt, - favIndex: favIndex, - dontDisturbUntil: dontDisturbUntil, - isOnline: isOnline, - seenTime: seenTime, - participants: participants, - options: options, - owner: owner, - admins: admins, - ); - } catch (e) { - logger.e("Ошибка при парсинге чата: $e"); - - return null; - } - } - - static int? _otherParticipantId(dynamic participants, int currentUserId) { - if (participants is! Map) return null; - for (final key in participants.keys) { - final id = key is int ? key : int.tryParse(key.toString()); - if (id != null && id != currentUserId) return id; - } - return null; - } - - static String? _nameFromContact(Map contact) { - final names = contact['names']; - if (names is! List || names.isEmpty) return null; - final nameRaw = names.firstWhere( - (n) => n is Map && n['type'] == 'ONEME', - orElse: () => names.firstWhere((n) => n is Map, orElse: () => null), - ); - if (nameRaw is! Map) return null; - final name = nameRaw; - return name['name'] as String?; - } - - static Future?> getChatInfo(Api api, int chatId) async { + Future?> getChatInfo(Api api, int chatId) async { final packet = await api.sendRequest(Opcode.chatInfo, { 'chatIds': [chatId], }); @@ -1310,7 +1100,7 @@ class ChatsModule { return Map.from(chats.first as Map); } - static Future searchById(Api api, int userId) async { + Future searchById(Api api, int userId) async { final packet = await api.sendRequest(Opcode.publicSearch, { 'query': userId.toString(), 'from': 0, @@ -1319,53 +1109,7 @@ class ChatsModule { return packet.payload; } - static List _parseSearchResult(dynamic payload) { - final result = (payload as Map?)?['result']; - if (result is! List) return const []; - final hits = []; - for (final item in result) { - if (item is! Map) continue; - final chat = item['chat']; - if (chat is! Map) continue; - final id = chat['id']; - if (id is! int) continue; - final last = chat['lastMessage']; - final link = chat['link']; - hits.add(ChatSearchHit( - id: id, - type: (chat['type'] as String?) ?? 'CHAT', - title: chat['title'] as String?, - avatarUrl: chat['baseIconUrl'] as String?, - subtitle: link is String && link.isNotEmpty - ? '@$link' - : (last is Map ? last['text'] as String? : null), - )); - } - return hits; - } - - static List _parseMessageResult(dynamic payload) { - final result = (payload as Map?)?['result']; - if (result is! List) return const []; - final hits = []; - for (final item in result) { - if (item is! Map) continue; - final message = item['message']; - if (message is! Map) continue; - final chatId = item['chatId']; - if (chatId is! int || chatId == 0) continue; - hits.add(MessageSearchHit( - chatId: chatId, - messageId: message['id']?.toString(), - text: message['text'] as String?, - time: (message['time'] as int?) ?? 0, - senderId: (message['sender'] as int?) ?? 0, - )); - } - return hits; - } - - static Future> searchMessages( + Future> searchMessages( Api api, String query, { int count = 50, @@ -1378,14 +1122,14 @@ class ChatsModule { 'query': term, }); if (packet.isError) return const []; - return _parseMessageResult(packet.payload); + return parseMessageResult(packet.payload); } catch (e) { logger.w('searchMessages failed: $e'); return const []; } } - static Future> searchPublic( + Future> searchPublic( Api api, String query, { int count = 20, @@ -1399,14 +1143,14 @@ class ChatsModule { 'query': term, }); if (packet.isError) return const []; - return _parseSearchResult(packet.payload); + return parseSearchResult(packet.payload); } catch (e) { logger.w('searchPublic failed: $e'); return const []; } } - static Future subscribeChat( + Future subscribeChat( Api api, int chatId, { bool subscribe = true, @@ -1421,11 +1165,7 @@ class ChatsModule { } } - static Future ensureChatCached( - Api api, - int accountId, - int chatId, - ) async { + Future ensureChatCached(Api api, int accountId, int chatId) async { final rows = await AppDatabase.loadChat(accountId, chatId); if (rows.isNotEmpty) return true; try { @@ -1439,7 +1179,7 @@ class ChatsModule { } } - static Future createGroupChat( + Future createGroupChat( Api api, { required String title, required List userIds, @@ -1483,7 +1223,7 @@ class ChatsModule { return cacheServerChat(chat, accountId); } - static Future requestChatPhotoUploadUrl(Api api) async { + Future requestChatPhotoUploadUrl(Api api) async { final packet = await api.sendRequest(Opcode.photoUpload, {'count': 1}); if (!packet.isOk) return null; final data = packet.payload; @@ -1491,31 +1231,29 @@ class ChatsModule { return data['url'] as String?; } - static Future setChatPhoto( + Future setChatPhoto( Api api, { required int chatId, required String photoToken, }) async { - final packet = await api.sendRequest(Opcode.chatUpdate, { + return api.sendRequestOk(Opcode.chatUpdate, { 'chatId': chatId, 'photoToken': photoToken, }); - return packet.isOk; } - static Future setChatOptions( + Future setChatOptions( Api api, { required int chatId, required Map options, }) async { - final packet = await api.sendRequest(Opcode.chatUpdate, { + return api.sendRequestOk(Opcode.chatUpdate, { 'chatId': chatId, 'options': options, }); - return packet.isOk; } - static Future setChatTitle( + Future setChatTitle( Api api, { required int chatId, required String title, @@ -1527,17 +1265,11 @@ class ChatsModule { if (!packet.isOk) return false; final accountId = await TokenStorage.getActiveAccountId(); if (accountId == null) return true; - final rows = await AppDatabase.loadChat(accountId, chatId); - if (rows.isNotEmpty) { - final updated = Map.from(rows.first); - updated['title'] = title; - await AppDatabase.saveChats([updated]); - _bump(); - } + await _updateChat(accountId, chatId, (chat) => chat.copyWith(title: title)); return true; } - static Future togglePin( + Future togglePin( Api api, { required List chatIds, required bool pin, @@ -1563,7 +1295,12 @@ class ChatsModule { favorites.removeWhere((id) => chatIds.contains(id)); } - await FoldersModule.setFolderFavorites(api, accountId, allFolder, favorites); + await FoldersModule.setFolderFavorites( + api, + accountId, + allFolder, + favorites, + ); final existingRows = await AppDatabase.loadChatsByIds(accountId, chatIds); final updates = >[]; @@ -1593,7 +1330,7 @@ class ChatsModule { } } - static Future setChatMute( + Future setChatMute( Api api, { required int chatId, required int dontDisturbUntil, @@ -1608,13 +1345,11 @@ class ChatsModule { }); final accountId = await TokenStorage.getActiveAccountId(); if (accountId != null) { - final rows = await AppDatabase.loadChat(accountId, chatId); - if (rows.isNotEmpty) { - final row = Map.from(rows.first); - row['dont_disturb_until'] = dontDisturbUntil; - await AppDatabase.saveChats([row]); - _bump(); - } + await _updateChat( + accountId, + chatId, + (chat) => chat.copyWith(dontDisturbUntil: dontDisturbUntil), + ); } return null; } on PacketError catch (e) { @@ -1626,7 +1361,7 @@ class ChatsModule { } } - static Future deleteChat( + Future deleteChat( Api api, { required int chatId, required int lastEventTime, @@ -1653,7 +1388,7 @@ class ChatsModule { } } - static Future clearHistory( + Future clearHistory( Api api, { required int chatId, required int lastEventTime, @@ -1682,7 +1417,7 @@ class ChatsModule { } } - static Future leaveChat(Api api, {required int chatId}) async { + Future leaveChat(Api api, {required int chatId}) async { try { await api.sendRequest(Opcode.chatLeave, {'chatId': chatId}); final accountId = await TokenStorage.getActiveAccountId(); @@ -1696,10 +1431,7 @@ class ChatsModule { } } - static Future> refreshChats( - Api api, - List chatIds, - ) async { + Future> refreshChats(Api api, List chatIds) async { if (chatIds.isEmpty) return const []; try { final packet = await api.sendRequest(Opcode.chatInfo, { @@ -1737,3 +1469,5 @@ class ChatsModule { } } } + +final chats = ChatsModule._(); diff --git a/lib/backend/modules/cloud_storage.dart b/lib/backend/modules/cloud_storage.dart index 9b173fe..3f64b24 100644 --- a/lib/backend/modules/cloud_storage.dart +++ b/lib/backend/modules/cloud_storage.dart @@ -82,23 +82,27 @@ class CloudStorageModule { } static Future _configurePrivacy(Api api, int chatId) async { - await Future.wait([ - ChatsModule.setChatOptions(api, chatId: chatId, options: {'ONLY_OWNER_CAN_CHANGE_ICON_TITLE': true}), - ChatsModule.setChatOptions(api, chatId: chatId, options: {'ONLY_ADMIN_CAN_ADD_MEMBER': true}), - ChatsModule.setChatOptions(api, chatId: chatId, options: {'ALL_CAN_PIN_MESSAGE': false}), - ChatsModule.setChatOptions(api, chatId: chatId, options: {'ONLY_ADMIN_CAN_CALL': true}), - ]); + await chats.setChatOptions( + api, + chatId: chatId, + options: { + 'ONLY_OWNER_CAN_CHANGE_ICON_TITLE': true, + 'ONLY_ADMIN_CAN_ADD_MEMBER': true, + 'ALL_CAN_PIN_MESSAGE': false, + 'ONLY_ADMIN_CAN_CALL': true, + }, + ); } static Future setupEnv(Api api) async { - final temp = await ChatsModule.createGroupChat( + final temp = await chats.createGroupChat( api, title: _tempName, userIds: [], ); if (temp == null) return null; final name = '$_prefix${_computeSpecialNumber(temp.id)}'; - final ok = await ChatsModule.setChatTitle(api, chatId: temp.id, title: name); + final ok = await chats.setChatTitle(api, chatId: temp.id, title: name); if (!ok) return null; await _configurePrivacy(api, temp.id); return temp; @@ -107,12 +111,34 @@ class CloudStorageModule { // Turns an orphan "Облачное хранилище" group into a valid env group static Future repairOrphan(Api api, CachedChat orphan) async { final name = '$_prefix${_computeSpecialNumber(orphan.id)}'; - final ok = await ChatsModule.setChatTitle(api, chatId: orphan.id, title: name); + final ok = await chats.setChatTitle(api, chatId: orphan.id, title: name); if (!ok) return null; await _configurePrivacy(api, orphan.id); return orphan; } + static Iterable _cloudFilesFrom( + Iterable msgs, + int chatId, + int accountId, + ) sync* { + for (final msg in msgs) { + for (final a in msg.attachments ?? []) { + if (a is FileAttachment && a.name != null) { + yield CloudFile( + name: a.name!, + size: a.size, + time: msg.time, + fileId: a.fileId, + messageId: msg.id, + chatId: chatId, + accountId: accountId, + ); + } + } + } + } + static Future> fetchFiles( MessagesModule messages, int accountId, @@ -120,23 +146,7 @@ class CloudStorageModule { int count = 200, }) async { final msgs = await messages.fetchHistory(accountId, chatId, count: count); - final files = []; - for (final msg in msgs) { - for (final a in msg.attachments ?? []) { - if (a is FileAttachment && a.name != null) { - files.add(CloudFile( - name: a.name!, - size: a.size, - time: msg.time, - fileId: a.fileId, - messageId: msg.id, - chatId: chatId, - accountId: accountId, - )); - } - } - } - return files; + return _cloudFilesFrom(msgs, chatId, accountId).toList(); } // Fetches only the last few messages to find a newly uploaded file — avoids full 200-msg reload @@ -147,21 +157,9 @@ class CloudStorageModule { int? expectedFileId, }) async { final msgs = await messages.fetchHistory(accountId, chatId, count: 5); - for (final msg in msgs) { - for (final a in msg.attachments ?? []) { - if (a is FileAttachment && a.name != null) { - if (expectedFileId == null || a.fileId == expectedFileId) { - return CloudFile( - name: a.name!, - size: a.size, - time: msg.time, - fileId: a.fileId, - messageId: msg.id, - chatId: chatId, - accountId: accountId, - ); - } - } + for (final file in _cloudFilesFrom(msgs, chatId, accountId)) { + if (expectedFileId == null || file.fileId == expectedFileId) { + return file; } } return null; diff --git a/lib/backend/modules/complaints.dart b/lib/backend/modules/complaints.dart index 0d9fe2f..5ccbb21 100644 --- a/lib/backend/modules/complaints.dart +++ b/lib/backend/modules/complaints.dart @@ -11,14 +11,15 @@ class ComplaintReason { class ComplaintsModule { static Map>? _cache; + static void clear() => _cache = null; + static Future>> fetchReasons(Api api) async { final cached = _cache; if (cached != null) return cached; - final response = await api.sendRequest( - Opcode.complainReasonsGet, - {'complainSync': 0}, - ); + final response = await api.sendRequest(Opcode.complainReasonsGet, { + 'complainSync': 0, + }); if (!response.isOk) return cached ?? const {}; final payload = response.payload; diff --git a/lib/backend/modules/digital_id.dart b/lib/backend/modules/digital_id.dart index e3aae4f..7285ac1 100644 --- a/lib/backend/modules/digital_id.dart +++ b/lib/backend/modules/digital_id.dart @@ -56,8 +56,9 @@ class DigitalIdModule { final hashIndex = url.indexOf('#'); if (hashIndex < 0) return null; final fragment = url.substring(hashIndex + 1); - final match = RegExp(r'WebAppData=([^&]*(?:&(?!WebApp)[^&]*)*)') - .firstMatch(fragment); + final match = RegExp( + r'WebAppData=([^&]*(?:&(?!WebApp)[^&]*)*)', + ).firstMatch(fragment); final raw = match?.group(1); if (raw == null || raw.isEmpty) return null; return Uri.decodeComponent(raw); @@ -84,8 +85,7 @@ class DigitalIdModule { Future _generateDeviceId() async { final rnd = Random.secure(); final bytes = List.generate(16, (_) => rnd.nextInt(256)); - final hex = - bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); try { final info = DeviceInfoPlugin(); if (Platform.isAndroid) { @@ -162,7 +162,8 @@ class DigitalIdModule { try { final decoded = jsonDecode(body); if (decoded is Map) { - final rawCode = decoded['code'] ?? decoded['error'] ?? decoded['status']; + final rawCode = + decoded['code'] ?? decoded['error'] ?? decoded['status']; if (rawCode is String && rawCode.isNotEmpty) code = rawCode; final rawMessage = decoded['message'] ?? decoded['error_description']; if (rawMessage is String && rawMessage.isNotEmpty) message = rawMessage; @@ -191,26 +192,26 @@ class DigitalIdModule { final decoded = await _send( 'POST', '/v3/digital-id/create-biometry-token', - body: { - 'device_id': deviceId, - 'photo_hash': ?photoHash, - }, + body: {'device_id': deviceId, 'photo_hash': ?photoHash}, ); return _unwrapData(decoded)['token'] as String? ?? ''; } Future refreshUserDocs(String token) async { - final decoded = - await _send('POST', '/v3/digital-id/refresh-user-docs', body: { - 'token': token, - }); + final decoded = await _send( + 'POST', + '/v3/digital-id/refresh-user-docs', + body: {'token': token}, + ); return _unwrapData(decoded)['state'] as String? ?? ''; } Future getUserDocs(String state) async { - final decoded = await _send('POST', '/v2/digital-id/get-user-docs', body: { - 'state': state, - }); + final decoded = await _send( + 'POST', + '/v2/digital-id/get-user-docs', + body: {'state': state}, + ); if (decoded is Map && decoded['status'] == 'done') { final data = decoded['data']; if (data is Map) return DigitalIdUserDocs.fromMap(data); @@ -227,19 +228,22 @@ class DigitalIdModule { required String deviceId, String? photoHash, }) async { - final decoded = await _send('POST', '/digital-id-verify-photo', body: { - 'device_id': deviceId, - 'photo_hash': ?photoHash, - }); + final decoded = await _send( + 'POST', + '/digital-id-verify-photo', + body: {'device_id': deviceId, 'photo_hash': ?photoHash}, + ); final status = decoded is Map ? decoded['status'] as String? : null; return DigitalIdVerification.fromValue(status); } Future shadowMode(String deviceId) async { try { - final decoded = await _send('POST', '/v3/digital-id/shadow-mode', body: { - 'device_id': deviceId, - }); + final decoded = await _send( + 'POST', + '/v3/digital-id/shadow-mode', + body: {'device_id': deviceId}, + ); return _unwrapData(decoded)['shadow_mode'] == true; } on DigitalIdException catch (e) { if (e.code == 'HTTP_404') return false; @@ -252,9 +256,11 @@ class DigitalIdModule { } Future userQr(String token) async { - final decoded = await _send('POST', '/v3/digital-id/user-qr', body: { - 'token': token, - }); + final decoded = await _send( + 'POST', + '/v3/digital-id/user-qr', + body: {'token': token}, + ); return DigitalIdUniversalQr.fromMap(_unwrapData(decoded)); } @@ -264,12 +270,16 @@ class DigitalIdModule { required DigitalIdQrType qrType, String? kidAct, }) async { - final decoded = await _send('POST', '/v3/digital-id/generate-qr', body: { - 'photo': photo, - 'token': token, - 'qr_type': qrType.code, - 'kid_act': ?kidAct, - }); + final decoded = await _send( + 'POST', + '/v3/digital-id/generate-qr', + body: { + 'photo': photo, + 'token': token, + 'qr_type': qrType.code, + 'kid_act': ?kidAct, + }, + ); return DigitalIdQr.fromMap(_unwrapData(decoded)); } @@ -277,14 +287,9 @@ class DigitalIdModule { String? passStatus, String? inn, }) async { - final query = { - 'pass_status': ?passStatus, - 'inn': ?inn, - }; - final suffix = - query.isEmpty ? '' : '?${Uri(queryParameters: query).query}'; - final decoded = - await _send('GET', '/v2/digital-id/get-cards-list$suffix'); + final query = {'pass_status': ?passStatus, 'inn': ?inn}; + final suffix = query.isEmpty ? '' : '?${Uri(queryParameters: query).query}'; + final decoded = await _send('GET', '/v2/digital-id/get-cards-list$suffix'); final cards = _unwrapData(decoded)['acms_cards']; if (cards is! List) return const []; return cards @@ -294,17 +299,19 @@ class DigitalIdModule { } Future activateAcms({required String id, required String inn}) async { - await _send('POST', '/v2/digital-id/activate-acms', body: { - 'id': id, - 'inn': inn, - 'pass_status': 'active', - }); + await _send( + 'POST', + '/v2/digital-id/activate-acms', + body: {'id': id, 'inn': inn, 'pass_status': 'active'}, + ); } Future createLiteProfile(String deviceId) async { - await _send('POST', '/v2/digital-id/create-lite-profile', body: { - 'device_id': deviceId, - }); + await _send( + 'POST', + '/v2/digital-id/create-lite-profile', + body: {'device_id': deviceId}, + ); } Future deleteProfile() async { diff --git a/lib/backend/modules/file_uploader.dart b/lib/backend/modules/file_uploader.dart index 790a9b8..6cb81de 100644 --- a/lib/backend/modules/file_uploader.dart +++ b/lib/backend/modules/file_uploader.dart @@ -42,6 +42,9 @@ class UploadError extends UploadEvent { } class FileUploader { + static const String _userAgentHeader = + 'OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)'; + final Api api; final MessagesModule messages; @@ -87,36 +90,24 @@ class FileUploader { }()); final uri = Uri.parse(info.url); - socket = await _openSocket(uri); - if (cancelled) return; - - _writeHeaders(socket!, uri, filename, totalSize); - - final stopwatch = Stopwatch()..start(); - var sent = 0; - final body = file.openRead().map((chunk) { - sent += chunk.length; - if (stopwatch.elapsed >= progressThrottle) { - ctrl.add(UploadProgress(sent: sent, total: totalSize)); - stopwatch.reset(); - } - return chunk; - }); - await socket!.addStream(body); - await socket!.flush(); - if (cancelled) return; - ctrl.add(UploadProgress(sent: totalSize, total: totalSize)); - - final statusCode = await _readResponse( - socket!, + final result = await _sendHttpRequest( + uri, + method: 'POST', + headers: _buildUploadHeaders(uri, filename, totalSize), + bodyStream: file.openRead(), + progressTotal: totalSize, + onProgress: (sent, total) { + if (!cancelled) ctrl.add(UploadProgress(sent: sent, total: total)); + }, + progressThrottle: progressThrottle, autoForceAfter: autoForceAfter, - overallTimeout: overallTimeout, + timeout: overallTimeout, + onSocketReady: (s) => socket = s, + shouldAbort: () => cancelled, ); - try { - socket!.destroy(); - } catch (_) {} if (cancelled) return; + final statusCode = result?.$1 ?? 0; if (statusCode != 200 && statusCode != 0) { ctrl.add(UploadError('http_$statusCode')); return; @@ -134,13 +125,15 @@ class FileUploader { return; } - ctrl.add(UploadDone( - fileId: info.fileId, - token: info.token, - url: info.url, - filename: filename, - size: totalSize, - )); + ctrl.add( + UploadDone( + fileId: info.fileId, + token: info.token, + url: info.url, + filename: filename, + size: totalSize, + ), + ); } catch (e) { if (!cancelled) ctrl.add(UploadError(e.toString())); } finally { @@ -155,11 +148,6 @@ class FileUploader { return ctrl.stream; } - /// Загружает медиа (Ogg/Opus аудио или MP4 видеосообщение) на CDN-URL, - /// полученный из [MessagesModule.requestAudioUploadUrl] / - /// [MessagesModule.requestVideoNoteUploadUrl]. Одиночный POST всего файла - /// (`octet-stream`, `Content-Range` на весь объём, `filename=<число>`). - /// Токен уже известен, поэтому возвращается только признак успеха. Future uploadMediaFile( Uri uri, File file, { @@ -167,43 +155,30 @@ class FileUploader { Duration overallTimeout = const Duration(minutes: 5), Duration progressThrottle = const Duration(milliseconds: 16), }) async { - Socket? socket; try { final total = await file.length(); if (total <= 0) return false; - final filename = - (DateTime.now().microsecondsSinceEpoch & 0x7FFFFFFF).toString(); + final filename = _syntheticFilename(); - socket = await _openSocket(uri); - _writeHeaders( - socket, + final result = await _sendHttpRequest( uri, - filename, - total, - contentType: 'application/octet-stream', - connection: 'close', + method: 'POST', + headers: _buildUploadHeaders( + uri, + filename, + total, + contentType: 'application/octet-stream', + connection: 'close', + ), + bodyStream: file.openRead(), + progressTotal: total, + onProgress: onProgress, + progressThrottle: progressThrottle, + timeout: overallTimeout, ); - final stopwatch = Stopwatch()..start(); - var sent = 0; - final body = file.openRead().map((chunk) { - sent += chunk.length; - if (onProgress != null && stopwatch.elapsed >= progressThrottle) { - onProgress(sent, total); - stopwatch.reset(); - } - return chunk; - }); - await socket.addStream(body); - await socket.flush(); - onProgress?.call(total, total); - - final response = await _readFullResponse(socket, timeout: overallTimeout); - try { - socket.destroy(); - } catch (_) {} - final statusCode = response?.$1 ?? 0; - final respBody = response?.$2 ?? ''; + final statusCode = result?.$1 ?? 0; + final respBody = result?.$2 ?? ''; logger.w( 'uploadMediaFile: status=$statusCode total=$total ' 'host=${uri.host} body=${respBody.length > 200 ? respBody.substring(0, 200) : respBody}', @@ -213,9 +188,6 @@ class FileUploader { return statusCode == 200 && !hasError; } catch (e) { logger.w('uploadMediaFile: $e'); - try { - socket?.destroy(); - } catch (_) {} return false; } } @@ -228,39 +200,49 @@ class FileUploader { if (uri.scheme != 'https') return base; final allowInsecure = await TlsConfig.isInsecureAllowed(); if (allowInsecure) { - logger.w('TLS: проверка сертификата отключена (дебаг) — загрузка уязвима к MitM'); - return SecureSocket.secure(base, host: uri.host, onBadCertificate: (_) => true); + logger.w( + 'TLS: проверка сертификата отключена (дебаг) — загрузка уязвима к MitM', + ); + return SecureSocket.secure( + base, + host: uri.host, + onBadCertificate: (_) => true, + ); } return SecureSocket.secure(base, host: uri.host); } - void _writeHeaders( - Socket socket, + String _syntheticFilename() => + (DateTime.now().microsecondsSinceEpoch & 0x7FFFFFFF).toString(); + + String _multipartBoundary() => + '----KometBoundary${DateTime.now().microsecondsSinceEpoch}'; + + Map _buildUploadHeaders( Uri uri, String filename, int total, { String contentType = 'application/x-binary; charset=x-user-defined', String connection = 'keep-alive', }) { - final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}'; - final headers = StringBuffer() - ..write('POST $path HTTP/1.1\r\n') - ..write('Host: ${uri.host}\r\n') - ..write('Content-Type: $contentType\r\n') - ..write('Content-Disposition: attachment; filename=$filename\r\n') - ..write('Connection: $connection\r\n') - ..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n') - ..write('Content-Range: bytes 0-${total - 1}/$total\r\n') - ..write('Content-Length: $total\r\n') - ..write('\r\n'); - socket.add(utf8.encode(headers.toString())); + return { + 'Host': uri.host, + 'Content-Type': contentType, + 'Content-Disposition': 'attachment; filename=$filename', + 'Connection': connection, + 'User-Agent': Uri.encodeComponent(_userAgentHeader), + 'Content-Range': 'bytes 0-${total - 1}/$total', + 'Content-Length': '$total', + }; } - Future uploadImage(Uri uri, Uint8List bytes, {String filename = 'avatar.jpg'}) async { - Socket? socket; + Future uploadImage( + Uri uri, + Uint8List bytes, { + String filename = 'avatar.jpg', + }) async { try { - socket = await _openSocket(uri); - final boundary = '----KometBoundary${DateTime.now().microsecondsSinceEpoch}'; + final boundary = _multipartBoundary(); final preamble = utf8.encode( '--$boundary\r\n' 'Content-Disposition: form-data; name="file"; filename="$filename"\r\n' @@ -268,43 +250,40 @@ class FileUploader { '\r\n', ); final epilogue = utf8.encode('\r\n--$boundary--\r\n'); - _writeImageHeaders( - socket, - uri, - preamble.length + bytes.length + epilogue.length, - boundary: boundary, - ); - socket.add(preamble); - socket.add(bytes); - socket.add(epilogue); - await socket.flush(); - final response = await _readFullResponse( - socket, + final response = await _sendHttpRequest( + uri, + method: 'POST', + headers: _buildMultipartHeaders( + uri, + preamble.length + bytes.length + epilogue.length, + boundary: boundary, + ), + prefixBytes: preamble, + bodyStream: Stream.value(bytes), + suffixBytes: epilogue, timeout: const Duration(minutes: 2), ); - try { - socket.destroy(); - } catch (_) {} if (response == null) { return null; } final (status, body) = response; if (status != 200) { - logger.w('uploadImage: status=$status body=${body.length > 200 ? '${body.substring(0, 200)}…' : body}'); + logger.w( + 'uploadImage: status=$status body=${body.length > 200 ? '${body.substring(0, 200)}…' : body}', + ); return null; } final token = _parsePhotoToken(body); if (token == null) { - logger.w('uploadImage: photoToken not found in body=${body.length > 200 ? '${body.substring(0, 200)}…' : body}'); + logger.w( + 'uploadImage: photoToken not found in body=${body.length > 200 ? '${body.substring(0, 200)}…' : body}', + ); } return token; } catch (e) { logger.w('uploadImage: $e'); - try { - socket?.destroy(); - } catch (_) {} return null; } } @@ -316,12 +295,9 @@ class FileUploader { void Function(int sent, int total)? onProgress, Duration progressThrottle = const Duration(milliseconds: 16), }) async { - Socket? socket; try { final fileLength = await file.length(); - socket = await _openSocket(uri); - final boundary = - '----KometBoundary${DateTime.now().microsecondsSinceEpoch}'; + final boundary = _multipartBoundary(); final preamble = utf8.encode( '--$boundary\r\n' 'Content-Disposition: form-data; name="file"; filename="$filename"\r\n' @@ -329,36 +305,23 @@ class FileUploader { '\r\n', ); final epilogue = utf8.encode('\r\n--$boundary--\r\n'); - _writeImageHeaders( - socket, + + final response = await _sendHttpRequest( uri, - preamble.length + fileLength + epilogue.length, - boundary: boundary, - ); - socket.add(preamble); - - final stopwatch = Stopwatch()..start(); - var sent = 0; - final body = file.openRead().map((chunk) { - sent += chunk.length; - if (onProgress != null && stopwatch.elapsed >= progressThrottle) { - onProgress(sent, fileLength); - stopwatch.reset(); - } - return chunk; - }); - await socket.addStream(body); - socket.add(epilogue); - await socket.flush(); - onProgress?.call(fileLength, fileLength); - - final response = await _readFullResponse( - socket, + method: 'POST', + headers: _buildMultipartHeaders( + uri, + preamble.length + fileLength + epilogue.length, + boundary: boundary, + ), + prefixBytes: preamble, + bodyStream: file.openRead(), + suffixBytes: epilogue, + progressTotal: fileLength, + onProgress: onProgress, + progressThrottle: progressThrottle, timeout: const Duration(minutes: 2), ); - try { - socket.destroy(); - } catch (_) {} if (response == null) return null; final (status, responseBody) = response; @@ -369,20 +332,10 @@ class FileUploader { return _parsePhotoToken(responseBody); } catch (e) { logger.w('uploadPhoto: $e'); - try { - socket?.destroy(); - } catch (_) {} return null; } } - /// Загружает видео на CDN-URL (vu.okcdn.ru/upload.do), полученный из - /// [MessagesModule.requestVideoUploadUrl], по протоколу OK с докачкой: - /// сначала GET-хендшейк (возвращает уже загруженный оффсет), затем - /// параллельная отправка чанков по [chunkSize] байт через `Content-Range` - /// ([concurrency] одновременных соединений, режим `X-Uploading-Mode: - /// parallel`). Токен уже известен, поэтому возвращается только признак - /// успеха. Future uploadVideoFile( Uri uri, File file, { @@ -394,8 +347,7 @@ class FileUploader { final total = await file.length(); if (total <= 0) return false; - final fileName = - (DateTime.now().microsecondsSinceEpoch & 0x7FFFFFFF).toString(); + final fileName = _syntheticFilename(); final handshake = await _okCdnRequest( uri, @@ -470,56 +422,140 @@ class FileUploader { String? contentRange, required Duration timeout, }) async { - Socket? socket; try { - socket = await _openSocket(uri); - final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}'; - final headers = StringBuffer() - ..write('$method $path HTTP/1.1\r\n') - ..write('Host: ${uri.host}\r\n') - ..write('Content-Type: application/x-binary; charset=x-user-defined\r\n') - ..write('Content-Disposition: attachment; fileName="$fileName"\r\n'); - if (contentRange != null) { - headers.write('Content-Range: $contentRange\r\n'); - } - headers - ..write('Content-Length: ${body?.length ?? 0}\r\n') - ..write('X-Uploading-Mode: parallel\r\n') - ..write('Connection: close\r\n') - ..write('\r\n'); - socket.add(utf8.encode(headers.toString())); - if (body != null && body.isNotEmpty) socket.add(body); - await socket.flush(); - - final response = await _readFullResponse(socket, timeout: timeout); - try { - socket.destroy(); - } catch (_) {} - return response; + final headers = { + 'Host': uri.host, + 'Content-Type': 'application/x-binary; charset=x-user-defined', + 'Content-Disposition': 'attachment; fileName="$fileName"', + 'Content-Range': ?contentRange, + 'Content-Length': '${body?.length ?? 0}', + 'X-Uploading-Mode': 'parallel', + 'Connection': 'close', + }; + return await _sendHttpRequest( + uri, + method: method, + headers: headers, + prefixBytes: body, + timeout: timeout, + ); } catch (e) { logger.w('_okCdnRequest($method): $e'); - try { - socket?.destroy(); - } catch (_) {} return null; } } - void _writeImageHeaders(Socket socket, Uri uri, int total, {required String boundary}) { + Map _buildMultipartHeaders( + Uri uri, + int total, { + required String boundary, + }) { + return { + 'Host': uri.host, + 'Content-Type': 'multipart/form-data; boundary=$boundary', + 'Content-Length': '$total', + 'Connection': 'keep-alive', + 'User-Agent': Uri.encodeComponent(_userAgentHeader), + }; + } + + Future<(int, String)?> _sendHttpRequest( + Uri uri, { + required String method, + required Map headers, + List? prefixBytes, + Stream>? bodyStream, + List? suffixBytes, + int? progressTotal, + void Function(int sent, int total)? onProgress, + Duration progressThrottle = const Duration(milliseconds: 16), + Duration? autoForceAfter, + required Duration timeout, + void Function(Socket socket)? onSocketReady, + bool Function()? shouldAbort, + }) async { + final socket = await _openSocket(uri); + onSocketReady?.call(socket); + try { + if (shouldAbort?.call() ?? false) return null; + + _writeRequestHeaders(socket, uri, method, headers); + if (prefixBytes != null && prefixBytes.isNotEmpty) { + socket.add(prefixBytes); + } + if (bodyStream != null) { + final stream = (onProgress != null && progressTotal != null) + ? _withProgress( + bodyStream, + progressTotal, + onProgress, + throttle: progressThrottle, + ) + : bodyStream; + await socket.addStream(stream); + } + if (suffixBytes != null && suffixBytes.isNotEmpty) { + socket.add(suffixBytes); + } + await socket.flush(); + if (onProgress != null && progressTotal != null) { + onProgress(progressTotal, progressTotal); + } + if (shouldAbort?.call() ?? false) return null; + + if (autoForceAfter != null) { + final status = await _readResponse( + socket, + autoForceAfter: autoForceAfter, + overallTimeout: timeout, + ); + return (status, ''); + } + return await _readFullResponse(socket, timeout: timeout); + } finally { + try { + socket.destroy(); + } catch (_) {} + } + } + + void _writeRequestHeaders( + Socket socket, + Uri uri, + String method, + Map headers, + ) { final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}'; - final headers = StringBuffer() - ..write('POST $path HTTP/1.1\r\n') - ..write('Host: ${uri.host}\r\n') - ..write('Content-Type: multipart/form-data; boundary=$boundary\r\n') - ..write('Content-Length: $total\r\n') - ..write('Connection: keep-alive\r\n') - ..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n') - ..write('\r\n'); - socket.add(utf8.encode(headers.toString())); + final buffer = StringBuffer()..write('$method $path HTTP/1.1\r\n'); + for (final entry in headers.entries) { + buffer.write('${entry.key}: ${entry.value}\r\n'); + } + buffer.write('\r\n'); + socket.add(utf8.encode(buffer.toString())); + } + + Stream> _withProgress( + Stream> src, + int total, + void Function(int sent, int total) onProgress, { + Duration throttle = const Duration(milliseconds: 16), + }) { + final stopwatch = Stopwatch()..start(); + var sent = 0; + return src.map((chunk) { + sent += chunk.length; + if (stopwatch.elapsed >= throttle) { + onProgress(sent, total); + stopwatch.reset(); + } + return chunk; + }); } String _contentTypeForFilename(String filename) { - final ext = filename.contains('.') ? filename.split('.').last.toLowerCase() : ''; + final ext = filename.contains('.') + ? filename.split('.').last.toLowerCase() + : ''; switch (ext) { case 'png': return 'image/png'; @@ -557,13 +593,17 @@ class FileUploader { (int, String)? tryParse({required bool atClose}) { final headerEnd = _findHeaderEnd(bytes); if (headerEnd == -1) return null; - final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true); + final headerStr = utf8.decode( + bytes.sublist(0, headerEnd), + allowMalformed: true, + ); final lines = headerStr.split('\r\n'); final parts = lines.first.split(' '); final status = parts.length >= 2 ? (int.tryParse(parts[1]) ?? 0) : 0; final headerLines = lines.skip(1); final chunked = headerLines.any( - (l) => l.toLowerCase().startsWith('transfer-encoding:') && + (l) => + l.toLowerCase().startsWith('transfer-encoding:') && l.toLowerCase().contains('chunked'), ); int? contentLength; @@ -572,12 +612,17 @@ class FileUploader { contentLength = int.tryParse(l.split(':').last.trim()); } } - final rawBody = utf8.decode(bytes.sublist(headerEnd), allowMalformed: true); + final rawBody = utf8.decode( + bytes.sublist(headerEnd), + allowMalformed: true, + ); if (chunked) { if (!atClose && !rawBody.contains('\r\n0\r\n')) return null; return (status, _decodeChunked(rawBody)); } - if (contentLength != null && !atClose && bytes.length - headerEnd < contentLength) { + if (contentLength != null && + !atClose && + bytes.length - headerEnd < contentLength) { return null; } return (status, rawBody); @@ -596,7 +641,9 @@ class FileUploader { onDone: () { final parsed = tryParse(atClose: true); if (parsed == null) { - logger.w('uploadImage: connection closed without HTTP response (${bytes.length} bytes)'); + logger.w( + 'uploadImage: connection closed without HTTP response (${bytes.length} bytes)', + ); } finishWith(parsed); }, @@ -697,7 +744,10 @@ class FileUploader { }, ); - overall = Timer(overallTimeout, () => fail(TimeoutException('Тайм-аут загрузки'))); + overall = Timer( + overallTimeout, + () => fail(TimeoutException('Тайм-аут загрузки')), + ); return completer.future; } @@ -705,7 +755,10 @@ class FileUploader { int? _parseHttpStatus(List bytes) { final headerEnd = _findHeaderEnd(bytes); if (headerEnd == -1) return null; - final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true); + final headerStr = utf8.decode( + bytes.sublist(0, headerEnd), + allowMalformed: true, + ); final statusLine = headerStr.split('\r\n').first; final parts = statusLine.split(' '); if (parts.length < 2) return null; diff --git a/lib/backend/modules/folders.dart b/lib/backend/modules/folders.dart index afa4860..74eb3ee 100644 --- a/lib/backend/modules/folders.dart +++ b/lib/backend/modules/folders.dart @@ -25,10 +25,7 @@ class FoldersModule { static bool isAllChatsFolder(ChatFolder f) { if (f.id == 'all.chat.folder') return true; final t = f.title.trim().toLowerCase(); - return t == 'все' || - t == 'все чаты' || - t == 'all' || - t == 'all chats'; + return t == 'все' || t == 'все чаты' || t == 'all' || t == 'all chats'; } static String? preferredInitialFolderId(List folders) { @@ -88,20 +85,42 @@ class FoldersModule { return false; } + static List _parseFolderList( + List json, { + bool lenient = true, + }) { + if (lenient) { + return json + .map((e) { + try { + final m = e is Map + ? e + : Map.from(e as Map); + return ChatFolder.fromJson(m); + } catch (_) { + return null; + } + }) + .whereType() + .toList(); + } + return json.map((e) { + final m = e is Map + ? e + : Map.from(e as Map); + return ChatFolder.fromJson(m); + }).toList(); + } + static Future> loadFolders(int accountId) async { final raw = await AppDatabase.getSyncValue(accountId, _syncKey); if (raw == null || raw.isEmpty) return []; try { final map = jsonDecode(raw) as Map; - final folders = (map['folders'] as List?) - ?.map((e) { - final m = e is Map - ? e - : Map.from(e as Map); - return ChatFolder.fromJson(m); - }) - .toList() ?? - []; + final foldersJson = map['folders'] as List?; + final folders = foldersJson == null + ? [] + : _parseFolderList(foldersJson, lenient: false); final order = map['foldersOrder'] as List?; sortFoldersInPlace(folders, order); return folders; @@ -135,19 +154,7 @@ class FoldersModule { List folders; if (foldersJson != null) { - folders = foldersJson - .map((json) { - try { - final m = json is Map - ? json - : Map.from(json as Map); - return ChatFolder.fromJson(m); - } catch (_) { - return null; - } - }) - .whereType() - .toList(); + folders = _parseFolderList(foldersJson); } else { folders = await loadFolders(accountId); } @@ -164,19 +171,7 @@ class FoldersModule { final foldersJson = chatFolders['FOLDERS'] as List?; if (foldersJson == null) return; final order = chatFolders['foldersOrder'] as List?; - final folders = foldersJson - .map((json) { - try { - final m = json is Map - ? json - : Map.from(json as Map); - return ChatFolder.fromJson(m); - } catch (_) { - return null; - } - }) - .whereType() - .toList(); + final folders = _parseFolderList(foldersJson); sortFoldersInPlace(folders, order); await _persist(accountId, folders, order); await markFoldersListReady(accountId); @@ -196,9 +191,7 @@ class FoldersModule { 'filters': folder.filters, 'options': folder.options ?? const [], }); - if (packet.isError) { - throw PacketError(messageFromErrorPayload(packet.payload)); - } + throwIfPacketError(packet); final data = packet.payload; if (data is! Map) return null; final folderJson = data['folder']; @@ -213,15 +206,10 @@ class FoldersModule { final snapshot = (currentRaw != null && currentRaw.isNotEmpty) ? jsonDecode(currentRaw) as Map : {}; - final existing = (snapshot['folders'] as List?) - ?.map((e) { - final m = e is Map - ? e - : Map.from(e as Map); - return ChatFolder.fromJson(m); - }) - .toList() ?? - []; + final existingRaw = snapshot['folders'] as List?; + final existing = existingRaw == null + ? [] + : _parseFolderList(existingRaw, lenient: false); final idx = existing.indexWhere((f) => f.id == updated.id); if (idx >= 0) { existing[idx] = updated; @@ -238,9 +226,7 @@ class FoldersModule { final packet = await api.sendRequest(Opcode.foldersGet, { 'folderSync': 0, }); - if (packet.isError) { - throw PacketError(messageFromErrorPayload(packet.payload)); - } + throwIfPacketError(packet); final data = packet.payload; if (data is Map) { await applyPayload(accountId, data.cast()); diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 773dd33..0ba12fd 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -10,7 +10,7 @@ import '../../core/storage/app_database.dart'; import '../../core/utils/logger.dart'; import '../../core/utils/text_format.dart'; import '../../models/attachment.dart'; -import 'chats.dart' show ChatsModule; +import 'chats.dart' show chats; class ContactCache { static final Map _nameCache = {}; @@ -337,6 +337,10 @@ class ReplyInfo { return ''; case AttachmentType.inlineKeyboard: return ''; + case AttachmentType.forward: + return 'Переслано'; + case AttachmentType.unknown: + return 'Вложение'; } } return ''; @@ -434,6 +438,28 @@ class CachedMessage { return list; } + static (List?, bool) parseAttachments( + Map map, + ) { + List? attachments; + final link = map['link']; + final linkType = link is Map ? link['type'] as String? : null; + if (linkType == 'FORWARD') { + attachments = [ForwardedMessageAttachment.fromMap(map)]; + } else { + final attaches = map['attaches'] as List?; + if (attaches != null) { + attachments = attaches + .whereType() + .map((a) => MessageAttachment.fromMap(Map.from(a))) + .toList(); + } + } + final isControl = + attachments?.any((a) => a.type == AttachmentType.control) ?? false; + return (attachments, isControl); + } + factory CachedMessage.fromDbRow(Map row) { Map? payload; final payloadRaw = row['payload']; @@ -444,22 +470,11 @@ class CachedMessage { } List? attachments; + bool isControl = false; if (payload != null) { - final linkType = payload['link']?['type'] as String?; - if (linkType == 'FORWARD') { - attachments = [ForwardedMessageAttachment.fromMap(payload)]; - } else { - final attaches = payload['attaches'] as List?; - if (attaches != null) { - attachments = attaches - .map( - (a) => MessageAttachment.fromMap( - Map.from(a as Map), - ), - ) - .toList(); - } - } + final parsed = parseAttachments(payload); + attachments = parsed.$1; + isControl = parsed.$2; } return CachedMessage( @@ -480,8 +495,7 @@ class CachedMessage { status: row['status']?.toString(), payload: payload, attachments: attachments, - isControl: - attachments?.any((a) => a.type == AttachmentType.control) ?? false, + isControl: isControl, deleted: row['deleted'] is int ? row['deleted'] == 1 : row['deleted']?.toString() == '1', @@ -503,7 +517,8 @@ class CachedMessage { ReplyInfo? get replyInfo => ReplyInfo.fromPayload(payload); - List get formatRanges => parseFormatElements(payload?['elements']); + List get formatRanges => + parseFormatElements(payload?['elements']); static List _decodeRows(List> rows) => rows.map(CachedMessage.fromDbRow).toList(); @@ -531,14 +546,8 @@ class CachedMessage { }; static CachedMessage fromPushPayload(int accountId, int chatId, Map msg) { - List? attachments; - final attaches = msg['attaches']; - if (attaches is List && attaches.isNotEmpty) { - attachments = attaches - .whereType() - .map((a) => MessageAttachment.fromMap(Map.from(a))) - .toList(); - } + final full = Map.from(msg); + final parsed = parseAttachments(full); return CachedMessage( id: msg['id']?.toString() ?? '', accountId: accountId, @@ -547,8 +556,9 @@ class CachedMessage { text: msg['text'] as String?, time: (msg['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch, status: (msg['status'] as String?) ?? 'sent', - payload: Map.from(msg), - attachments: attachments, + payload: full, + attachments: parsed.$1, + isControl: parsed.$2, ); } } @@ -558,11 +568,6 @@ class MessagesModule { MessagesModule(this._api); - /// Загружает историю сообщений для указанного чата. - /// - /// [fromTime] — опционально, время от которого грузить (миллисекунды). - /// Если не указано, грузит самые свежие. - /// [count] — количество сообщений. Future> fetchHistory( int accountId, int chatId, { @@ -573,10 +578,7 @@ class MessagesModule { }) async { final payload = { 'chatId': chatId, - 'from': - fromTime ?? - (DateTime.now().millisecondsSinceEpoch + - 86400000), // +1 день для запаса + 'from': fromTime ?? (DateTime.now().millisecondsSinceEpoch + 86400000), 'forward': forward, 'backward': backward ?? count, 'getMessages': true, @@ -614,9 +616,7 @@ class MessagesModule { if (toSave.isNotEmpty) { try { - await AppDatabase.saveMessages( - toSave.map((m) => m.toDbRow()).toList(), - ); + await AppDatabase.saveMessages(toSave.map((m) => m.toDbRow()).toList()); } catch (e) { logger.e('saveMessages error: $e'); } @@ -625,11 +625,6 @@ class MessagesModule { return toSave; } - /// Поиск сообщений в чате по строке [query] (opcode 73). - /// - /// Возвращает сырые записи результата вида - /// `{'message': {...}, 'highlights': [...]}`, отсортированные сервером - /// от новых к старым. Future>> searchMessages( int chatId, String query, { @@ -691,7 +686,6 @@ class MessagesModule { return out; } - /// Загружает сообщения из локальной базы данных. Future> getLocalHistory( int accountId, int chatId, { @@ -715,30 +709,8 @@ class MessagesModule { final id = m['id']?.toString(); if (id == null) return null; - final linkRaw = m['link']; - String? linkType; - if (linkRaw is Map) { - linkType = linkRaw['type'] as String?; - } - - List? attachments; - bool isControl = false; - if (linkType == 'FORWARD') { - final fwdMap = Map.from(m.cast()); - attachments = [ForwardedMessageAttachment.fromMap(fwdMap)]; - } else { - final attaches = m['attaches'] as List?; - if (attaches != null) { - attachments = attaches - .whereType() - .map((a) => MessageAttachment.fromMap(Map.from(a))) - .toList(); - // Detect CONTROL - if (attachments.any((a) => a.type == AttachmentType.control)) { - isControl = true; - } - } - } + final full = Map.from(m.cast()); + final parsed = CachedMessage.parseAttachments(full); return CachedMessage( id: id, @@ -748,9 +720,9 @@ class MessagesModule { text: m['text']?.toString(), time: _parseIntField(m['time']), status: m['status']?.toString(), - payload: Map.from(m.cast()), - attachments: attachments, - isControl: isControl, + payload: full, + attachments: parsed.$1, + isControl: parsed.$2, ); } @@ -789,20 +761,18 @@ class MessagesModule { 'notifySender': true, }; } - final payload = { - 'chatId': chatId, - 'message': message, - 'notify': notify, - }; + final payload = {'chatId': chatId, 'message': message, 'notify': notify}; + return _sendAndExtractMessageId(payload, 'Ошибка отправки'); + } + + Future _sendAndExtractMessageId( + Map payload, + String defaultError, + ) async { final response = await _api.sendRequest(Opcode.msgSend, payload); if (!response.isOk) { - final msg = (response.payload is Map) - ? (response.payload['localizedMessage'] ?? - response.payload['message'] ?? - 'Ошибка отправки') - : 'Ошибка отправки'; - throw Exception(msg.toString()); + _throwSendError(response.payload, defaultError); } final data = response.payload; if (data is Map) { @@ -815,11 +785,46 @@ class MessagesModule { return ''; } - /// Пересылает сообщение [messageId] из чата [sourceChatId] в [targetChatId]. - /// - /// Пересылка — это отдельное сообщение без текста и вложений, со ссылкой - /// `link.type = FORWARD`, указывающей на оригинал. Сервер сам подставит - /// тело оригинала в ответе. + Never _throwSendError(dynamic payload, String fallback) { + final msg = (payload is Map) + ? (payload['localizedMessage'] ?? payload['message'] ?? fallback) + : fallback; + throw Exception(msg.toString()); + } + + Map? _sentMessageMap(Packet response) { + if (!response.isOk) return null; + final data = response.payload; + if (data is Map) { + final msg = data['message']; + if (msg is Map) return Map.from(msg); + } + return null; + } + + Future _sendWithNotReadyRetry({ + required Map payload, + required int maxAttempts, + required Duration retryDelay, + required T Function(Packet response) onResult, + required T onExhausted, + }) async { + for (var attempt = 0; attempt < maxAttempts; attempt++) { + try { + final response = await _api.sendRequest(Opcode.msgSend, payload); + return onResult(response); + } on PacketError catch (e) { + if (!(e.errorKey?.contains('not.ready') ?? false)) { + logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}'); + rethrow; + } + if (attempt == maxAttempts - 1) return onExhausted; + await Future.delayed(retryDelay); + } + } + return onExhausted; + } + Future forwardMessage( int targetChatId, int sourceChatId, @@ -844,24 +849,7 @@ class MessagesModule { 'notify': notify, }; - final response = await _api.sendRequest(Opcode.msgSend, payload); - if (!response.isOk) { - final msg = (response.payload is Map) - ? (response.payload['localizedMessage'] ?? - response.payload['message'] ?? - 'Ошибка пересылки') - : 'Ошибка пересылки'; - throw Exception(msg.toString()); - } - final data = response.payload; - if (data is Map) { - final msgMap = data['message']; - if (msgMap is Map) { - final id = msgMap['id']; - if (id != null) return id.toString(); - } - } - return ''; + return _sendAndExtractMessageId(payload, 'Ошибка пересылки'); } static CachedMessage buildForwardMessage({ @@ -955,18 +943,13 @@ class MessagesModule { ], 'attaches': [], }; - final response = await _api.sendRequest(Opcode.msgSend, { + return _api.sendRequestOk(Opcode.msgSend, { 'chatId': chatId, 'message': message, 'notify': true, }); - return response.isOk; } - /// Загружает отложенные (запланированные) сообщения чата. - /// - /// В отличие от обычной истории, отложенные сообщения не сохраняются - /// в локальную БД — они живут только до момента отправки. Future> fetchDelayedMessages( int accountId, int chatId, @@ -1009,10 +992,6 @@ class MessagesModule { return results; } - /// Редактирует текст (подпись) обычного сообщения. - /// - /// Поле `attachments` не передаётся — сервер сохраняет существующие - /// вложения. Future editMessage( int chatId, String messageId, { @@ -1031,13 +1010,9 @@ class MessagesModule { }; if (sendAttachments) payload['attachments'] = const []; - final response = await _api.sendRequest(Opcode.msgEdit, payload); - return response.isOk; + return _api.sendRequestOk(Opcode.msgEdit, payload); } - /// Редактирует отложенное сообщение: меняет текст и/или время отправки. - /// - /// Вложения сервер сохраняет сам — в payload они не передаются. Future editScheduledMessage( int chatId, String messageId, { @@ -1052,14 +1027,10 @@ class MessagesModule { 'chatId': chatId, 'elements': [], 'text': text, - 'delayedAttributes': { - 'timeToFire': timeToFire, - 'notifySender': true, - }, + 'delayedAttributes': {'timeToFire': timeToFire, 'notifySender': true}, }; - final response = await _api.sendRequest(Opcode.msgEdit, payload); - return response.isOk; + return _api.sendRequestOk(Opcode.msgEdit, payload); } Future deleteMessages( @@ -1081,8 +1052,7 @@ class MessagesModule { 'itemType': itemType, }; - final response = await _api.sendRequest(Opcode.msgDelete, payload); - return response.isOk; + return _api.sendRequestOk(Opcode.msgDelete, payload); } Future?> sendButtonCallback({ @@ -1168,7 +1138,6 @@ class MessagesModule { int? scheduledTime, int maxAttempts = 20, Duration retryDelay = const Duration(seconds: 1), - Duration initialDelay = const Duration(seconds: 3), }) async { final message = { 'isLive': false, @@ -1188,29 +1157,15 @@ class MessagesModule { 'notifySender': true, }; } - final payload = { - 'chatId': chatId, - 'message': message, - 'notify': notify, - }; + final payload = {'chatId': chatId, 'message': message, 'notify': notify}; - await Future.delayed(initialDelay); - - for (var attempt = 0; attempt < maxAttempts; attempt++) { - try { - final response = await _api.sendRequest(Opcode.msgSend, payload); - if (response.isOk) return true; - return false; - } on PacketError catch (e) { - if (!(e.errorKey?.contains('not.ready') ?? false)) { - logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}'); - rethrow; - } - if (attempt == maxAttempts - 1) return false; - await Future.delayed(retryDelay); - } - } - return false; + return _sendWithNotReadyRetry( + payload: payload, + maxAttempts: maxAttempts, + retryDelay: retryDelay, + onResult: (response) => response.isOk, + onExhausted: false, + ); } Future requestPhotoUploadUrl() async { @@ -1246,29 +1201,15 @@ class MessagesModule { } final payload = {'chatId': chatId, 'message': message, 'notify': notify}; - for (var attempt = 0; attempt < maxAttempts; attempt++) { - try { - final response = await _api.sendRequest(Opcode.msgSend, payload); - if (!response.isOk) return null; - final data = response.payload; - if (data is Map) { - final msg = data['message']; - if (msg is Map) return Map.from(msg); - } - return null; - } on PacketError catch (e) { - if (!(e.errorKey?.contains('not.ready') ?? false)) { - logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}'); - rethrow; - } - if (attempt == maxAttempts - 1) return null; - await Future.delayed(retryDelay); - } - } - return null; + return _sendWithNotReadyRetry?>( + payload: payload, + maxAttempts: maxAttempts, + retryDelay: retryDelay, + onResult: _sentMessageMap, + onExhausted: null, + ); } - /// Запрашивает URL для загрузки видео (опкод 82). Future requestVideoUploadUrl() async { final response = await _api.sendRequest(Opcode.videoUpload, { 'uploaderType': 0, @@ -1293,9 +1234,6 @@ class MessagesModule { ); } - /// Отправляет сообщение с видео по [token], полученному из - /// [requestVideoUploadUrl]. Сервер может ответить `attachment.not.ready`, - /// пока обрабатывает загруженное видео — в этом случае запрос повторяется. Future?> sendVideoMessage( int chatId, String token, { @@ -1323,33 +1261,15 @@ class MessagesModule { } final payload = {'chatId': chatId, 'message': message, 'notify': notify}; - for (var attempt = 0; attempt < maxAttempts; attempt++) { - try { - final response = await _api.sendRequest(Opcode.msgSend, payload); - if (!response.isOk) return null; - final data = response.payload; - if (data is Map) { - final msg = data['message']; - if (msg is Map) return Map.from(msg); - } - return null; - } on PacketError catch (e) { - if (!(e.errorKey?.contains('not.ready') ?? false)) { - logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}'); - rethrow; - } - if (attempt == maxAttempts - 1) return null; - await Future.delayed(retryDelay); - } - } - return null; + return _sendWithNotReadyRetry?>( + payload: payload, + maxAttempts: maxAttempts, + retryDelay: retryDelay, + onResult: _sentMessageMap, + onExhausted: null, + ); } - /// Запрашивает URL для загрузки голосового сообщения (опкод 82). - /// - /// Тот же опкод, что и у видео, но `uploaderType: 1, type: 2`. В ответе - /// `videoId` — это идентификатор аудио (`audioId`), а `token` уже выдан и - /// используется в [sendAudioMessage] после загрузки байтов. Future requestAudioUploadUrl() async { final response = await _api.sendRequest(Opcode.videoUpload, { 'uploaderType': 1, @@ -1374,13 +1294,6 @@ class MessagesModule { ); } - /// Отправляет голосовое сообщение по [token], полученному из - /// [requestAudioUploadUrl], после загрузки Ogg/Opus-байтов на CDN. - /// - /// [duration] — длительность в миллисекундах. [wave] — hex-строка амплитуд - /// для дорожки; если пусто, отправляется плоская (нулевая) волна, которую - /// сервер принимает. Сервер может ответить `attachment.not.ready`, пока - /// обрабатывает загрузку — запрос повторяется. Future?> sendAudioMessage( int chatId, String token, { @@ -1413,30 +1326,15 @@ class MessagesModule { } final payload = {'chatId': chatId, 'message': message, 'notify': notify}; - for (var attempt = 0; attempt < maxAttempts; attempt++) { - try { - final response = await _api.sendRequest(Opcode.msgSend, payload); - if (!response.isOk) return null; - final data = response.payload; - if (data is Map) { - final msg = data['message']; - if (msg is Map) return Map.from(msg); - } - return null; - } on PacketError catch (e) { - if (!(e.errorKey?.contains('not.ready') ?? false)) { - logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}'); - rethrow; - } - if (attempt == maxAttempts - 1) return null; - await Future.delayed(retryDelay); - } - } - return null; + return _sendWithNotReadyRetry?>( + payload: payload, + maxAttempts: maxAttempts, + retryDelay: retryDelay, + onResult: _sentMessageMap, + onExhausted: null, + ); } - /// Запрашивает URL для загрузки видеосообщения-кружка (опкод 82, - /// `uploaderType: 1, type: 1`). Ответ — `vu.oneme.ru/uploadVideo` + token. Future requestVideoNoteUploadUrl() async { final response = await _api.sendRequest(Opcode.videoUpload, { 'uploaderType': 1, @@ -1461,13 +1359,6 @@ class MessagesModule { ); } - /// Отправляет видеосообщение-кружок (`videoType: 1`) по [token], полученному - /// из [requestVideoNoteUploadUrl], после загрузки MP4-байтов на CDN. - /// - /// [duration] — длительность в мс. [wave] — амплитуды аудиодорожки (бинарь, - /// 80 байт; нули допустимы). [thumbhash] — компактный хеш превью (опционально, - /// сервер всё равно отдаёт собственный `previewData`). Повторяет запрос на - /// `attachment.not.ready`, пока CDN обрабатывает загрузку. Future?> sendVideoNoteMessage( int chatId, String token, { @@ -1496,26 +1387,13 @@ class MessagesModule { }; final payload = {'chatId': chatId, 'message': message, 'notify': notify}; - for (var attempt = 0; attempt < maxAttempts; attempt++) { - try { - final response = await _api.sendRequest(Opcode.msgSend, payload); - if (!response.isOk) return null; - final data = response.payload; - if (data is Map) { - final msg = data['message']; - if (msg is Map) return Map.from(msg); - } - return null; - } on PacketError catch (e) { - if (!(e.errorKey?.contains('not.ready') ?? false)) { - logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}'); - rethrow; - } - if (attempt == maxAttempts - 1) return null; - await Future.delayed(retryDelay); - } - } - return null; + return _sendWithNotReadyRetry?>( + payload: payload, + maxAttempts: maxAttempts, + retryDelay: retryDelay, + onResult: _sentMessageMap, + onExhausted: null, + ); } Future?> sendLocationMessage( @@ -1542,15 +1420,12 @@ class MessagesModule { }; final response = await _api.sendRequest(Opcode.msgSend, payload); - if (!response.isOk) return null; - final data = response.payload; - if (data is Map) { - final msg = data['message']; - if (msg is Map) return Map.from(msg); - } - return null; + return _sentMessageMap(response); } + static const int _pollAnonymousFlag = 4; + static const int _pollMultipleFlag = 1; + Future?> sendPollMessage( int chatId, String title, @@ -1559,7 +1434,9 @@ class MessagesModule { bool anonymous = true, bool notify = true, }) async { - final settings = (anonymous ? 4 : 0) | (multiple ? 1 : 0); + final settings = + (anonymous ? _pollAnonymousFlag : 0) | + (multiple ? _pollMultipleFlag : 0); final payload = { 'chatId': chatId, 'message': { @@ -1579,13 +1456,7 @@ class MessagesModule { }; final response = await _api.sendRequest(Opcode.msgSend, payload); - if (!response.isOk) return null; - final data = response.payload; - if (data is Map) { - final msg = data['message']; - if (msg is Map) return Map.from(msg); - } - return null; + return _sentMessageMap(response); } void sendTyping(int chatId, String type) { @@ -1616,13 +1487,7 @@ class MessagesModule { }; final response = await _api.sendRequest(Opcode.msgSend, payload); - if (!response.isOk) return null; - final data = response.payload; - if (data is Map) { - final msg = data['message']; - if (msg is Map) return Map.from(msg); - } - return null; + return _sentMessageMap(response); } Future downloadPhoto(String baseUrl, String photoToken) async { @@ -1662,13 +1527,6 @@ class MessagesModule { } } - /// Запрашивает у сервера ссылки на воспроизведение видео (opcode 83). - /// - /// Формат подтверждён дампом: запрос `{messageId, chatId, token, videoId}`, - /// ответ содержит `MP4_1080/MP4_720/...`, `HLS`, `DASH`, `EXTERNAL`. - /// Возвращает все доступные progressive-MP4 качества (label → URL), - /// отсортированные по убыванию. URL'ы — готовые подписанные ссылки на CDN, - /// поддерживающие HTTP range, поэтому пригодны для стриминга. Future> getVideoSources({ required String messageId, required int chatId, @@ -1713,7 +1571,6 @@ class MessagesModule { } } - /// Возвращает один лучший progressive-MP4 (или HLS как запасной). Future getVideoUrl({ required String messageId, required int chatId, @@ -1769,10 +1626,6 @@ class MessagesModule { } } - /// Запрашивает у сервера временный CDN-URL для скачивания файла (opcode 88). - /// - /// Формат подтверждён дампом: запрос `{messageId, chatId, fileId}`, - /// ответ `{url: "https://fd.oneme.ru/getfile?..."}`. Future getFileUrl({ required String messageId, required int chatId, @@ -1836,7 +1689,7 @@ class MessagesModule { ); } - ChatsModule.applyContactUpdate(contactId); + chats.applyContactUpdate(contactId); return fullName; } } @@ -1898,7 +1751,7 @@ class MessagesModule { ContactCache.putOptions(id, rawOpts.whereType().toSet()); } - ChatsModule.applyContactUpdate(id); + chats.applyContactUpdate(id); resolvedAny = true; } diff --git a/lib/backend/modules/outbox.dart b/lib/backend/modules/outbox.dart index 6e88847..ea2712e 100644 --- a/lib/backend/modules/outbox.dart +++ b/lib/backend/modules/outbox.dart @@ -2,6 +2,7 @@ import 'dart:async'; import '../../core/storage/app_database.dart'; import '../../core/storage/token_storage.dart'; +import '../../core/utils/logger.dart'; import '../api.dart'; import 'chats.dart'; import 'messages.dart'; @@ -42,11 +43,20 @@ class OutboxService { if (api.state != SessionState.online) break; final pending = CachedMessage.fromDbRow(row); final text = pending.text; - if (text == null || text.isEmpty || pending.payload != null) continue; + if (text == null || text.isEmpty) continue; + + final payload = pending.payload; + final replyToMessageId = _replyIdFromPayload(payload); + final elements = _elementsFromPayload(payload); try { - final actualId = - await messages.sendMessage(accountId, pending.chatId, text); + final actualId = await messages.sendMessage( + accountId, + pending.chatId, + text, + replyToMessageId: replyToMessageId, + elements: elements, + ); final sent = CachedMessage( id: actualId.isNotEmpty ? actualId : pending.id, accountId: accountId, @@ -55,28 +65,63 @@ class OutboxService { text: text, time: pending.time, status: 'sent', + payload: payload, ); await AppDatabase.saveMessages([sent.toDbRow()]); if (sent.id != pending.id) { await AppDatabase.deleteMessage( - accountId, pending.chatId, pending.id); + accountId, + pending.chatId, + pending.id, + ); } - ChatsModule.emitMessageSent(pending.chatId, pending.id, sent); - await ChatsModule.applyOutgoing( + chats.emitMessageSent(pending.chatId, pending.id, sent); + await chats.applyOutgoing( accountId, pending.chatId, messageId: sent.id, time: sent.time, text: text, status: 'sent', + elements: elements.isEmpty ? null : elements, ); - } catch (_) { - break; + } catch (e) { + logger.w('Outbox: отправка ${pending.id} не удалась: $e'); + continue; } } - } catch (_) { + } catch (e) { + logger.e('Outbox flush: $e'); } finally { _flushing = false; } } + + int? _replyIdFromPayload(Map? payload) { + if (payload == null) return null; + final link = payload['link']; + if (link is! Map) return null; + if ((link['type'] as String?)?.toUpperCase() != 'REPLY') return null; + final msg = link['message']; + if (msg is Map) { + final id = msg['id']; + if (id is int) return id; + if (id != null) return int.tryParse(id.toString()); + } + final mid = link['messageId']; + if (mid is int) return mid; + if (mid != null) return int.tryParse(mid.toString()); + return null; + } + + List> _elementsFromPayload( + Map? payload, + ) { + final raw = payload?['elements']; + if (raw is! List) return const []; + return raw + .whereType() + .map((e) => Map.from(e)) + .toList(); + } } diff --git a/lib/backend/modules/polls.dart b/lib/backend/modules/polls.dart index eecf427..3abc8cb 100644 --- a/lib/backend/modules/polls.dart +++ b/lib/backend/modules/polls.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; +import '../../core/utils/logger.dart'; import '../../models/poll.dart'; class PollsModule extends ChangeNotifier { @@ -52,8 +53,8 @@ class PollsModule extends ChangeNotifier { } } if (changed) notifyListeners(); - } catch (_) { - // тихо игнорируем — опрос просто не отобразится + } catch (e) { + logger.w('PollsModule.fetch: pollId=$pollId chatId=$chatId $e'); } finally { _inFlight.remove(pollId); } @@ -84,7 +85,8 @@ class PollsModule extends ChangeNotifier { await fetch(chatId, messageId, pollId, force: true); } return true; - } catch (_) { + } catch (e) { + logger.w('PollsModule.vote: pollId=$pollId chatId=$chatId $e'); return false; } } diff --git a/lib/backend/modules/self_check.dart b/lib/backend/modules/self_check.dart index 96cb050..61bf606 100644 --- a/lib/backend/modules/self_check.dart +++ b/lib/backend/modules/self_check.dart @@ -26,6 +26,17 @@ class SelfCheckService { void checkNow() => unawaited(_check()); + void pause() { + _timer?.cancel(); + _timer = null; + } + + void resume() { + if (_api == null || _timer != null) return; + _timer = Timer.periodic(interval, (_) => unawaited(_check())); + checkNow(); + } + Future _check() async { final api = _api; if (api == null || api.state != SessionState.online) return; diff --git a/lib/backend/modules/stickers.dart b/lib/backend/modules/stickers.dart index 673cda5..80f403b 100644 --- a/lib/backend/modules/stickers.dart +++ b/lib/backend/modules/stickers.dart @@ -47,12 +47,12 @@ class StickersModule { Future _loadFavorites() async { final favIds = []; - final favResp = await _api.sendRequest(Opcode.assetsUpdate, { + final fav = await _api.sendRequestMap(Opcode.assetsUpdate, { 'type': 'FAVORITE_STICKER', 'sync': 0, }); - if (favResp.isOk && favResp.payload is Map) { - final sections = favResp.payload['sections']; + if (fav != null) { + final sections = fav['sections']; if (sections is List) { for (final s in sections) { if (s is Map && s['id'] == 'FAVORITE_STICKER_SETS') { @@ -68,12 +68,12 @@ class StickersModule { final newSetIds = []; int marker = 0; - final stickerResp = await _api.sendRequest(Opcode.assetsUpdate, { + final stickerData = await _api.sendRequestMap(Opcode.assetsUpdate, { 'type': 'STICKER', 'sync': 0, }); - if (stickerResp.isOk && stickerResp.payload is Map) { - final sections = stickerResp.payload['sections']; + if (stickerData != null) { + final sections = stickerData['sections']; if (sections is List) { for (final s in sections) { if (s is! Map) continue; @@ -91,16 +91,16 @@ class StickersModule { var guard = 0; while (marker != 0 && guard < 50) { guard++; - final page = await _api.sendRequest(Opcode.assetsGet, { + final page = await _api.sendRequestMap(Opcode.assetsGet, { 'sectionId': 'NEW_STICKER_SETS', 'from': marker, 'count': 100, }); - if (!page.isOk || page.payload is! Map) break; + if (page == null) break; final before = newSetIds.length; - _appendIntList(newSetIds, page.payload['stickerSets']); + _appendIntList(newSetIds, page['stickerSets']); if (newSetIds.length == before) break; - final m = page.payload['marker']; + final m = page['marker']; marker = m is int ? m : 0; } @@ -112,47 +112,53 @@ class StickersModule { if (seen.add(id)) ordered.add(id); } _orderedSetIds = ordered; - logger.i('Стикеры: ${ordered.length} паков, ${_recentStickerIds.length} недавних'); + logger.i( + 'Стикеры: ${ordered.length} паков, ${_recentStickerIds.length} недавних', + ); await _ensureSetMetas(ordered); } - Future _ensureSetMetas(List ids) async { - final missing = ids.where((id) => !_sets.containsKey(id)).toList(); + Future _fetchAndCache({ + required String type, + required List ids, + required String listKey, + required T Function(Map) fromMap, + required Map cache, + }) async { + final missing = ids.where((id) => !cache.containsKey(id)).toList(); for (final batch in _chunk(missing, 100)) { - final resp = await _api.sendRequest(Opcode.assetsGetByIds, { - 'type': 'STICKER_SET', + final map = await _api.sendRequestMap(Opcode.assetsGetByIds, { + 'type': type, 'ids': batch, }); - if (!resp.isOk || resp.payload is! Map) continue; - final list = resp.payload['stickerSets']; + if (map == null) continue; + final list = map[listKey]; if (list is! List) continue; for (final e in list) { if (e is Map && e['id'] is int) { - final set = StickerSet.fromMap(e); - _sets[set.id] = set; + cache[e['id'] as int] = fromMap(e); } } } } + Future _ensureSetMetas(List ids) => _fetchAndCache( + type: 'STICKER_SET', + ids: ids, + listKey: 'stickerSets', + fromMap: StickerSet.fromMap, + cache: _sets, + ); + Future> ensureStickers(List stickerIds) async { - final missing = stickerIds.where((id) => !_stickers.containsKey(id)).toList(); - for (final batch in _chunk(missing, 100)) { - final resp = await _api.sendRequest(Opcode.assetsGetByIds, { - 'type': 'STICKER', - 'ids': batch, - }); - if (!resp.isOk || resp.payload is! Map) continue; - final list = resp.payload['stickers']; - if (list is! List) continue; - for (final e in list) { - if (e is Map && e['id'] is int) { - final item = StickerItem.fromMap(e); - _stickers[item.id] = item; - } - } - } + await _fetchAndCache( + type: 'STICKER', + ids: stickerIds, + listKey: 'stickers', + fromMap: StickerItem.fromMap, + cache: _stickers, + ); return stickerIds .map((id) => _stickers[id]) .whereType() @@ -167,9 +173,9 @@ class StickersModule { void cacheSet(StickerSet set) => _sets[set.id] = set; Future resolveSetByLink(String link) async { - final resp = await _api.sendRequest(Opcode.linkInfo, {'link': link}); - if (!resp.isOk || resp.payload is! Map) return null; - final raw = resp.payload['stickerSet']; + final map = await _api.sendRequestMap(Opcode.linkInfo, {'link': link}); + if (map == null) return null; + final raw = map['stickerSet']; if (raw is! Map || raw['id'] is! int) return null; final set = StickerSet.fromMap(raw); _sets[set.id] = set; @@ -182,21 +188,21 @@ class StickersModule { } Future favoriteSet(int setId) async { - final resp = await _api.sendRequest(Opcode.assetsAdd, { + final map = await _api.sendRequestMap(Opcode.assetsAdd, { 'type': 'FAVORITE_STICKER_SET', 'id': setId, }); - final ok = resp.isOk && resp.payload is Map && resp.payload['success'] == true; + final ok = map != null && map['success'] == true; if (ok) _markFavorite(setId, true); return ok; } Future unfavoriteSet(int setId) async { - final resp = await _api.sendRequest(Opcode.assetsRemove, { + final map = await _api.sendRequestMap(Opcode.assetsRemove, { 'type': 'FAVORITE_STICKER_SET', 'ids': [setId], }); - final ok = resp.isOk && resp.payload is Map && resp.payload['success'] == true; + final ok = map != null && map['success'] == true; if (ok) _markFavorite(setId, false); return ok; } diff --git a/lib/core/cache/info_cache.dart b/lib/core/cache/info_cache.dart index 74e1ae0..52eb7b4 100644 --- a/lib/core/cache/info_cache.dart +++ b/lib/core/cache/info_cache.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import '../../backend/api.dart'; +import '../../models/chat_info.dart'; +import '../../models/contact_info.dart'; import '../protocol/opcode_map.dart'; Api? _api; @@ -94,20 +96,20 @@ class InfoCache { } class ContactInfoFetch { - static final _cache = InfoCache>( + static final _cache = InfoCache( ttl: const Duration(minutes: 5), fetcher: _fetch, ); - static Future?> get(int id, {bool forceRefresh = false}) => + static Future get(int id, {bool forceRefresh = false}) => _cache.get(id, forceRefresh: forceRefresh); - static Map? peek(int id) => _cache.peek(id); + static ContactInfo? peek(int id) => _cache.peek(id); static void invalidate(int id) => _cache.invalidate(id); static void clear() => _cache.clear(); - static Future?> _fetch(int id) async { + static Future _fetch(int id) async { final api = _api; if (api == null || api.state != SessionState.online) return null; final resp = await api.sendRequest(Opcode.contactInfo, { @@ -119,7 +121,7 @@ class ContactInfoFetch { if (contacts is! List || contacts.isEmpty) return null; final first = contacts.first; if (first is! Map) return null; - return Map.from(first); + return ContactInfo.fromMap(Map.from(first)); } } @@ -129,8 +131,10 @@ class PresenceFetch { fetcher: _fetch, ); - static Future?> get(int id, {bool forceRefresh = false}) => - _cache.get(id, forceRefresh: forceRefresh); + static Future?> get( + int id, { + bool forceRefresh = false, + }) => _cache.get(id, forceRefresh: forceRefresh); static Map? peek(int id) => _cache.peek(id); @@ -206,7 +210,9 @@ class PresenceFetch { return result; } - static Future>> _fetchBatch(List ids) async { + static Future>> _fetchBatch( + List ids, + ) async { final api = _api; if (api == null || api.state != SessionState.online || ids.isEmpty) { return const {}; @@ -230,20 +236,20 @@ class PresenceFetch { } class ChatInfoFetch { - static final _cache = InfoCache>( + static final _cache = InfoCache( ttl: const Duration(minutes: 5), fetcher: _fetch, ); - static Future?> get(int id, {bool forceRefresh = false}) => + static Future get(int id, {bool forceRefresh = false}) => _cache.get(id, forceRefresh: forceRefresh); - static Map? peek(int id) => _cache.peek(id); + static ChatInfo? peek(int id) => _cache.peek(id); static void invalidate(int id) => _cache.invalidate(id); static void clear() => _cache.clear(); - static Future?> _fetch(int id) async { + static Future _fetch(int id) async { final api = _api; if (api == null || api.state != SessionState.online) return null; final resp = await api.sendRequest(Opcode.chatInfo, { @@ -255,6 +261,6 @@ class ChatInfoFetch { if (chats is! List || chats.isEmpty) return null; final first = chats.first; if (first is! Map) return null; - return Map.from(first); + return ChatInfo.fromMap(Map.from(first)); } } diff --git a/lib/core/cache/message_session_cache.dart b/lib/core/cache/message_session_cache.dart index e9d043c..68867e9 100644 --- a/lib/core/cache/message_session_cache.dart +++ b/lib/core/cache/message_session_cache.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import '../../backend/modules/messages.dart'; class CachedChatMessages { @@ -8,12 +10,20 @@ class CachedChatMessages { } class MessageSessionCache { - static final Map _store = {}; + static const int _capacity = 24; + + static final LinkedHashMap _store = + LinkedHashMap(); static String _key(int accountId, int chatId) => '$accountId:$chatId'; - static CachedChatMessages? get(int accountId, int chatId) => - _store[_key(accountId, chatId)]; + static CachedChatMessages? get(int accountId, int chatId) { + final key = _key(accountId, chatId); + final entry = _store.remove(key); + if (entry == null) return null; + _store[key] = entry; + return entry; + } static void save( int accountId, @@ -22,10 +32,15 @@ class MessageSessionCache { required bool reachedStart, }) { if (messages.isEmpty) return; - _store[_key(accountId, chatId)] = CachedChatMessages( + final key = _key(accountId, chatId); + _store.remove(key); + _store[key] = CachedChatMessages( List.of(messages), reachedStart, ); + while (_store.length > _capacity) { + _store.remove(_store.keys.first); + } } static void remove(int accountId, int chatId) => diff --git a/lib/core/calls/call_bridge.dart b/lib/core/calls/call_bridge.dart index b139b3a..9de9731 100644 --- a/lib/core/calls/call_bridge.dart +++ b/lib/core/calls/call_bridge.dart @@ -4,6 +4,7 @@ import 'dart:io' show Platform; import 'package:flutter/services.dart'; +import '../utils/logger.dart'; import 'call_controller.dart'; class CallBridge { @@ -26,14 +27,19 @@ class CallBridge { void init() { if (_started || !_android) return; _started = true; - _events.receiveBroadcastStream().listen(_handle, onError: (_) {}); + _events.receiveBroadcastStream().listen( + _handle, + onError: (e) => logger.w('CallBridge.init: events stream error: $e'), + ); } Future checkInitialCall() async { if (!_android) return; try { _handle(await _method.invokeMethod('consumeInitialCall')); - } catch (_) {} + } catch (e) { + logger.w('CallBridge.checkInitialCall: $e'); + } } void _handle(Object? event) { @@ -52,7 +58,8 @@ class CallBridge { Object? decoded; try { decoded = jsonDecode(dataStr); - } catch (_) { + } catch (e) { + logger.w('CallBridge._handle: action=$action jsonDecode failed: $e'); return; } if (decoded is! Map) return; @@ -66,28 +73,35 @@ class CallBridge { if (!_android) return; try { await _method.invokeMethod('notifyAccepted', {'caller': caller}); - } catch (_) {} + } catch (e) { + logger.w('CallBridge.notifyAccepted: caller=$caller $e'); + } } Future notifyEnded() async { if (!_android) return; try { await _method.invokeMethod('notifyEnded'); - } catch (_) {} + } catch (e) { + logger.w('CallBridge.notifyEnded: $e'); + } } Future cancelIncoming() async { if (!_android) return; try { await _method.invokeMethod('cancelIncoming'); - } catch (_) {} + } catch (e) { + logger.w('CallBridge.cancelIncoming: $e'); + } } Future canUseFullScreenIntent() async { if (!_android) return true; try { return await _method.invokeMethod('canUseFullScreenIntent') ?? true; - } catch (_) { + } catch (e) { + logger.w('CallBridge.canUseFullScreenIntent: $e'); return true; } } @@ -96,6 +110,8 @@ class CallBridge { if (!_android) return; try { await _method.invokeMethod('openFullScreenIntentSettings'); - } catch (_) {} + } catch (e) { + logger.w('CallBridge.openFullScreenIntentSettings: $e'); + } } } diff --git a/lib/core/calls/call_controller.dart b/lib/core/calls/call_controller.dart index 6eebfea..cbe2e37 100644 --- a/lib/core/calls/call_controller.dart +++ b/lib/core/calls/call_controller.dart @@ -4,6 +4,7 @@ import '../../backend/api.dart'; import '../../backend/modules/calls.dart'; import '../protocol/opcode_map.dart'; import '../protocol/packet.dart'; +import '../utils/parse.dart'; import 'call_bridge.dart'; import 'call_session.dart'; import 'conversation_params.dart'; @@ -83,14 +84,16 @@ class CallController { final params = ConversationParams.decode(vcp); if (params == null) return; - _emitIncoming(IncomingCall( - conversationId: conversationId, - callerId: callerId, - isVideo: payload['type'] == 'VIDEO' || params.isVideo, - params: params, - country: payload['country'] as String?, - isContact: payload['isContact'] as bool?, - )); + _emitIncoming( + IncomingCall( + conversationId: conversationId, + callerId: callerId, + isVideo: payload['type'] == 'VIDEO' || params.isVideo, + params: params, + country: payload['country'] as String?, + isContact: payload['isContact'] as bool?, + ), + ); } void injectFromNative(Map data, {bool autoAccept = false}) { @@ -100,11 +103,10 @@ class CallController { final params = ConversationParams.decode(vcp); if (params == null) return; - final conversationId = - (data['conversationId'] ?? data['vcId'])?.toString(); + final conversationId = (data['conversationId'] ?? data['vcId'])?.toString(); if (conversationId == null || conversationId.isEmpty) return; - final callerId = _asInt(data['callerId'] ?? data['suid']); + final callerId = parseIntOrNull(data['callerId'] ?? data['suid']); if (callerId == null) return; final type = (data['type'] ?? data['callType'])?.toString(); @@ -139,17 +141,16 @@ class CallController { _canceled.add(null); } - static int? _asInt(Object? v) { - if (v is int) return v; - if (v is num) return v.toInt(); - if (v is String) return int.tryParse(v); - return null; - } - - Future startOutgoing(int calleeId, {bool isVideo = false}) async { + Future startOutgoing( + int calleeId, { + bool isVideo = false, + }) async { if (_active != null) throw StateError('уже идёт звонок'); final out = await _calls!.initiateCall(calleeId, isVideo: isVideo); - final config = Ws2Config.fromEndpoint(out.endpoint, userId: out.callsUserId); + final config = Ws2Config.fromEndpoint( + out.endpoint, + userId: out.callsUserId, + ); final session = CallSession(ws2Config: config, role: CallRole.caller); _bind(session); await session.start(); @@ -163,8 +164,10 @@ class CallController { Future joinByLink(String token, {bool isVideo = false}) async { if (_active != null) throw StateError('уже идёт звонок'); final params = await _calls!.joinByLink(token, isVideo: isVideo); - final config = - Ws2Config.fromEndpoint(params.endpoint, userId: params.callsUserId); + final config = Ws2Config.fromEndpoint( + params.endpoint, + userId: params.callsUserId, + ); final session = CallSession(ws2Config: config, role: CallRole.joiner); _bind(session); await session.start(); diff --git a/lib/core/calls/call_session.dart b/lib/core/calls/call_session.dart index ca5da90..2c91f91 100644 --- a/lib/core/calls/call_session.dart +++ b/lib/core/calls/call_session.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart' import 'package:flutter_webrtc/flutter_webrtc.dart'; import '../utils/logger.dart'; +import '../utils/parse.dart'; import 'call_info.dart'; import 'conversation_params.dart'; import 'ws2_signaling.dart'; @@ -50,11 +51,7 @@ class CallSession { final ConversationParams? params; final CallRole role; - CallSession({ - required this.ws2Config, - required this.role, - this.params, - }); + CallSession({required this.ws2Config, required this.role, this.params}); Ws2Signaling? _signaling; RTCPeerConnection? _pc; @@ -151,8 +148,9 @@ class CallSession { CallSessionState get currentState => _current; - int get elapsedSeconds => - _activeSince == null ? 0 : DateTime.now().difference(_activeSince!).inSeconds; + int get elapsedSeconds => _activeSince == null + ? 0 + : DateTime.now().difference(_activeSince!).inSeconds; void _setState(CallSessionState s) { if (_current == s || _current == CallSessionState.ended) return; @@ -174,7 +172,9 @@ class CallSession { signaling.done.then((_) => _end()); await signaling.connect(); _levelTimer = Timer.periodic( - const Duration(milliseconds: 300), (_) => unawaited(_sampleLevels())); + const Duration(milliseconds: 300), + (_) => unawaited(_sampleLevels()), + ); } Future _sampleLevels() async { @@ -341,7 +341,8 @@ class CallSession { } void _onHungup(Map msg) { - final raw = msg['participantId'] ?? + final raw = + msg['participantId'] ?? (msg['participant'] is Map ? (msg['participant'] as Map)['id'] : null); if (raw is! int) return; if (raw == ws2Config.userId) { @@ -353,7 +354,8 @@ class CallSession { void _onSessionState(Map msg) { logger.t( - '[call][sfu] session-state id=${msg['participantId']} connected=${msg['connected']}'); + '[call][sfu] session-state id=${msg['participantId']} connected=${msg['connected']}', + ); } void _resolveParticipants(Object? conversation) { @@ -413,10 +415,7 @@ class CallSession { int? _externalId(Object? ext) { if (ext is! Map) return null; - final v = ext['id']; - if (v is int) return v; - if (v is String) return int.tryParse(v); - return null; + return parseIntOrNull(ext['id']); } bool? _handFrom(Object? participantState) { @@ -496,7 +495,7 @@ class CallSession { _topology = (conversation is Map ? conversation['topology']?.toString() : null) ?? - _topology; + _topology; logger.t('[call] connection role=$role peer=$_peerId topology=$_topology'); if (_topology == 'SERVER') { @@ -515,6 +514,8 @@ class CallSession { await _setupKometProbe(pc); + if (_isDesktop) await _preferVp8Codecs(pc); + if (role == CallRole.caller) { _setState(CallSessionState.ringing); await _createAndSendOffer(); @@ -597,7 +598,9 @@ class CallSession { if (frame != null && frame['t'] == 'chat') { final body = frame['text']; if (body is String && body.isNotEmpty) { - _addChat(CallChatMessage(text: body, mine: false, time: DateTime.now())); + _addChat( + CallChatMessage(text: body, mine: false, time: DateTime.now()), + ); } return; } @@ -634,7 +637,9 @@ class CallSession { final channel = _probeChannel; if (body.isEmpty || channel == null) return; try { - channel.send(RTCDataChannelMessage(jsonEncode({'t': 'chat', 'text': body}))); + channel.send( + RTCDataChannelMessage(jsonEncode({'t': 'chat', 'text': body})), + ); } catch (_) { return; } @@ -731,8 +736,10 @@ class CallSession { final local = await pc.getLocalDescription(); final answerSdp = local?.sdp ?? answer.sdp ?? ''; final ssrcs = _extractSsrcs(answerSdp); - logger.t('[call][sfu] answer: ${_mLines(answerSdp)} m-lines, ' - 'ssrcs=${ssrcs.length}'); + logger.t( + '[call][sfu] answer: ${_mLines(answerSdp)} m-lines, ' + 'ssrcs=${ssrcs.length}', + ); await _signaling?.acceptProducer( description: answerSdp, @@ -749,13 +756,20 @@ class CallSession { await _signaling?.changeSimulcast( mediaSource: 'CAMERA', layers: const [ - {'rid': 'h', 'width': 1280, 'height': 720, 'fps': 30, 'bitrateKbps': 2000}, + { + 'rid': 'h', + 'width': 1280, + 'height': 720, + 'fps': 30, + 'bitrateKbps': 2000, + }, ], ); } catch (_) {} } - int _mLines(String sdp) => RegExp(r'^m=', multiLine: true).allMatches(sdp).length; + int _mLines(String sdp) => + RegExp(r'^m=', multiLine: true).allMatches(sdp).length; List _extractSsrcs(String sdp) { final set = {}; @@ -766,8 +780,7 @@ class CallSession { return set.toList(); } - Future _waitIceGathering( - RTCPeerConnection pc, Duration timeout) async { + Future _waitIceGathering(RTCPeerConnection pc, Duration timeout) async { if (pc.iceGatheringState == RTCIceGatheringState.RTCIceGatheringStateComplete) { return; @@ -809,7 +822,8 @@ class CallSession { Future _onRemoteTrack(RTCTrackEvent event) async { logger.t( - '[call] remote track: ${event.track.kind} streams=${event.streams.length}'); + '[call] remote track: ${event.track.kind} streams=${event.streams.length}', + ); if (event.streams.isNotEmpty) { _remoteStreamRef = event.streams.first; _remoteStream.add(event.streams.first); @@ -853,8 +867,7 @@ class CallSession { if (pc == null || peerId == null) return; final offer = await pc.createOffer({}); - final raw = offer.sdp ?? ''; - final sdp = _isDesktop ? _forceVp8(raw) : raw; + final sdp = offer.sdp ?? ''; await pc.setLocalDescription(RTCSessionDescription(sdp, offer.type)); logger.t('[call] our offer video: ${_videoDir(sdp)}'); await _signaling?.transmitSdp( @@ -871,59 +884,25 @@ class CallSession { defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.macOS; - String _forceVp8(String sdp) { - final lines = sdp.split('\r\n'); - var mIdx = -1; - for (var i = 0; i < lines.length; i++) { - if (lines[i].startsWith('m=video')) { - mIdx = i; - break; + Future _preferVp8Codecs(RTCPeerConnection pc) async { + try { + final caps = await getRtpSenderCapabilities('video'); + final all = caps.codecs ?? const []; + final hasVp8 = all.any((c) => c.mimeType.toLowerCase() == 'video/vp8'); + if (!hasVp8) return; + final preferred = all.where((c) { + final m = c.mimeType.toLowerCase(); + return m == 'video/vp8' || m == 'video/rtx'; + }).toList(); + if (preferred.isEmpty) return; + for (final t in await pc.getTransceivers()) { + try { + await t.setCodecPreferences(preferred); + } catch (_) {} } + } catch (e) { + logger.t('[call] setCodecPreferences недоступен: $e'); } - if (mIdx == -1) return sdp; - - String? vp8; - for (final l in lines) { - final m = RegExp(r'^a=rtpmap:(\d+) VP8/90000').firstMatch(l); - if (m != null) { - vp8 = m.group(1); - break; - } - } - if (vp8 == null) return sdp; - - String? rtx; - for (final l in lines) { - final m = RegExp('^a=fmtp:(\\d+) apt=$vp8\$').firstMatch(l); - if (m != null) { - rtx = m.group(1); - break; - } - } - - final keep = {vp8, ?rtx}; - final parts = lines[mIdx].split(' '); - if (parts.length <= 3) return sdp; - lines[mIdx] = [...parts.sublist(0, 3), ...keep].join(' '); - - var end = lines.length; - for (var i = mIdx + 1; i < lines.length; i++) { - if (lines[i].startsWith('m=')) { - end = i; - break; - } - } - - final ptLine = RegExp(r'^a=(?:rtpmap|fmtp|rtcp-fb):(\d+)'); - final result = []; - for (var i = 0; i < lines.length; i++) { - if (i > mIdx && i < end) { - final m = ptLine.firstMatch(lines[i]); - if (m != null && !keep.contains(m.group(1))) continue; - } - result.add(lines[i]); - } - return result.join('\r\n'); } Future _onTransmittedData(Map msg) async { @@ -1038,7 +1017,8 @@ class CallSession { Future _applyMuted(bool muted, {bool announce = false}) async { _muted = muted; - for (final track in _localStream?.getAudioTracks() ?? []) { + for (final track + in _localStream?.getAudioTracks() ?? []) { track.enabled = !muted; } _notifyInfo(); @@ -1066,10 +1046,14 @@ class CallSession { MediaStream stream; try { stream = screen - ? await navigator.mediaDevices - .getDisplayMedia({'video': true, 'audio': false}) - : await navigator.mediaDevices - .getUserMedia({'video': true, 'audio': false}); + ? await navigator.mediaDevices.getDisplayMedia({ + 'video': true, + 'audio': false, + }) + : await navigator.mediaDevices.getUserMedia({ + 'video': true, + 'audio': false, + }); } catch (e) { logger.t('[call] video capture failed: $e'); return; @@ -1301,7 +1285,9 @@ class CallSession { _peerType = responderTypes.first.toString(); } final deviceIdxs = p['responderDeviceIdxs']; - if (deviceIdxs is List && deviceIdxs.isNotEmpty && deviceIdxs.first is int) { + if (deviceIdxs is List && + deviceIdxs.isNotEmpty && + deviceIdxs.first is int) { _peerDeviceIdx = deviceIdxs.first as int; } break; diff --git a/lib/core/config/app_amoled.dart b/lib/core/config/app_amoled.dart index 71def4b..e4a8c77 100644 --- a/lib/core/config/app_amoled.dart +++ b/lib/core/config/app_amoled.dart @@ -1,18 +1,22 @@ import 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; + +import 'persisted_setting.dart'; class AppAmoled { static const prefKey = 'app_amoled'; - static final ValueNotifier current = ValueNotifier(false); - static Future load() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(prefKey) ?? false; - } + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: false, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); - static Future save(bool value) async { - current.value = value; - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(prefKey, value); - } + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); } diff --git a/lib/core/config/app_bubble_behavior.dart b/lib/core/config/app_bubble_behavior.dart index 9ea84cf..e062e55 100644 --- a/lib/core/config/app_bubble_behavior.dart +++ b/lib/core/config/app_bubble_behavior.dart @@ -1,30 +1,27 @@ import 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; + +import 'persisted_setting.dart'; enum BubbleBehavior { mutable, immutable } class AppBubbleBehavior { static const prefKey = 'app_bubble_behavior'; - static final ValueNotifier current = ValueNotifier( - BubbleBehavior.mutable, + + static final _setting = PersistedEnum( + prefKey: prefKey, + defaultValue: BubbleBehavior.mutable, + encode: (value) => value.name, + decode: _parse, ); - static Future load() async { - final prefs = await SharedPreferences.getInstance(); - final val = prefs.getString(prefKey); - return _parse(val); - } + static ValueNotifier get current => _setting.current; - static Future save(BubbleBehavior behavior) async { - current.value = behavior; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(prefKey, behavior.name); - } + static Future load() => _setting.load(); - static BubbleBehavior _parse(String? val) { - if (val == BubbleBehavior.immutable.name) return BubbleBehavior.immutable; - return BubbleBehavior.mutable; - } + static Future save(BubbleBehavior behavior) => _setting.save(behavior); + + static BubbleBehavior _parse(String? val) => + enumFromName(BubbleBehavior.values, val, BubbleBehavior.mutable); static String label(BubbleBehavior behavior) { switch (behavior) { diff --git a/lib/core/config/app_bubble_shape.dart b/lib/core/config/app_bubble_shape.dart index 9a29c58..df78a76 100644 --- a/lib/core/config/app_bubble_shape.dart +++ b/lib/core/config/app_bubble_shape.dart @@ -1,30 +1,27 @@ import 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; + +import 'persisted_setting.dart'; enum BubbleStyle { mobile, desktop } class AppBubbleShape { static const prefKey = 'app_bubble_shape'; - static final ValueNotifier current = ValueNotifier( - BubbleStyle.mobile, + + static final _setting = PersistedEnum( + prefKey: prefKey, + defaultValue: BubbleStyle.mobile, + encode: (value) => value.name, + decode: _parse, ); - static Future load() async { - final prefs = await SharedPreferences.getInstance(); - final val = prefs.getString(prefKey); - return _parse(val); - } + static ValueNotifier get current => _setting.current; - static Future save(BubbleStyle style) async { - current.value = style; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(prefKey, style.name); - } + static Future load() => _setting.load(); - static BubbleStyle _parse(String? val) { - if (val == BubbleStyle.desktop.name) return BubbleStyle.desktop; - return BubbleStyle.mobile; - } + static Future save(BubbleStyle style) => _setting.save(style); + + static BubbleStyle _parse(String? val) => + enumFromName(BubbleStyle.values, val, BubbleStyle.mobile); static String label(BubbleStyle style) { switch (style) { diff --git a/lib/core/config/app_cache_extent.dart b/lib/core/config/app_cache_extent.dart index 5aeef36..398e361 100644 --- a/lib/core/config/app_cache_extent.dart +++ b/lib/core/config/app_cache_extent.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; + +import 'persisted_setting.dart'; class AppCacheExtent { static const prefKey = 'app_cache_extent'; @@ -9,21 +10,21 @@ class AppCacheExtent { static const double lowWarnThreshold = 2500; static const double highWarnThreshold = 7000; - static final ValueNotifier current = ValueNotifier(defaultValue); + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getDouble(key), + write: (prefs, key, value) async { + await prefs.setDouble(key, value); + }, + sanitize: clamp, + ); - static Future load() async { - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getDouble(prefKey); - if (raw == null) return defaultValue; - return clamp(raw); - } + static ValueNotifier get current => _setting.current; - static Future save(double value) async { - final clamped = clamp(value); - current.value = clamped; - final prefs = await SharedPreferences.getInstance(); - await prefs.setDouble(prefKey, clamped); - } + static Future load() => _setting.load(); + + static Future save(double value) => _setting.save(value); static double clamp(double v) { if (v < min) return min; diff --git a/lib/core/config/app_chat_chrome.dart b/lib/core/config/app_chat_chrome.dart index 36fc54a..661ee95 100644 --- a/lib/core/config/app_chat_chrome.dart +++ b/lib/core/config/app_chat_chrome.dart @@ -1,43 +1,27 @@ import 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; + +import 'persisted_setting.dart'; enum ChatChromeStyle { color, blur, none } class AppChatChrome { static const prefKey = 'app_chat_chrome'; - static final ValueNotifier current = - ValueNotifier(ChatChromeStyle.none); - static ChatChromeStyle _parse(String? value) { - switch (value) { - case 'color': - return ChatChromeStyle.color; - case 'blur': - return ChatChromeStyle.blur; - default: - return ChatChromeStyle.none; - } - } + static final _setting = PersistedEnum( + prefKey: prefKey, + defaultValue: ChatChromeStyle.none, + encode: _encode, + decode: _parse, + ); - static String _encode(ChatChromeStyle value) { - switch (value) { - case ChatChromeStyle.color: - return 'color'; - case ChatChromeStyle.blur: - return 'blur'; - case ChatChromeStyle.none: - return 'none'; - } - } + static ValueNotifier get current => _setting.current; - static Future load() async { - final prefs = await SharedPreferences.getInstance(); - return _parse(prefs.getString(prefKey)); - } + static ChatChromeStyle _parse(String? value) => + enumFromName(ChatChromeStyle.values, value, ChatChromeStyle.none); - static Future save(ChatChromeStyle value) async { - current.value = value; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(prefKey, _encode(value)); - } + static String _encode(ChatChromeStyle value) => value.name; + + static Future load() => _setting.load(); + + static Future save(ChatChromeStyle value) => _setting.save(value); } diff --git a/lib/core/config/app_colors.dart b/lib/core/config/app_colors.dart new file mode 100644 index 0000000..6ee6bfe --- /dev/null +++ b/lib/core/config/app_colors.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +extension AppColorTokens on ColorScheme { + Color get mutedText => onSurfaceVariant.withValues(alpha: 0.6); +} + +const int kAvatarThumbSize = 144; + +const Color kReadReceiptBlue = Color(0xFF4FC3F7); +const Color kOnlineGreen = Color(0xFF34C759); +const Color kEditorAccent = Color(0xFF2F8FFF); diff --git a/lib/core/config/app_commands.dart b/lib/core/config/app_commands.dart index cb66576..42e8c37 100644 --- a/lib/core/config/app_commands.dart +++ b/lib/core/config/app_commands.dart @@ -1,20 +1,23 @@ import 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; + +import 'persisted_setting.dart'; class AppCommands { static const prefKey = 'dev_commands'; static const bool defaultValue = false; - static final ValueNotifier current = ValueNotifier(defaultValue); + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); - static Future load() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(prefKey) ?? defaultValue; - } + static ValueNotifier get current => _setting.current; - static Future save(bool value) async { - current.value = value; - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(prefKey, value); - } + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); } diff --git a/lib/core/config/app_digital_id_mode.dart b/lib/core/config/app_digital_id_mode.dart index 1b22a5a..5766809 100644 --- a/lib/core/config/app_digital_id_mode.dart +++ b/lib/core/config/app_digital_id_mode.dart @@ -1,18 +1,22 @@ import 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; + +import 'persisted_setting.dart'; class AppDigitalIdNative { static const prefKey = 'app_digital_id_native'; - static final ValueNotifier current = ValueNotifier(false); - static Future load() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(prefKey) ?? false; - } + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: false, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); - static Future save(bool value) async { - current.value = value; - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(prefKey, value); - } + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); } diff --git a/lib/core/config/app_fonts.dart b/lib/core/config/app_fonts.dart index 636ef54..8fe9b9d 100644 --- a/lib/core/config/app_fonts.dart +++ b/lib/core/config/app_fonts.dart @@ -5,11 +5,7 @@ class AppFont { final String label; final String? fontFamily; - const AppFont({ - required this.id, - required this.label, - this.fontFamily, - }); + const AppFont({required this.id, required this.label, this.fontFamily}); bool get isSystem => fontFamily == null; bool get isCustom => id.startsWith(AppFonts.customPrefix); diff --git a/lib/core/config/app_link_preview.dart b/lib/core/config/app_link_preview.dart index 924aad0..b2d104f 100644 --- a/lib/core/config/app_link_preview.dart +++ b/lib/core/config/app_link_preview.dart @@ -1,20 +1,23 @@ import 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; + +import 'persisted_setting.dart'; class AppLinkPreview { static const prefKey = 'dev_link_preview'; static const bool defaultValue = true; - static final ValueNotifier current = ValueNotifier(defaultValue); + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); - static Future load() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(prefKey) ?? defaultValue; - } + static ValueNotifier get current => _setting.current; - static Future save(bool value) async { - current.value = value; - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(prefKey, value); - } + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); } diff --git a/lib/core/config/app_media_cache.dart b/lib/core/config/app_media_cache.dart index 8ff5ee9..66c8450 100644 --- a/lib/core/config/app_media_cache.dart +++ b/lib/core/config/app_media_cache.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; + +import 'persisted_setting.dart'; class AppMediaCacheLimit { static const prefKey = 'media_cache_limit_bytes'; @@ -18,16 +19,18 @@ class AppMediaCacheLimit { unlimited, ]; - static final ValueNotifier current = ValueNotifier(defaultValue); + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getInt(key), + write: (prefs, key, value) async { + await prefs.setInt(key, value); + }, + ); - static Future load() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getInt(prefKey) ?? defaultValue; - } + static ValueNotifier get current => _setting.current; - static Future save(int value) async { - current.value = value; - final prefs = await SharedPreferences.getInstance(); - await prefs.setInt(prefKey, value); - } + static Future load() => _setting.load(); + + static Future save(int value) => _setting.save(value); } diff --git a/lib/core/config/app_message_actions_style.dart b/lib/core/config/app_message_actions_style.dart index 2b8c5ef..9375175 100644 --- a/lib/core/config/app_message_actions_style.dart +++ b/lib/core/config/app_message_actions_style.dart @@ -1,29 +1,27 @@ import 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; + +import 'persisted_setting.dart'; enum MessageActionsStyle { radial, list } class AppMessageActionsStyle { static const prefKey = 'app_message_actions_style'; - static final ValueNotifier current = ValueNotifier( - MessageActionsStyle.radial, + + static final _setting = PersistedEnum( + prefKey: prefKey, + defaultValue: MessageActionsStyle.radial, + encode: (value) => value.name, + decode: _parse, ); - static Future load() async { - final prefs = await SharedPreferences.getInstance(); - return _parse(prefs.getString(prefKey)); - } + static ValueNotifier get current => _setting.current; - static Future save(MessageActionsStyle style) async { - current.value = style; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(prefKey, style.name); - } + static Future load() => _setting.load(); - static MessageActionsStyle _parse(String? val) { - if (val == MessageActionsStyle.list.name) return MessageActionsStyle.list; - return MessageActionsStyle.radial; - } + static Future save(MessageActionsStyle style) => _setting.save(style); + + static MessageActionsStyle _parse(String? val) => + enumFromName(MessageActionsStyle.values, val, MessageActionsStyle.radial); static String label(MessageActionsStyle style) { switch (style) { diff --git a/lib/core/config/app_pill_gradient.dart b/lib/core/config/app_pill_gradient.dart index f31a1f5..ff5e98b 100644 --- a/lib/core/config/app_pill_gradient.dart +++ b/lib/core/config/app_pill_gradient.dart @@ -1,18 +1,22 @@ import 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; + +import 'persisted_setting.dart'; class AppPillGradient { static const prefKey = 'app_pill_gradient'; - static final ValueNotifier current = ValueNotifier(true); - static Future load() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(prefKey) ?? true; - } + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: true, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); - static Future save(bool value) async { - current.value = value; - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(prefKey, value); - } + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); } diff --git a/lib/core/config/app_pranks.dart b/lib/core/config/app_pranks.dart index 033a23a..36b6219 100644 --- a/lib/core/config/app_pranks.dart +++ b/lib/core/config/app_pranks.dart @@ -1,20 +1,23 @@ import 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; + +import 'persisted_setting.dart'; class AppPranks { static const prefKey = 'dev_pranks'; static const bool defaultValue = false; - static final ValueNotifier current = ValueNotifier(defaultValue); + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); - static Future load() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(prefKey) ?? defaultValue; - } + static ValueNotifier get current => _setting.current; - static Future save(bool value) async { - current.value = value; - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(prefKey, value); - } + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); } diff --git a/lib/core/config/app_show_extra_info.dart b/lib/core/config/app_show_extra_info.dart index 3f95d45..e1580d4 100644 --- a/lib/core/config/app_show_extra_info.dart +++ b/lib/core/config/app_show_extra_info.dart @@ -1,20 +1,23 @@ import 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; + +import 'persisted_setting.dart'; class AppShowExtraInfo { static const prefKey = 'dev_show_extra_info'; static const bool defaultValue = false; - static final ValueNotifier current = ValueNotifier(defaultValue); + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); - static Future load() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(prefKey) ?? defaultValue; - } + static ValueNotifier get current => _setting.current; - static Future save(bool value) async { - current.value = value; - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(prefKey, value); - } + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); } diff --git a/lib/core/config/app_stories.dart b/lib/core/config/app_stories.dart index 4f75a2d..e375584 100644 --- a/lib/core/config/app_stories.dart +++ b/lib/core/config/app_stories.dart @@ -1,20 +1,23 @@ import 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; + +import 'persisted_setting.dart'; class AppStories { static const prefKey = 'dev_stories'; static const bool defaultValue = false; - static final ValueNotifier current = ValueNotifier(defaultValue); + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); - static Future load() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(prefKey) ?? defaultValue; - } + static ValueNotifier get current => _setting.current; - static Future save(bool value) async { - current.value = value; - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(prefKey, value); - } + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); } diff --git a/lib/core/config/app_swipe_back_desktop.dart b/lib/core/config/app_swipe_back_desktop.dart index 9dffd1f..9dd338a 100644 --- a/lib/core/config/app_swipe_back_desktop.dart +++ b/lib/core/config/app_swipe_back_desktop.dart @@ -1,20 +1,23 @@ import 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; + +import 'persisted_setting.dart'; class AppSwipeBackDesktop { static const prefKey = 'dev_swipe_back_desktop'; static const bool defaultValue = false; - static final ValueNotifier current = ValueNotifier(defaultValue); + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); - static Future load() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(prefKey) ?? defaultValue; - } + static ValueNotifier get current => _setting.current; - static Future save(bool value) async { - current.value = value; - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(prefKey, value); - } + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); } diff --git a/lib/core/config/app_theme_mode.dart b/lib/core/config/app_theme_mode.dart index be141e1..3426a8a 100644 --- a/lib/core/config/app_theme_mode.dart +++ b/lib/core/config/app_theme_mode.dart @@ -1,37 +1,27 @@ import 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; + +import 'persisted_setting.dart'; enum AppThemeMode { system, light, dark, schedule } class AppThemeModeConfig { static const prefKey = 'app_theme_mode'; - static final ValueNotifier current = ValueNotifier( - AppThemeMode.system, + + static final _setting = PersistedEnum( + prefKey: prefKey, + defaultValue: AppThemeMode.system, + encode: (mode) => mode.name, + decode: _parse, ); - static Future load() async { - final prefs = await SharedPreferences.getInstance(); - return _parse(prefs.getString(prefKey)); - } + static ValueNotifier get current => _setting.current; - static Future save(AppThemeMode mode) async { - current.value = mode; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(prefKey, mode.name); - } + static Future load() => _setting.load(); - static AppThemeMode _parse(String? val) { - switch (val) { - case 'light': - return AppThemeMode.light; - case 'dark': - return AppThemeMode.dark; - case 'schedule': - return AppThemeMode.schedule; - default: - return AppThemeMode.system; - } - } + static Future save(AppThemeMode mode) => _setting.save(mode); + + static AppThemeMode _parse(String? val) => + enumFromName(AppThemeMode.values, val, AppThemeMode.system); static String label(AppThemeMode mode) { switch (mode) { diff --git a/lib/core/config/app_theme_schedule.dart b/lib/core/config/app_theme_schedule.dart index 3c62c7f..bcbff0d 100644 --- a/lib/core/config/app_theme_schedule.dart +++ b/lib/core/config/app_theme_schedule.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../utils/format.dart'; + class ThemeSchedule { final TimeOfDay darkStart; final TimeOfDay darkEnd; @@ -44,7 +46,9 @@ class AppThemeSchedule { static Future load() async { final prefs = await SharedPreferences.getInstance(); - return _parse(prefs.getString(prefKey)); + final value = _parse(prefs.getString(prefKey)); + current.value = value; + return value; } static Future save(ThemeSchedule schedule) async { @@ -56,8 +60,7 @@ class AppThemeSchedule { ); } - static String _fmt(TimeOfDay t) => - '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + static String _fmt(TimeOfDay t) => '${pad2(t.hour)}:${pad2(t.minute)}'; static ThemeSchedule _parse(String? val) { if (val == null) { diff --git a/lib/core/config/app_visual_style.dart b/lib/core/config/app_visual_style.dart index c82ff8a..ece7066 100644 --- a/lib/core/config/app_visual_style.dart +++ b/lib/core/config/app_visual_style.dart @@ -1,26 +1,27 @@ import 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; + +import 'persisted_setting.dart'; enum VisualStyle { materialYou, glossy } class AppVisualStyle { static const prefKey = 'app_visual_style'; - static final ValueNotifier current = - ValueNotifier(VisualStyle.materialYou); - static Future load() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getString(prefKey) == 'glossy' - ? VisualStyle.glossy - : VisualStyle.materialYou; - } + static final _setting = PersistedEnum( + prefKey: prefKey, + defaultValue: VisualStyle.materialYou, + encode: _encode, + decode: _parse, + ); - static Future save(VisualStyle value) async { - current.value = value; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString( - prefKey, - value == VisualStyle.glossy ? 'glossy' : 'materialYou', - ); - } + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(VisualStyle value) => _setting.save(value); + + static String _encode(VisualStyle value) => value.name; + + static VisualStyle _parse(String? val) => + enumFromName(VisualStyle.values, val, VisualStyle.materialYou); } diff --git a/lib/core/config/countries.dart b/lib/core/config/countries.dart index e4120c3..b8a3ed0 100644 --- a/lib/core/config/countries.dart +++ b/lib/core/config/countries.dart @@ -4,11 +4,13 @@ class CountryName { final String code; // ISO 3166-1 alpha-2, например "RU" final String en; final String ru; - final String phoneCode; // Код страны для звонков, например "+7" - final int phoneDigits; // Количество цифр номера после кода страны - final String phoneMask; // Маска абонентского номера, например "(###) ###-##-##" - final List phoneGroupSizes; // Размеры групп цифр, например [3, 3, 2, 2] - final List phoneGroupSeparators; // Разделители вокруг групп, например ["(", ") ", "-", "-", ""] + final String phoneCode; // Код страны для звонков, например "+7" + final int phoneDigits; // Количество цифр номера после кода страны + final String + phoneMask; // Маска абонентского номера, например "(###) ###-##-##" + final List phoneGroupSizes; // Размеры групп цифр, например [3, 3, 2, 2] + final List + phoneGroupSeparators; // Разделители вокруг групп, например ["(", ") ", "-", "-", ""] const CountryName({ required this.code, @@ -20,6 +22,10 @@ class CountryName { required this.phoneGroupSizes, required this.phoneGroupSeparators, }); + + String displayName(String languageCode) { + return languageCode == 'ru' ? ru : en; + } } /// Полный список стран (195 государств) с названием на русском и английском. @@ -52,11 +58,7 @@ Map? exampleCountryLookup(String code) { final country = countriesByCode[code.toUpperCase()]; if (country == null) return null; - return { - 'code': country.code, - 'ru': country.ru, - 'en': country.en, - }; + return {'code': country.code, 'ru': country.ru, 'en': country.en}; } List _buildCountries() { @@ -79,9 +81,19 @@ List _buildCountries() { final phoneDigits = item['phoneDigits'] as int; final phoneMask = item['phoneMask'] as String; final phoneGroupSizes = (item['phoneGroupSizes'] as List).cast(); - final phoneGroupSeparators = (item['phoneGroupSeparators'] as List).cast(); + final phoneGroupSeparators = (item['phoneGroupSeparators'] as List) + .cast(); - return CountryName(code: code, en: en, ru: ru, phoneCode: phoneCode, phoneDigits: phoneDigits, phoneMask: phoneMask, phoneGroupSizes: phoneGroupSizes, phoneGroupSeparators: phoneGroupSeparators); + return CountryName( + code: code, + en: en, + ru: ru, + phoneCode: phoneCode, + phoneDigits: phoneDigits, + phoneMask: phoneMask, + phoneGroupSizes: phoneGroupSizes, + phoneGroupSeparators: phoneGroupSeparators, + ); }).toList(); countries.sort((a, b) => a.en.compareTo(b.en)); diff --git a/lib/core/config/custom_font_service.dart b/lib/core/config/custom_font_service.dart index 151d31f..bc34bd2 100644 --- a/lib/core/config/custom_font_service.dart +++ b/lib/core/config/custom_font_service.dart @@ -6,9 +6,13 @@ import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../utils/logger.dart'; + class CustomFontService { static const String prefKey = 'app_custom_fonts'; - static const String _userAgent = 'Mozilla/5.0 (X11; Linux x86_64) Chrome/120'; + static const String _userAgent = + 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us) ' + 'AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30'; static final Set _loaded = {}; @@ -30,24 +34,26 @@ class CustomFontService { } static Future addFamily(String family) async { - final dir = await _cacheDir(); - final file = _fileFor(dir, family); - Uint8List? bytes; - if (await file.exists()) { - bytes = await file.readAsBytes(); - } else { - bytes = await _download(family); - if (bytes == null) return null; - await file.writeAsBytes(bytes); - } try { + final dir = await _cacheDir(); + final file = _fileFor(dir, family); + Uint8List? bytes; + if (await file.exists()) { + final cached = await file.readAsBytes(); + if (_isSfnt(cached)) bytes = cached; + } + if (bytes == null) { + bytes = await _download(family); + if (bytes == null) return null; + await file.writeAsBytes(bytes); + } await _register(family, bytes); - } catch (_) { - if (await file.exists()) await file.delete(); + await _persist(family); + return family; + } catch (e) { + logger.w('CustomFont: не удалось добавить «$family»: $e'); return null; } - await _persist(family); - return family; } static Future removeFamily(String family) async { @@ -102,19 +108,23 @@ class CustomFontService { static Future _download(String family) async { final encoded = Uri.encodeQueryComponent(family); final variants = [ - 'https://fonts.googleapis.com/css2?family=$encoded:wght@100..900', 'https://fonts.googleapis.com/css2?family=$encoded', + 'https://fonts.googleapis.com/css2?family=$encoded:wght@400', + 'https://fonts.googleapis.com/css2?family=$encoded:wght@100..900', ]; - final client = HttpClient()..connectionTimeout = const Duration(seconds: 15); + final urlRegex = RegExp(r'url\((https://[^)]+)\)'); + final client = HttpClient() + ..connectionTimeout = const Duration(seconds: 15); try { for (final url in variants) { final css = await _fetchText(client, Uri.parse(url)); if (css == null) continue; - final ttf = RegExp(r'url\((https://[^)]+\.ttf)\)').firstMatch(css); - final ttfUrl = ttf?.group(1); - if (ttfUrl == null) continue; - final bytes = await _fetchBytes(client, Uri.parse(ttfUrl)); - if (bytes != null && _isSfnt(bytes)) return bytes; + for (final match in urlRegex.allMatches(css)) { + final fontUrl = match.group(1); + if (fontUrl == null) continue; + final bytes = await _fetchBytes(client, Uri.parse(fontUrl)); + if (bytes != null && _isSfnt(bytes)) return bytes; + } } return null; } catch (_) { @@ -127,26 +137,27 @@ class CustomFontService { static Future _fetchText(HttpClient client, Uri uri) async { final req = await client.getUrl(uri); req.headers.set(HttpHeaders.userAgentHeader, _userAgent); - final resp = await req.close(); + final resp = await req.close().timeout(const Duration(seconds: 20)); if (resp.statusCode != HttpStatus.ok) { await resp.drain(); return null; } - return resp.transform(const Utf8Decoder()).join(); + return resp + .transform(const Utf8Decoder()) + .join() + .timeout(const Duration(seconds: 20)); } static Future _fetchBytes(HttpClient client, Uri uri) async { final req = await client.getUrl(uri); req.headers.set(HttpHeaders.userAgentHeader, _userAgent); - final resp = await req.close(); + final resp = await req.close().timeout(const Duration(seconds: 20)); if (resp.statusCode != HttpStatus.ok) { await resp.drain(); return null; } final builder = BytesBuilder(copy: false); - await for (final chunk in resp) { - builder.add(chunk); - } + await resp.forEach(builder.add).timeout(const Duration(seconds: 30)); return builder.takeBytes(); } } diff --git a/lib/core/config/persisted_setting.dart b/lib/core/config/persisted_setting.dart new file mode 100644 index 0000000..19489a8 --- /dev/null +++ b/lib/core/config/persisted_setting.dart @@ -0,0 +1,72 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +typedef PrefReader = T? Function(SharedPreferences prefs, String key); +typedef PrefWriter = + Future Function(SharedPreferences prefs, String key, T value); + +class PersistedSetting { + PersistedSetting({ + required this.prefKey, + required this.defaultValue, + required this.read, + required this.write, + T Function(T value)? sanitize, + }) : sanitize = sanitize ?? ((value) => value), + current = ValueNotifier(defaultValue); + + final String prefKey; + final T defaultValue; + final PrefReader read; + final PrefWriter write; + final T Function(T value) sanitize; + final ValueNotifier current; + + Future load() async { + final prefs = await SharedPreferences.getInstance(); + final value = sanitize(read(prefs, prefKey) ?? defaultValue); + current.value = value; + return value; + } + + Future save(T value) async { + final sanitized = sanitize(value); + current.value = sanitized; + final prefs = await SharedPreferences.getInstance(); + await write(prefs, prefKey, sanitized); + } +} + +typedef EnumEncoder = String Function(T value); +typedef EnumDecoder = T Function(String? raw); + +T enumFromName(List values, String? raw, T fallback) => + values.firstWhere((v) => v.name == raw, orElse: () => fallback); + +class PersistedEnum { + PersistedEnum({ + required this.prefKey, + required this.defaultValue, + required this.encode, + required this.decode, + }) : current = ValueNotifier(defaultValue); + + final String prefKey; + final T defaultValue; + final EnumEncoder encode; + final EnumDecoder decode; + final ValueNotifier current; + + Future load() async { + final prefs = await SharedPreferences.getInstance(); + final value = decode(prefs.getString(prefKey)); + current.value = value; + return value; + } + + Future save(T value) async { + current.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(prefKey, encode(value)); + } +} diff --git a/lib/core/config/proxy_config.dart b/lib/core/config/proxy_config.dart index 3428fd8..ba5cc40 100644 --- a/lib/core/config/proxy_config.dart +++ b/lib/core/config/proxy_config.dart @@ -1,5 +1,7 @@ import 'package:shared_preferences/shared_preferences.dart'; +import '../storage/token_storage.dart'; + enum ProxyType { none, socks5, httpConnect } class ProxySettings { @@ -38,8 +40,22 @@ abstract class ProxyConfig { final typeIndex = prefs.getInt(_prefType) ?? 0; final host = prefs.getString(_prefHost) ?? ''; final port = prefs.getInt(_prefPort) ?? 1080; - final username = prefs.getString(_prefUsername); - final password = prefs.getString(_prefPassword); + var username = await TokenStorage.readSecure(_prefUsername); + var password = await TokenStorage.readSecure(_prefPassword); + final legacyUsername = prefs.getString(_prefUsername); + final legacyPassword = prefs.getString(_prefPassword); + if (username == null && legacyUsername != null) { + username = legacyUsername; + await TokenStorage.writeSecure(_prefUsername, legacyUsername); + } + if (password == null && legacyPassword != null) { + password = legacyPassword; + await TokenStorage.writeSecure(_prefPassword, legacyPassword); + } + if (legacyUsername != null || legacyPassword != null) { + await prefs.remove(_prefUsername); + await prefs.remove(_prefPassword); + } return ProxySettings( type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)], host: host, @@ -54,15 +70,17 @@ abstract class ProxyConfig { await prefs.setInt(_prefType, settings.type.index); await prefs.setString(_prefHost, settings.host); await prefs.setInt(_prefPort, settings.port); + await prefs.remove(_prefUsername); + await prefs.remove(_prefPassword); if (settings.username != null) { - await prefs.setString(_prefUsername, settings.username!); + await TokenStorage.writeSecure(_prefUsername, settings.username!); } else { - await prefs.remove(_prefUsername); + await TokenStorage.deleteSecure(_prefUsername); } if (settings.password != null) { - await prefs.setString(_prefPassword, settings.password!); + await TokenStorage.writeSecure(_prefPassword, settings.password!); } else { - await prefs.remove(_prefPassword); + await TokenStorage.deleteSecure(_prefPassword); } } @@ -73,5 +91,7 @@ abstract class ProxyConfig { await prefs.remove(_prefPort); await prefs.remove(_prefUsername); await prefs.remove(_prefPassword); + await TokenStorage.deleteSecure(_prefUsername); + await TokenStorage.deleteSecure(_prefPassword); } } diff --git a/lib/core/games/checkers.dart b/lib/core/games/checkers.dart index 798b8d5..45bb7f5 100644 --- a/lib/core/games/checkers.dart +++ b/lib/core/games/checkers.dart @@ -64,8 +64,14 @@ class Checkers { return quiet; } - static void _collectCaptures(List work, int at, CheckersSide side, - List path, Set captured, List> out) { + static void _collectCaptures( + List work, + int at, + CheckersSide side, + List path, + Set captured, + List> out, + ) { final steps = _captureSteps(work, at, captured); if (steps.isEmpty) { if (path.length > 1) out.add(List.of(path)); @@ -93,7 +99,10 @@ class Checkers { } static List> _captureSteps( - List work, int at, Set captured) { + List work, + int at, + Set captured, + ) { final piece = work[at]; final side = sideOf(piece); if (side == null) return const []; @@ -139,7 +148,11 @@ class Checkers { } static void _collectQuiet( - List board, int at, CheckersSide side, List> out) { + List board, + int at, + CheckersSide side, + List> out, + ) { final piece = board[at]; final r0 = _row(at); final c0 = _col(at); diff --git a/lib/core/links/max_link.dart b/lib/core/links/max_link.dart index ff460c6..01f9168 100644 --- a/lib/core/links/max_link.dart +++ b/lib/core/links/max_link.dart @@ -34,8 +34,10 @@ class MaxLink { if (match == null) return null; final path = match.group(1)!.split('?').first.split('#').first; - final segments = - path.split('/').where((s) => s.isNotEmpty).toList(growable: false); + final segments = path + .split('/') + .where((s) => s.isNotEmpty) + .toList(growable: false); if (segments.isEmpty) return null; switch (segments.first.toLowerCase()) { diff --git a/lib/core/media/gallery_source.dart b/lib/core/media/gallery_source.dart index 3b8ede6..6ff60fa 100644 --- a/lib/core/media/gallery_source.dart +++ b/lib/core/media/gallery_source.dart @@ -120,7 +120,14 @@ class _AssetGalleryItem implements GalleryItem { class _DesktopGallerySource implements GallerySource { static const _imageExtensions = { - '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.heic', '.heif', + '.jpg', + '.jpeg', + '.png', + '.gif', + '.webp', + '.bmp', + '.heic', + '.heif', }; @override @@ -140,10 +147,7 @@ class _DesktopGallerySource implements GallerySource { } catch (_) {} } entries.sort((a, b) => b.modified.compareTo(a.modified)); - return entries - .take(limit) - .map((e) => _FileGalleryItem(e.file)) - .toList(); + return entries.take(limit).map((e) => _FileGalleryItem(e.file)).toList(); } @override diff --git a/lib/core/media/opus_ogg_encoder.dart b/lib/core/media/opus_ogg_encoder.dart index 5b9c8be..5104f08 100644 --- a/lib/core/media/opus_ogg_encoder.dart +++ b/lib/core/media/opus_ogg_encoder.dart @@ -77,7 +77,8 @@ class OpusOggEncoder { if (end <= pcm.length) { frame = Int16List.sublistView(pcm, off, end); } else { - frame = Int16List(_frameSamples)..setRange(0, pcm.length - off, pcm, off); + frame = Int16List(_frameSamples) + ..setRange(0, pcm.length - off, pcm, off); } packets.add(encoder.encode(input: frame)); } @@ -87,12 +88,29 @@ class OpusOggEncoder { return _buildOgg(packets, totalSamples: pcm.length); } - static Uint8List _buildOgg(List packets, {required int totalSamples}) { + static Uint8List _buildOgg( + List packets, { + required int totalSamples, + }) { final out = BytesBuilder(); var seq = 0; - out.add(_page(headerType: 0x02, granulePos: 0, seq: seq++, packets: [_opusHead()])); - out.add(_page(headerType: 0x00, granulePos: 0, seq: seq++, packets: [_opusTags()])); + out.add( + _page( + headerType: 0x02, + granulePos: 0, + seq: seq++, + packets: [_opusHead()], + ), + ); + out.add( + _page( + headerType: 0x00, + granulePos: 0, + seq: seq++, + packets: [_opusTags()], + ), + ); var pagePackets = []; var pageSegments = 0; @@ -100,12 +118,14 @@ class OpusOggEncoder { void flush({required bool last}) { final granule = last ? totalSamples + _preSkip : samples + _preSkip; - out.add(_page( - headerType: last ? 0x04 : 0x00, - granulePos: granule, - seq: seq++, - packets: pagePackets, - )); + out.add( + _page( + headerType: last ? 0x04 : 0x00, + granulePos: granule, + seq: seq++, + packets: pagePackets, + ), + ); pagePackets = []; pageSegments = 0; } @@ -214,7 +234,8 @@ class OpusOggEncoder { static int _crc32(Uint8List data) { var crc = 0; for (final b in data) { - crc = (((crc << 8) & 0xffffffff) ^ _crcTable[((crc >> 24) & 0xff) ^ b]) & + crc = + (((crc << 8) & 0xffffffff) ^ _crcTable[((crc >> 24) & 0xff) ^ b]) & 0xffffffff; } return crc & 0xffffffff; diff --git a/lib/core/media/raster.dart b/lib/core/media/raster.dart new file mode 100644 index 0000000..aafcc97 --- /dev/null +++ b/lib/core/media/raster.dart @@ -0,0 +1,34 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../utils/image_utils.dart'; + +Future rasterPictureToJpegFile( + ui.Picture picture, + int width, + int height, { + required String prefix, + void Function()? onPictureDisposed, +}) async { + final rendered = await picture.toImage(width, height); + picture.dispose(); + onPictureDisposed?.call(); + final bd = await rendered.toByteData(format: ui.ImageByteFormat.rawRgba); + rendered.dispose(); + if (bd == null) return null; + + final jpeg = await encodeRgbaToJpeg(bd.buffer.asUint8List(), width, height); + if (jpeg == null) return null; + final dir = await getTemporaryDirectory(); + final out = File( + p.join( + dir.path, + 'komet_${prefix}_${DateTime.now().microsecondsSinceEpoch}.jpg', + ), + ); + await out.writeAsBytes(jpeg); + return out; +} diff --git a/lib/core/nfc/nfc_exchange_service.dart b/lib/core/nfc/nfc_exchange_service.dart index 898218f..7d44cf1 100644 --- a/lib/core/nfc/nfc_exchange_service.dart +++ b/lib/core/nfc/nfc_exchange_service.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:flutter/services.dart'; +import '../utils/parse.dart'; + enum NfcEventType { received, exchanging, cancelled, error } class NfcEvent { @@ -60,17 +62,19 @@ class NfcExchangeService { NfcEvent _decodeEvent(dynamic raw) { final map = raw is Map ? raw : const {}; - final id = map['id']; - final parsedId = id is int ? id : (id is num ? id.toInt() : null); - final phone = map['phone']; - final parsedPhone = phone is int ? phone : (phone is num ? phone.toInt() : null); + final parsedId = parseIntOrNull(map['id']); + final parsedPhone = parseIntOrNull(map['phone']); switch (map['event']) { case 'received': return NfcEvent(NfcEventType.received, parsedId, phone: parsedPhone); case 'exchanging': return const NfcEvent(NfcEventType.exchanging, null); case 'error': - return NfcEvent(NfcEventType.error, null, reason: map['reason'] as String?); + return NfcEvent( + NfcEventType.error, + null, + reason: map['reason'] as String?, + ); default: return const NfcEvent(NfcEventType.cancelled, null); } diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 38f001a..c99c138 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -11,7 +11,8 @@ const int _maxDecompressedSize = 1048576; // 1 MB /// Типы команд в протоколе abstract class CmdType { - static const int request = 0; // запрос клиента / пуш от сервера (направление определяет смысл) + static const int request = + 0; // запрос клиента / пуш от сервера (направление определяет смысл) static const int push = 0; // пуш от сервера (имеет смысл только для incoming) static const int ok = 1; // ответ: ок @@ -83,6 +84,21 @@ String messageFromErrorPayload(dynamic payload) { return s.isNotEmpty ? s : 'Неизвестная ошибка'; } +bool isSessionExpiredPayload(dynamic payload) { + return payload is Map && + (payload['message'] == 'FAIL_LOGIN_TOKEN' || + payload['message'] == 'FAIL_WRONG_PASSWORD'); +} + +void throwIfPacketError(Packet packet) { + if (!packet.isError) return; + final payload = packet.payload; + if (isSessionExpiredPayload(payload)) { + throw SessionExpiredException(messageFromErrorPayload(payload)); + } + throw PacketError(messageFromErrorPayload(payload)); +} + bool isSessionStateError(Object error) { if (error is SessionExpiredException) return true; final text = error.toString().toLowerCase(); @@ -192,7 +208,9 @@ Uint8List _decompressPayload(Uint8List src) { src[2] == 0x2F && src[3] == 0xFD) { try { - return ZstdCodec(maxDecompressedSize: _maxDecompressedSize).decompress(src); + return ZstdCodec( + maxDecompressedSize: _maxDecompressedSize, + ).decompress(src); } catch (e) { throw Exception('Zstd decompression error: $e'); } @@ -218,4 +236,3 @@ Uint8List _decompressPayload(Uint8List src) { throw Exception('LZ4 block decompression error: $e'); } } - diff --git a/lib/core/push/push_service.dart b/lib/core/push/push_service.dart index 33c29da..4ecb356 100644 --- a/lib/core/push/push_service.dart +++ b/lib/core/push/push_service.dart @@ -1,11 +1,8 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; -import 'dart:ui' as ui; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -23,279 +20,14 @@ import '../utils/logger.dart'; const _channelId = 'komet_messages'; const _channelName = 'Сообщения'; const _prefsTokenKey = 'fcm_push_token'; -const _groupKey = 'komet_messages_group'; -const _callNotifId = 424242; -const _historyLimit = 6; - -@pragma('vm:entry-point') -Future _backgroundHandler(RemoteMessage message) async {} - -class _NotifMessage { - _NotifMessage(this.text, this.senderKey, this.senderName, this.ts); - final String text; - final String senderKey; - final String senderName; - final int ts; -} - -Future _showMessageNotification( - FlutterLocalNotificationsPlugin plugin, - Map data, -) async { - final chatId = int.tryParse(data['mc']?.toString() ?? '') ?? 0; - final senderKey = data['suid']?.toString() ?? ''; - final senderName = - data['userName']?.toString() ?? data['title']?.toString() ?? 'MAX'; - final chatTitle = data['title']?.toString() ?? senderName; - final text = data['msg']?.toString() ?? - data['body']?.toString() ?? - data['text']?.toString() ?? - data['message']?.toString() ?? - 'Новое сообщение'; - final ts = int.tryParse(data['ctime']?.toString() ?? '') ?? - int.tryParse(data['ttime']?.toString() ?? '') ?? - DateTime.now().millisecondsSinceEpoch; - final isGroup = chatTitle != senderName; - final account = int.tryParse(data['c']?.toString() ?? '') ?? 0; - final replyTo = int.tryParse(data['msgid']?.toString() ?? ''); - - final notifId = (chatId != 0 ? chatId : senderKey.hashCode) & 0x7fffffff; - if (!await _isActive(plugin, notifId)) { - await _clearHistory(chatId); - } - - final photo = await _avatarBytes(senderKey); - final avatar = photo ?? await _initialsAvatar(senderName); - print('PUSHDBG avatar sender=$senderKey photo=${photo?.length} ' - 'final=${avatar?.length}'); - final history = await _appendHistory(chatId, senderKey, senderName, text, ts); - - final persons = {}; - Person personFor(String key, String name) => persons.putIfAbsent( - key, - () => Person( - key: key, - name: name, - icon: (key == senderKey && avatar != null) - ? ByteArrayAndroidIcon(avatar) - : null, - ), - ); - - final messages = [ - for (final h in history) - Message( - h.text, - DateTime.fromMillisecondsSinceEpoch(h.ts), - personFor(h.senderKey, h.senderName), - ), - ]; - - final style = MessagingStyleInformation( - const Person(name: 'Вы'), - conversationTitle: isGroup ? chatTitle : null, - groupConversation: isGroup, - messages: messages, - ); - - await plugin.show( - id: notifId, - title: chatTitle, - body: text, - notificationDetails: NotificationDetails( - android: AndroidNotificationDetails( - _channelId, - _channelName, - importance: Importance.high, - priority: Priority.high, - category: AndroidNotificationCategory.message, - styleInformation: style, - groupKey: _groupKey, - largeIcon: avatar != null ? ByteArrayAndroidBitmap(avatar) : null, - ticker: text, - actions: account != 0 - ? const [ - AndroidNotificationAction( - 'reply', - 'Ответить', - inputs: [ - AndroidNotificationActionInput(label: 'Сообщение…'), - ], - semanticAction: SemanticAction.reply, - ), - ] - : null, - ), - ), - payload: jsonEncode({'c': account, 'chat': chatId, 'mid': replyTo}), - ); -} - -Future _showCallNotification( - FlutterLocalNotificationsPlugin plugin, - Map data, -) async { - final name = - data['userName']?.toString() ?? data['msg']?.toString() ?? 'Неизвестный'; - final avatar = await _avatarBytes(data['suid']?.toString() ?? '') ?? - await _initialsAvatar(name); - await plugin.show( - id: _callNotifId, - title: 'Входящий звонок', - body: name, - notificationDetails: NotificationDetails( - android: AndroidNotificationDetails( - _channelId, - _channelName, - importance: Importance.high, - priority: Priority.high, - category: AndroidNotificationCategory.call, - largeIcon: avatar != null ? ByteArrayAndroidBitmap(avatar) : null, - ticker: 'Входящий звонок', - ), - ), - ); -} - -Future> _appendHistory( - int chatId, - String senderKey, - String senderName, - String text, - int ts, -) async { - final prefs = await SharedPreferences.getInstance(); - final key = 'notif_hist_$chatId'; - final list = >[]; - final raw = prefs.getString(key); - if (raw != null) { - try { - final decoded = jsonDecode(raw); - if (decoded is List) { - for (final e in decoded) { - if (e is Map) list.add(e.cast()); - } - } - } catch (_) {} - } - list.add({'t': text, 'k': senderKey, 'n': senderName, 'ts': ts}); - while (list.length > _historyLimit) { - list.removeAt(0); - } - await prefs.setString(key, jsonEncode(list)); - return [ - for (final e in list) - _NotifMessage( - e['t']?.toString() ?? '', - e['k']?.toString() ?? '', - e['n']?.toString() ?? '', - int.tryParse(e['ts']?.toString() ?? '') ?? ts, - ), - ]; -} - -Future _isActive(FlutterLocalNotificationsPlugin plugin, int id) async { - try { - final active = await plugin.getActiveNotifications(); - return active.any((n) => n.id == id); - } catch (_) { - return true; - } -} Future _clearHistory(int chatId) async { final prefs = await SharedPreferences.getInstance(); await prefs.remove('notif_hist_$chatId'); } -const _avatarPalette = [ - 0xFF5B8DEF, - 0xFFEF5B8D, - 0xFF3FB950, - 0xFFE3883A, - 0xFF9B72F0, - 0xFF2AA9B5, - 0xFFE05252, - 0xFF6A7BE0, -]; - -String _initialsOf(String name) { - final parts = - name.trim().split(RegExp(r'\s+')).where((p) => p.isNotEmpty).toList(); - if (parts.isEmpty) return '?'; - if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase(); - return (parts[0].substring(0, 1) + parts[1].substring(0, 1)).toUpperCase(); -} - -Future _initialsAvatar(String name) async { - try { - const size = 128; - final recorder = ui.PictureRecorder(); - final canvas = ui.Canvas(recorder); - final paint = ui.Paint() - ..isAntiAlias = true - ..color = ui.Color( - _avatarPalette[name.isEmpty ? 0 : name.hashCode.abs() % _avatarPalette.length], - ); - canvas.drawCircle(const ui.Offset(64, 64), 64, paint); - final builder = ui.ParagraphBuilder( - ui.ParagraphStyle( - textAlign: ui.TextAlign.center, - fontSize: 56, - fontWeight: ui.FontWeight.w600, - ), - ) - ..pushStyle(ui.TextStyle(color: const ui.Color(0xFFFFFFFF))) - ..addText(_initialsOf(name)); - final paragraph = builder.build() - ..layout(const ui.ParagraphConstraints(width: 128)); - canvas.drawParagraph(paragraph, ui.Offset(0, (size - paragraph.height) / 2)); - final image = await recorder.endRecording().toImage(size, size); - final data = await image.toByteData(format: ui.ImageByteFormat.png); - image.dispose(); - if (data == null) return null; - return data.buffer.asUint8List(); - } catch (_) { - return null; - } -} - -Future _avatarBytes(String senderKey) async { - if (senderKey.isEmpty) return null; - try { - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString('contact_cache_v1'); - if (raw == null) return null; - final map = jsonDecode(raw); - if (map is! Map) return null; - final entry = map[senderKey]; - final url = entry is Map ? entry['a']?.toString() : null; - if (url == null || url.isEmpty) return null; - return await _downloadBytes(url); - } catch (_) { - return null; - } -} - -Future _downloadBytes(String url) async { - HttpClient? client; - try { - client = HttpClient()..connectionTimeout = const Duration(seconds: 4); - final req = await client.getUrl(Uri.parse(url)); - final resp = await req.close().timeout(const Duration(seconds: 5)); - if (resp.statusCode != 200) return null; - return await consolidateHttpClientResponseBytes(resp); - } catch (_) { - return null; - } finally { - client?.close(force: true); - } -} - @pragma('vm:entry-point') void _onNotificationResponse(NotificationResponse response) { - print('REPLYDBG cb action=${response.actionId} ' - 'input=${response.input} payload=${response.payload}'); if (response.actionId == 'call_decline') { final payload = response.payload; if (payload != null) unawaited(_handleCallDecline(payload)); @@ -329,9 +61,7 @@ Future _handleCallDecline(String payloadJson) async { try { await signaling.connect(); await signaling.hangup(reason: 'REJECTED'); - print('REPLYDBG call decline sent'); - } catch (e) { - print('REPLYDBG call decline error $e'); + } catch (_) { } finally { await signaling.close(); } @@ -351,7 +81,6 @@ Future _handleReply(String payloadJson, String text) async { return; } if (account == 0 || chatId == 0) return; - print('REPLYDBG start acc=$account chat=$chatId reply=$replyTo'); WidgetsFlutterBinding.ensureInitialized(); if (AppInstance.isNamed) { @@ -366,7 +95,6 @@ Future _handleReply(String payloadJson, String text) async { var sent = false; try { final token = await TokenStorage.readToken(account); - print('REPLYDBG token=${token != null && token.isNotEmpty}'); if (token != null && token.isNotEmpty) { api = Api()..spoofScope = '$account'; await api.connect(); @@ -375,30 +103,19 @@ Future _handleReply(String payloadJson, String text) async { .firstWhere((s) => s == SessionState.online) .timeout(const Duration(seconds: 20)); } - print('REPLYDBG online'); - final login = await api.sendRequest(Opcode.login, { - 'token': token, - 'interactive': false, - 'exp': { - 'chatsCountGroups': Uint8List.fromList([0x0b, 0x32]), - }, - 'presenceSync': 0, - }); - print('REPLYDBG login ok=${login.isOk}'); + final login = await api.sendRequest( + Opcode.login, + AccountModule(api).buildLoginPayload(token, interactive: false), + ); if (login.isOk) { - await MessagesModule(api).sendMessage( - account, - chatId, - text, - replyToMessageId: replyTo, - ); + await MessagesModule( + api, + ).sendMessage(account, chatId, text, replyToMessageId: replyTo); sent = true; - print('REPLYDBG sent'); } } - } catch (e) { + } catch (_) { sent = false; - print('REPLYDBG error $e'); } finally { await api?.disconnect(); } @@ -464,7 +181,8 @@ class PushService { ); await _local .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>() + AndroidFlutterLocalNotificationsPlugin + >() ?.createNotificationChannel( const AndroidNotificationChannel( _channelId, diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 3efa47b..287b7e2 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:komet/core/storage/app_instance.dart'; @@ -166,7 +167,8 @@ class AppDatabase { final dir = await getApplicationSupportDirectory(); return dir.path; } - return _mobileDbDir ??= await databaseFactorySqflitePlugin.getDatabasesPath(); + return _mobileDbDir ??= await databaseFactorySqflitePlugin + .getDatabasesPath(); } static Future _migrateLegacyDb(String target) async { @@ -180,7 +182,9 @@ class AppDatabase { await legacy.copy(target); logger.i('[db] перенёс komet.db -> $target'); } - } catch (_) {} + } catch (e) { + logger.w('legacy db migration failed: $e'); + } } static Future _open() async { @@ -190,13 +194,16 @@ class AppDatabase { await _migrateLegacyDb(target); return openDatabase( target, - version: 16, + version: 17, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { if (oldVersion < 2) { await _addColumnIfMissing( - db, 'profile', 'is_active', 'INTEGER NOT NULL DEFAULT 0', + db, + 'profile', + 'is_active', + 'INTEGER NOT NULL DEFAULT 0', ); await db.execute('DROP TABLE IF EXISTS sync_state'); await db.execute(_syncStateSchema); @@ -232,16 +239,27 @@ class AppDatabase { await _createIndexes(db); } if (oldVersion < 12) { - await _addColumnIfMissing(db, 'chats_cache', 'last_msg_status', 'TEXT'); + await _addColumnIfMissing( + db, + 'chats_cache', + 'last_msg_status', + 'TEXT', + ); } if (oldVersion < 13) { await _addColumnIfMissing( - db, 'messages', 'deleted', 'INTEGER NOT NULL DEFAULT 0', + db, + 'messages', + 'deleted', + 'INTEGER NOT NULL DEFAULT 0', ); } if (oldVersion < 14) { await _addColumnIfMissing( - db, 'chats_cache', 'in_list', 'INTEGER NOT NULL DEFAULT 1', + db, + 'chats_cache', + 'in_list', + 'INTEGER NOT NULL DEFAULT 1', ); } if (oldVersion < 15) { @@ -249,9 +267,17 @@ class AppDatabase { } if (oldVersion < 16) { await _addColumnIfMissing( - db, 'chats_cache', 'last_msg_elements', 'TEXT', + db, + 'chats_cache', + 'last_msg_elements', + 'TEXT', ); } + if (oldVersion < 17) { + await db.execute(_chatParticipantsSchema); + await _createChatParticipantsIndex(db); + await _backfillChatParticipants(db); + } }, ); } @@ -277,7 +303,9 @@ class AppDatabase { await db.execute(_chatsCacheSchema); await db.execute(_contactsSchema); await db.execute(_messagesSchema); + await db.execute(_chatParticipantsSchema); await _createIndexes(db); + await _createChatParticipantsIndex(db); } static Future _addColumnIfMissing( @@ -304,6 +332,51 @@ class AppDatabase { ); } + static Future _createChatParticipantsIndex(Database db) async { + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_chat_participants_lookup ' + 'ON chat_participants(account_id, participant_id, chat_id)', + ); + } + + static List _participantIdsFromRaw(Object? raw) { + if (raw is! String || raw.isEmpty) return const []; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return const []; + final ids = []; + for (final key in decoded.keys) { + final id = key is int ? key : int.tryParse(key.toString()); + if (id != null) ids.add(id); + } + return ids; + } catch (_) { + return const []; + } + } + + static Future _backfillChatParticipants(Database db) async { + final chats = await db.query( + 'chats_cache', + columns: ['id', 'account_id', 'participants'], + where: "type = 'DIALOG'", + ); + final batch = db.batch(); + for (final chat in chats) { + final accountId = chat['account_id']; + final chatId = chat['id']; + if (accountId is! int || chatId is! int) continue; + for (final pid in _participantIdsFromRaw(chat['participants'])) { + batch.insert('chat_participants', { + 'account_id': accountId, + 'chat_id': chatId, + 'participant_id': pid, + }, conflictAlgorithm: ConflictAlgorithm.ignore); + } + } + await batch.commit(noResult: true); + } + static const _contactsSchema = ''' CREATE TABLE contacts ( id INTEGER PRIMARY KEY, @@ -357,6 +430,17 @@ class AppDatabase { ) '''; + static const _chatParticipantsSchema = ''' + CREATE TABLE chat_participants ( + account_id INTEGER NOT NULL, + chat_id INTEGER NOT NULL, + participant_id INTEGER NOT NULL, + PRIMARY KEY (account_id, chat_id, participant_id), + FOREIGN KEY (chat_id, account_id) + REFERENCES chats_cache (id, account_id) ON DELETE CASCADE + ) + '''; + static const _messagesSchema = ''' CREATE TABLE messages ( id TEXT NOT NULL, @@ -374,7 +458,10 @@ class AppDatabase { ) '''; - static Future saveProfile(ProfileData profile, {bool isActive = true}) async { + static Future saveProfile( + ProfileData profile, { + bool isActive = true, + }) async { final db = await _instance; final row = profile.toDbRow(isActive: isActive); final cols = row.keys.toList(); @@ -524,7 +611,8 @@ class AppDatabase { .where((c) => c != 'id' && c != 'account_id') .map((c) => '$c = excluded.$c') .join(', '); - final sql = 'INSERT INTO chats_cache (${cols.join(', ')}) ' + final sql = + 'INSERT INTO chats_cache (${cols.join(', ')}) ' 'VALUES ($placeholders) ' 'ON CONFLICT(id, account_id) DO UPDATE SET $updates'; await db.transaction((txn) async { @@ -533,13 +621,35 @@ class AppDatabase { batch.rawInsert(sql, cols.map((c) => row[c]).toList()); } await batch.commit(noResult: true); + for (final row in rows) { + if (!row.containsKey('participants')) continue; + if (row['type'] != 'DIALOG') continue; + final accountId = row['account_id']; + final chatId = row['id']; + if (accountId is! int || chatId is! int) continue; + await txn.delete( + 'chat_participants', + where: 'account_id = ? AND chat_id = ?', + whereArgs: [accountId, chatId], + ); + for (final pid in _participantIdsFromRaw(row['participants'])) { + await txn.insert('chat_participants', { + 'account_id': accountId, + 'chat_id': chatId, + 'participant_id': pid, + }, conflictAlgorithm: ConflictAlgorithm.ignore); + } + } }); } catch (e) { logger.e("Ошибка при сохранении чата: $e"); } } - static Future>> loadChat(int accountId, int chatId) async { + static Future>> loadChat( + int accountId, + int chatId, + ) async { final db = await _instance; return db.query( 'chats_cache', @@ -548,7 +658,7 @@ class AppDatabase { orderBy: 'last_event_time DESC', ); } - + static Future>> loadChats(int accountId) async { final db = await _instance; return db.query( @@ -575,20 +685,25 @@ class AppDatabase { return (result.first['total'] as int?) ?? 0; } - static Future findDialogChatByParticipant(int accountId, int contactId) async { + static Future findDialogChatByParticipant( + int accountId, + int contactId, + ) async { final db = await _instance; - final rows = await db.query( - 'chats_cache', - columns: ['id'], - where: "account_id = ? AND type = 'DIALOG' AND participants LIKE ?", - whereArgs: [accountId, '%"$contactId":%'], - limit: 1, + final rows = await db.rawQuery( + 'SELECT p.chat_id AS id FROM chat_participants p ' + 'JOIN chats_cache c ON c.id = p.chat_id AND c.account_id = p.account_id ' + "WHERE p.account_id = ? AND p.participant_id = ? AND c.type = 'DIALOG' " + 'LIMIT 1', + [accountId, contactId], ); if (rows.isEmpty) return null; return rows.first['id'] as int?; } - static Future>> loadDialogChats(int accountId) async { + static Future>> loadDialogChats( + int accountId, + ) async { final db = await _instance; return db.query( 'chats_cache', @@ -597,8 +712,10 @@ class AppDatabase { ); } - static String _escapeLike(String value) => - value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_'); + static String _escapeLike(String value) => value + .replaceAll('\\', '\\\\') + .replaceAll('%', '\\%') + .replaceAll('_', '\\_'); static Future>> searchContacts( int accountId, @@ -611,7 +728,8 @@ class AppDatabase { final like = '%${_escapeLike(term)}%'; return db.query( 'contacts', - where: 'account_id = ? AND ' + where: + 'account_id = ? AND ' "(first_name LIKE ? ESCAPE '\\' OR last_name LIKE ? ESCAPE '\\' " "OR CAST(phone AS TEXT) LIKE ? ESCAPE '\\')", whereArgs: [accountId, like, like, like], diff --git a/lib/core/storage/chat_wallpaper_store.dart b/lib/core/storage/chat_wallpaper_store.dart index 3dcf078..d3aaabc 100644 --- a/lib/core/storage/chat_wallpaper_store.dart +++ b/lib/core/storage/chat_wallpaper_store.dart @@ -1,10 +1,11 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; + +import '../utils/logger.dart'; +import 'per_chat_json_store.dart'; enum ChatWallpaperKind { image, theme } @@ -39,18 +40,18 @@ class ChatWallpaper { this.blur = false, this.motion = false, this.offsetX = 0, - }) : kind = ChatWallpaperKind.image, - imagePath = path, - themeId = null; + }) : kind = ChatWallpaperKind.image, + imagePath = path, + themeId = null; const ChatWallpaper.theme(String id) - : kind = ChatWallpaperKind.theme, - imagePath = null, - themeId = id, - dim = 0, - blur = false, - motion = false, - offsetX = 0; + : kind = ChatWallpaperKind.theme, + imagePath = null, + themeId = id, + dim = 0, + blur = false, + motion = false, + offsetX = 0; bool get isImage => kind == ChatWallpaperKind.image; @@ -82,42 +83,19 @@ class ChatWallpaper { } } -class ChatWallpaperStore { - ChatWallpaperStore._(); +class ChatWallpaperStore extends PerChatJsonStore { + ChatWallpaperStore._() + : super( + prefsKey: 'chat_wallpapers', + fromJson: ChatWallpaper._fromJson, + toJson: (value) => value._toJson(), + ); static final ChatWallpaperStore instance = ChatWallpaperStore._(); - static const String _prefsKey = 'chat_wallpapers'; static const String _dirName = 'chat_wallpapers'; - final Map _wallpapers = {}; - final ValueNotifier revision = ValueNotifier(0); - bool _loaded = false; - - String _key(int accountId, int chatId) => '$accountId/$chatId'; - - Future load() async { - if (_loaded) return; - _loaded = true; - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_prefsKey); - if (raw == null) return; - try { - final map = jsonDecode(raw); - if (map is Map) { - map.forEach((k, v) { - if (k is! String) return; - final wp = ChatWallpaper._fromJson(v); - if (wp != null) _wallpapers[k] = wp; - }); - } - } catch (_) {} - } - - ChatWallpaper? get(int accountId, int chatId) { - if (accountId == 0) return null; - return _wallpapers[_key(accountId, chatId)]; - } + ChatWallpaper? get(int accountId, int chatId) => read(accountId, chatId); Future setImage( int accountId, @@ -139,7 +117,7 @@ class ChatWallpaperStore { motion: settings.motion, offsetX: settings.offsetX, ); - await _store(accountId, chatId, wallpaper); + await write(accountId, chatId, wallpaper); return wallpaper; } @@ -149,36 +127,20 @@ class ChatWallpaperStore { String themeId, ) async { final wallpaper = ChatWallpaper.theme(themeId); - await _store(accountId, chatId, wallpaper); + await write(accountId, chatId, wallpaper); return wallpaper; } - Future clear(int accountId, int chatId) => _store(accountId, chatId, null); + Future clear(int accountId, int chatId) => + write(accountId, chatId, null); - Future _store( - int accountId, - int chatId, - ChatWallpaper? wallpaper, - ) async { - if (accountId == 0) return; - final key = _key(accountId, chatId); - final previous = _wallpapers[key]; + @override + void onBeforeWrite(String key, ChatWallpaper? previous, ChatWallpaper? next) { if (previous != null && previous.isImage && - previous.imagePath != wallpaper?.imagePath) { + previous.imagePath != next?.imagePath) { unawaited(_deleteImage(previous.imagePath)); } - if (wallpaper == null) { - if (previous == null) return; - _wallpapers.remove(key); - } else { - _wallpapers[key] = wallpaper; - } - revision.value++; - final prefs = await SharedPreferences.getInstance(); - final serializable = {}; - _wallpapers.forEach((k, v) => serializable[k] = v._toJson()); - await prefs.setString(_prefsKey, jsonEncode(serializable)); } Future _deleteImage(String? path) async { @@ -186,6 +148,8 @@ class ChatWallpaperStore { try { final file = File(path); if (await file.exists()) await file.delete(); - } catch (_) {} + } catch (e) { + logger.w('wallpaper image delete failed: $e'); + } } } diff --git a/lib/core/storage/device_identity.dart b/lib/core/storage/device_identity.dart index 56bcc13..2e3dbe7 100644 --- a/lib/core/storage/device_identity.dart +++ b/lib/core/storage/device_identity.dart @@ -2,6 +2,8 @@ import 'dart:math'; import 'package:shared_preferences/shared_preferences.dart'; +import '../utils/ids.dart'; + abstract class DeviceIdentity { static const String _instanceIdKey = 'mt_instance_id'; static const String _deviceIdKey = 'device_id_local'; @@ -16,7 +18,7 @@ abstract class DeviceIdentity { final prefs = await SharedPreferences.getInstance(); final existing = prefs.getString(_instanceIdKey); if (existing != null && existing.isNotEmpty) return existing; - final generated = _uuidV4(); + final generated = uuidV4(); await prefs.setString(_instanceIdKey, generated); return generated; } @@ -25,25 +27,9 @@ abstract class DeviceIdentity { final prefs = await SharedPreferences.getInstance(); final existing = prefs.getString(_deviceIdKey); if (existing != null && existing.isNotEmpty) return existing; - final generated = _hex(8); + final generated = randomHex(8); await prefs.setString(_deviceIdKey, generated); return generated; } - static String _hex(int bytes) { - final sb = StringBuffer(); - for (var i = 0; i < bytes; i++) { - sb.write(_rng.nextInt(256).toRadixString(16).padLeft(2, '0')); - } - return sb.toString(); - } - - static String _uuidV4() { - final b = List.generate(16, (_) => _rng.nextInt(256)); - b[6] = (b[6] & 0x0f) | 0x40; - b[8] = (b[8] & 0x3f) | 0x80; - String h(int i) => b[i].toRadixString(16).padLeft(2, '0'); - return '${h(0)}${h(1)}${h(2)}${h(3)}-${h(4)}${h(5)}-${h(6)}${h(7)}-' - '${h(8)}${h(9)}-${h(10)}${h(11)}${h(12)}${h(13)}${h(14)}${h(15)}'; - } } diff --git a/lib/core/storage/draft_store.dart b/lib/core/storage/draft_store.dart index 26cdd8e..e1dc9f6 100644 --- a/lib/core/storage/draft_store.dart +++ b/lib/core/storage/draft_store.dart @@ -1,56 +1,27 @@ -import 'dart:convert'; +import 'per_chat_json_store.dart'; -import 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class DraftStore { - DraftStore._(); +class DraftStore extends PerChatJsonStore { + DraftStore._() + : super( + prefsKey: 'chat_drafts', + fromJson: (raw) => raw is String ? raw : null, + toJson: (value) => value, + ); static final DraftStore instance = DraftStore._(); - static const String _prefsKey = 'chat_drafts'; - - final Map _drafts = {}; - final ValueNotifier revision = ValueNotifier(0); - bool _loaded = false; - - String _key(int accountId, int chatId) => '$accountId/$chatId'; - - Future load() async { - if (_loaded) return; - _loaded = true; - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_prefsKey); - if (raw == null) return; - try { - final map = jsonDecode(raw); - if (map is Map) { - map.forEach((k, v) { - if (k is String && v is String) _drafts[k] = v; - }); - } - } catch (_) {} - } - - String? get(int accountId, int chatId) { - if (accountId == 0) return null; - return _drafts[_key(accountId, chatId)]; - } + String? get(int accountId, int chatId) => read(accountId, chatId); Future set(int accountId, int chatId, String text) async { if (accountId == 0) return; - final key = _key(accountId, chatId); - final current = _drafts[key]; + final current = read(accountId, chatId); if (text.trim().isEmpty) { if (current == null) return; - _drafts.remove(key); + await write(accountId, chatId, null); } else { if (current == text) return; - _drafts[key] = text; + await write(accountId, chatId, text); } - revision.value++; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_prefsKey, jsonEncode(_drafts)); } Future clear(int accountId, int chatId) => set(accountId, chatId, ''); diff --git a/lib/core/storage/per_chat_json_store.dart b/lib/core/storage/per_chat_json_store.dart new file mode 100644 index 0000000..c53c1dd --- /dev/null +++ b/lib/core/storage/per_chat_json_store.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +abstract class PerChatJsonStore { + PerChatJsonStore({ + required String prefsKey, + required T? Function(Object? raw) fromJson, + required Object? Function(T value) toJson, + }) : _prefsKey = prefsKey, + _fromJson = fromJson, + _toJson = toJson; + + final String _prefsKey; + final T? Function(Object? raw) _fromJson; + final Object? Function(T value) _toJson; + + final Map _values = {}; + final ValueNotifier revision = ValueNotifier(0); + bool _loaded = false; + + String _buildKey(int accountId, int chatId) => '$accountId/$chatId'; + + @protected + void onBeforeWrite(String key, T? previous, T? next) {} + + Future load() async { + if (_loaded) return; + _loaded = true; + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_prefsKey); + if (raw == null) return; + try { + final map = jsonDecode(raw); + if (map is Map) { + map.forEach((k, v) { + if (k is! String) return; + final value = _fromJson(v); + if (value != null) _values[k] = value; + }); + } + } catch (_) {} + } + + @protected + T? read(int accountId, int chatId) { + if (accountId == 0) return null; + return _values[_buildKey(accountId, chatId)]; + } + + @protected + Future write(int accountId, int chatId, T? value) async { + if (accountId == 0) return; + final key = _buildKey(accountId, chatId); + final previous = _values[key]; + onBeforeWrite(key, previous, value); + if (value == null) { + if (previous == null) return; + _values.remove(key); + } else { + _values[key] = value; + } + revision.value++; + final prefs = await SharedPreferences.getInstance(); + final serializable = {}; + _values.forEach((k, v) => serializable[k] = _toJson(v)); + await prefs.setString(_prefsKey, jsonEncode(serializable)); + } +} diff --git a/lib/core/storage/spoofing_service.dart b/lib/core/storage/spoofing_service.dart index 38031e4..2f68a7c 100644 --- a/lib/core/storage/spoofing_service.dart +++ b/lib/core/storage/spoofing_service.dart @@ -6,6 +6,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../config/device_presets.dart'; import '../../models/spoof_profile.dart'; import 'token_storage.dart'; +import '../utils/ids.dart'; +import '../utils/logger.dart'; class SpoofingService { static const String hardcodedAppVersion = '26.20.2'; @@ -84,8 +86,9 @@ class SpoofingService { final fresh = devicePresets .where((p) => isAndroid(p) && !used.contains(p.deviceName)) .toList(); - final pool = - fresh.isNotEmpty ? fresh : devicePresets.where(isAndroid).toList(); + final pool = fresh.isNotEmpty + ? fresh + : devicePresets.where(isAndroid).toList(); final preset = pool[_rng.nextInt(pool.length)]; final shortLocale = preset.locale.split(RegExp(r'[-_]')).first; @@ -103,7 +106,7 @@ class SpoofingService { appVersion: hardcodedAppVersion, buildNumber: hardcodedBuildNumber, pushDeviceType: 'GCM', - instanceId: _uuidV4(), + instanceId: uuidV4(), clientSessionId: _rng.nextInt(0x7FFFFFFF) + 1, userAgent: preset.userAgent, ); @@ -131,11 +134,13 @@ class SpoofingService { 'device_locale': profile.deviceLocale, 'device_id': profile.deviceId, 'device_type': profile.deviceType, - 'app_version': - profile.appVersion.isEmpty ? hardcodedAppVersion : profile.appVersion, + 'app_version': profile.appVersion.isEmpty + ? hardcodedAppVersion + : profile.appVersion, 'arch': profile.arch.isEmpty ? 'arm64-v8a' : profile.arch, - 'build_number': - profile.buildNumber == 0 ? hardcodedBuildNumber : profile.buildNumber, + 'build_number': profile.buildNumber == 0 + ? hardcodedBuildNumber + : profile.buildNumber, 'instance_id': profile.instanceId, 'client_session_id': profile.clientSessionId, 'push_device_type': profile.pushDeviceType, @@ -155,14 +160,16 @@ class SpoofingService { } static String _deriveUserAgent(SpoofProfile profile) { - final deviceType = - profile.deviceType.isEmpty ? 'ANDROID' : profile.deviceType; + final deviceType = profile.deviceType.isEmpty + ? 'ANDROID' + : profile.deviceType; final osVersion = profile.osVersion; final model = profile.deviceName.isEmpty ? 'K' : profile.deviceName; if (deviceType == 'IOS' || deviceType == 'iOS') { - final version = - osVersion.replaceAll(RegExp(r'[^0-9.]'), '').replaceAll('.', '_'); + final version = osVersion + .replaceAll(RegExp(r'[^0-9.]'), '') + .replaceAll('.', '_'); return 'Mozilla/5.0 (iPhone; CPU iPhone OS ' '${version.isEmpty ? '17_0' : version} like Mac OS X) ' 'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 ' @@ -181,10 +188,13 @@ class SpoofingService { final raw = prefs.getString(_profileKey(scope)); if (raw != null && raw.isNotEmpty) { try { - final profile = - SpoofProfile.fromJson(jsonDecode(raw) as Map); + final profile = SpoofProfile.fromJson( + jsonDecode(raw) as Map, + ); return _migrateVersion(prefs, scope, profile); - } catch (_) {} + } catch (e) { + logger.w('spoof profile read failed: $e'); + } } if (scope != pendingScope) { return _migrateLegacy(prefs, scope); @@ -248,13 +258,4 @@ class SpoofingService { } return sb.toString(); } - - static String _uuidV4() { - final b = List.generate(16, (_) => _rng.nextInt(256)); - b[6] = (b[6] & 0x0f) | 0x40; - b[8] = (b[8] & 0x3f) | 0x80; - String h(int i) => b[i].toRadixString(16).padLeft(2, '0'); - return '${h(0)}${h(1)}${h(2)}${h(3)}-${h(4)}${h(5)}-${h(6)}${h(7)}-' - '${h(8)}${h(9)}-${h(10)}${h(11)}${h(12)}${h(13)}${h(14)}${h(15)}'; - } } diff --git a/lib/core/transport/dispatcher.dart b/lib/core/transport/dispatcher.dart index 0a5f268..13ce1d8 100644 --- a/lib/core/transport/dispatcher.dart +++ b/lib/core/transport/dispatcher.dart @@ -7,13 +7,19 @@ import '../utils/logger.dart'; typedef PacketHandler = void Function(Packet packet); +class _PendingRequest { + _PendingRequest(this.completer, this.sentAt); + + final Completer completer; + final DateTime sentAt; +} + /// Роутер входящих пакетов. /// /// Ответы на запросы матчатся по seq (через [registerPending]), /// пуши — по opcode (через [registerHandler]). class PacketDispatcher { - final Map> _pendingRequests = {}; - final Map _requestTimestamps = {}; + final Map _pendingRequests = {}; final Map _pushHandlers = {}; final _pushController = StreamController.broadcast(); @@ -46,14 +52,13 @@ class PacketDispatcher { /// придёт пакет с совпадающим seq. Future registerPending(int seq) { final existing = _pendingRequests[seq]; - if (existing != null && !existing.isCompleted) { - existing.completeError( + if (existing != null && !existing.completer.isCompleted) { + existing.completer.completeError( StateError('seq=$seq переиспользован до получения ответа'), ); } final completer = Completer(); - _pendingRequests[seq] = completer; - _requestTimestamps[seq] = DateTime.now(); + _pendingRequests[seq] = _PendingRequest(completer, DateTime.now()); return completer.future; } @@ -80,7 +85,8 @@ class PacketDispatcher { ); if (packet.isError) { - final isSessionExpired = packet.payload is Map && + final isSessionExpired = + packet.payload is Map && packet.payload['message'] == 'FAIL_LOGIN_TOKEN'; final serverText = _serverErrorText(packet.payload); if (serverText != null && !isSessionExpired) { @@ -88,8 +94,8 @@ class PacketDispatcher { } } - final completer = _pendingRequests.remove(packet.seq); - _requestTimestamps.remove(packet.seq); + final pending = _pendingRequests.remove(packet.seq); + final completer = pending?.completer; if (completer == null) { if (packet.opcode != Opcode.ping) { @@ -116,7 +122,14 @@ class PacketDispatcher { logger.i( '<= push {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${payloadForLog(packet.payload)}}', ); - _pushHandlers[packet.opcode]?.call(packet); + final handler = _pushHandlers[packet.opcode]; + if (handler != null) { + try { + handler(packet); + } catch (e) { + logger.w('$tag handler failed: $e'); + } + } _pushController.add(packet); } } @@ -126,13 +139,13 @@ class PacketDispatcher { final now = DateTime.now(); final staleKeys = []; - _requestTimestamps.forEach((seq, ts) { - if (now.difference(ts).inSeconds > 30) staleKeys.add(seq); + _pendingRequests.forEach((seq, pending) { + if (now.difference(pending.sentAt).inSeconds > 30) staleKeys.add(seq); }); for (final seq in staleKeys) { - final completer = _pendingRequests.remove(seq); - _requestTimestamps.remove(seq); + final pending = _pendingRequests.remove(seq); + final completer = pending?.completer; if (completer != null && !completer.isCompleted) { completer.completeError(TimeoutException('Таймаут запроса seq=$seq')); } @@ -142,12 +155,11 @@ class PacketDispatcher { /// Обрывает все ожидающие запросы (при дисконнекте) void clearPending() { for (final entry in _pendingRequests.entries) { - if (!entry.value.isCompleted) { - entry.value.completeError(StateError('Соединение закрыто')); + if (!entry.value.completer.isCompleted) { + entry.value.completer.completeError(StateError('Соединение закрыто')); } } _pendingRequests.clear(); - _requestTimestamps.clear(); } void dispose() { diff --git a/lib/core/transport/proxy_connector.dart b/lib/core/transport/proxy_connector.dart index 71108cc..6770ad0 100644 --- a/lib/core/transport/proxy_connector.dart +++ b/lib/core/transport/proxy_connector.dart @@ -93,9 +93,7 @@ class ProxyConnector { throw SocketException('SOCKS5: неверная версия в ответе'); } if (reply[1] != 0x00) { - throw SocketException( - 'SOCKS5: ошибка подключения, код: ${reply[1]}', - ); + throw SocketException('SOCKS5: ошибка подключения, код: ${reply[1]}'); } // Пропускаем bind address @@ -125,10 +123,7 @@ class ProxyConnector { // ── HTTP CONNECT ──────────────────────────────────────────────────────── - Future _connectHttpConnect( - String targetHost, - int targetPort, - ) async { + Future _connectHttpConnect(String targetHost, int targetPort) async { final proxySocket = await RawSocket.connect(settings.host, settings.port); logger.i( 'HTTP CONNECT: подключено к прокси ${settings.host}:${settings.port}', @@ -177,9 +172,7 @@ class ProxyConnector { } final statusCode = int.tryParse(parts[1]) ?? 0; if (statusCode != 200) { - throw SocketException( - 'HTTP CONNECT: прокси вернул статус $statusCode', - ); + throw SocketException('HTTP CONNECT: прокси вернул статус $statusCode'); } logger.i('HTTP CONNECT: туннель к $targetHost:$targetPort установлен'); @@ -197,10 +190,7 @@ class ProxyConnector { ) async { ServerSocket? server; try { - server = await ServerSocket.bind( - InternetAddress.loopbackIPv4, - 0, - ); + server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); } catch (e) { io.dispose(); proxySocket.close(); @@ -223,11 +213,13 @@ class ProxyConnector { serverSide.listen( (data) { - unawaited(io.write(data).catchError((Object _) { - try { - serverSide.destroy(); - } catch (_) {} - })); + unawaited( + io.write(data).catchError((Object _) { + try { + serverSide.destroy(); + } catch (_) {} + }), + ); }, onError: (Object _) { proxySocket.shutdown(SocketDirection.send); @@ -303,9 +295,7 @@ class _RawSocketIO { case RawSocketEvent.closed: _closed = true; onClosed?.call(); - _readWaiter?.completeError( - SocketException('Прокси закрыл соединение'), - ); + _readWaiter?.completeError(SocketException('Прокси закрыл соединение')); _readWaiter = null; _writeWaiter?.completeError( SocketException('Прокси закрыл соединение'), @@ -328,8 +318,7 @@ class _RawSocketIO { _readWaiter = Completer(); await _readWaiter!.future.timeout( const Duration(seconds: 15), - onTimeout: () => - throw SocketException('Тайм-аут при чтении от прокси'), + onTimeout: () => throw SocketException('Тайм-аут при чтении от прокси'), ); } final result = Uint8List.fromList(_readBuffer.sublist(0, count)); diff --git a/lib/core/transport/receiver.dart b/lib/core/transport/receiver.dart index 7321133..1fcd198 100644 --- a/lib/core/transport/receiver.dart +++ b/lib/core/transport/receiver.dart @@ -1,33 +1,28 @@ import 'dart:typed_data'; import '../protocol/packet.dart'; -import '../utils/logger.dart'; -/// Буфер входящих данных. -/// Копит сырые байты из сокета, нарезает их на байтовые срезы целых пакетов. +class ReceiverOverflowException implements Exception { + final int size; + const ReceiverOverflowException(this.size); + @override + String toString() => 'PacketReceiver: переполнение буфера ($size B)'; +} + class PacketReceiver { Uint8List _buffer = Uint8List(0); int _start = 0; int _end = 0; - static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта + static const int _maxBufferSize = 2 * 1024 * 1024; - /// Добавляет байты в буфер и возвращает все собранные пакеты как сырые срезы. - /// Полностью синхронный — нарезка не блокируется на распаковке, поэтому - /// конкурентные вызовы из stream-листенера не могут пересечься на `_buffer`. - /// - /// Накопление идёт без перекопирования всего буфера на каждый чанк: целые - /// пакеты отдаются как `sublistView`, а потреблённый префикс отбрасывается - /// сдвигом указателя `_start`, а не пересборкой буфера. List feed(Uint8List data) { _append(data); if (_end - _start > _maxBufferSize) { - logger.e( - 'PacketReceiver: переполнение буфера (${_end - _start} B), сброс', - ); + final overflow = _end - _start; reset(); - return const []; + throw ReceiverOverflowException(overflow); } final packets = []; diff --git a/lib/core/transport/vpn_bypass.dart b/lib/core/transport/vpn_bypass.dart index a764811..2767a25 100644 --- a/lib/core/transport/vpn_bypass.dart +++ b/lib/core/transport/vpn_bypass.dart @@ -37,8 +37,9 @@ class VpnBypassService { static const String prefKey = 'dev_vpn_bypass'; - static const MethodChannel _channel = - MethodChannel('ru.komet.app/vpn_bypass'); + static const MethodChannel _channel = MethodChannel( + 'ru.komet.app/vpn_bypass', + ); bool _bound = false; @@ -70,8 +71,9 @@ class VpnBypassService { Future bind() async { VpnBypassResult result; try { - final res = await _channel - .invokeMapMethod('bindToNonVpnNetwork'); + final res = await _channel.invokeMapMethod( + 'bindToNonVpnNetwork', + ); final bound = res?['bound'] == true; _bound = bound; result = VpnBypassResult( @@ -97,8 +99,10 @@ class VpnBypassService { ); } if (result.bound) { - logger.i('VPN bypass: привязано к ${result.boundInterface} ' - '(${result.transport})'); + logger.i( + 'VPN bypass: привязано к ${result.boundInterface} ' + '(${result.transport})', + ); } else { logger.w('VPN bypass: обойти не удалось (${result.reason})'); } @@ -108,8 +112,9 @@ class VpnBypassService { Future _isVpnActive() async { try { - final res = await _channel - .invokeMapMethod('detectInterfaces'); + final res = await _channel.invokeMapMethod( + 'detectInterfaces', + ); if (res != null) { if (res['hasTun'] == true || res['hasVpn'] == true) return true; if (res.containsKey('hasTun')) return false; diff --git a/lib/core/utils/debouncer.dart b/lib/core/utils/debouncer.dart new file mode 100644 index 0000000..976b7bb --- /dev/null +++ b/lib/core/utils/debouncer.dart @@ -0,0 +1,20 @@ +import 'dart:async'; + +class Debouncer { + Debouncer(this.duration); + + final Duration duration; + Timer? _timer; + + void run(void Function() action) { + _timer?.cancel(); + _timer = Timer(duration, action); + } + + void cancel() { + _timer?.cancel(); + _timer = null; + } + + void dispose() => cancel(); +} diff --git a/lib/core/utils/debug_session_log.dart b/lib/core/utils/debug_session_log.dart index 15b5c02..11bf6f8 100644 --- a/lib/core/utils/debug_session_log.dart +++ b/lib/core/utils/debug_session_log.dart @@ -1,11 +1,11 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; import 'package:path_provider/path_provider.dart'; import '../protocol/opcode_map.dart'; +import 'log_redact.dart'; class _LogEntry { final int opcode; @@ -132,7 +132,7 @@ class DebugSessionLog { opcode: opcode, seq: seq, requestTime: DateTime.now(), - request: _redact(payload), + request: redactForLog(payload), ), ); if (_entries.length > _maxEntriesPerSession) { @@ -147,7 +147,7 @@ class DebugSessionLog { if (entry == null) return; entry.responseTime = DateTime.now(); entry.cmd = cmd; - entry.response = _redact(payload); + entry.response = redactForLog(payload); _scheduleFlush(); } @@ -270,7 +270,9 @@ class DebugSessionLog { for (var s = 0; s < lastN.length; s++) { final session = lastN[s]; buffer.writeln('=================================================='); - buffer.writeln('ЗАХОД #${s + 1} — ${session.startedAt.toIso8601String()}'); + buffer.writeln( + 'ЗАХОД #${s + 1} — ${session.startedAt.toIso8601String()}', + ); buffer.writeln( 'запросов: ${session.entries.length}' '${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}', @@ -329,37 +331,3 @@ String _cmdName(int? cmd) { return 'cmd$cmd'; } } - -bool _isTokenKey(String key) => key.toLowerCase().contains('token'); - -bool _isPhoneKey(String key) { - final k = key.toLowerCase(); - return k.contains('phone') || k == 'msisdn'; -} - -String _maskPhone(dynamic value) { - final text = value?.toString() ?? ''; - if (text.length <= 3) return text; - return '${text.substring(0, 3)}***'; -} - -dynamic _redact(dynamic value) { - if (value is Map) { - final out = {}; - value.forEach((k, v) { - final key = k.toString(); - if (_isTokenKey(key)) { - out[key] = '***'; - } else if (_isPhoneKey(key)) { - out[key] = _maskPhone(v); - } else { - out[key] = _redact(v); - } - }); - return out; - } - if (value is List) return value.map(_redact).toList(); - if (value is Uint8List) return ''; - if (value is num || value is bool || value is String) return value; - return value?.toString(); -} diff --git a/lib/core/utils/format.dart b/lib/core/utils/format.dart index c2f4f88..ee42945 100644 --- a/lib/core/utils/format.dart +++ b/lib/core/utils/format.dart @@ -1,4 +1,3 @@ -/// Shared formatting helpers (dates, durations, sizes, phone, gender). library; const List kRuMonthsShort = [ @@ -16,9 +15,33 @@ const List kRuMonthsShort = [ 'дек', ]; -String _two(int n) => n.toString().padLeft(2, '0'); +String pad2(int n) => n.toString().padLeft(2, '0'); + +String pluralRu(int n, String one, String few, String many) { + final mod100 = n % 100; + if (mod100 >= 11 && mod100 <= 14) return many; + switch (n % 10) { + case 1: + return one; + case 2: + case 3: + case 4: + return few; + default: + return many; + } +} + +String formatVoiceElapsed(int ms) { + final totalSec = ms ~/ 1000; + final m = totalSec ~/ 60; + final s = pad2(totalSec % 60); + final ds = (ms % 1000) ~/ 100; + return '$m:$s,$ds'; +} + +final RegExp _phoneNonDigits = RegExp(r'[^0-9]'); -/// "512 Б" / "1.5 КБ" / "3.2 МБ" / "1.1 ГБ" — Cyrillic units, 1 decimal. String formatBytes(int bytes) { if (bytes < 1024) return '$bytes Б'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} КБ'; @@ -28,38 +51,42 @@ String formatBytes(int bytes) { return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} ГБ'; } -/// "m:ss" (e.g. "3:07"); with [padMinutes] the minutes are zero-padded ("03:07"). String formatDurationMmSs(Duration d, {bool padMinutes = false}) { final m = d.inMinutes; - return '${padMinutes ? _two(m) : m}:${_two(d.inSeconds % 60)}'; + return '${padMinutes ? pad2(m) : m}:${pad2(d.inSeconds % 60)}'; } -/// "m:ss" from a raw seconds count. String formatSecondsMmSs(int seconds, {bool padMinutes = false}) => formatDurationMmSs(Duration(seconds: seconds), padMinutes: padMinutes); -/// "HH:mm" or "HH:mm:ss" when [withSeconds] is set. +String formatDurationClock(Duration d) { + final s = d.inSeconds; + final sec = pad2(s % 60); + final m = s ~/ 60; + if (m >= 60) return '${m ~/ 60}:${pad2(m % 60)}:$sec'; + return '$m:$sec'; +} + +String formatFileStamp(DateTime t) => + '${t.year}${pad2(t.month)}${pad2(t.day)}_' + '${pad2(t.hour)}${pad2(t.minute)}${pad2(t.second)}'; + String formatClock(DateTime dt, {bool withSeconds = false}) => withSeconds - ? '${_two(dt.hour)}:${_two(dt.minute)}:${_two(dt.second)}' - : '${_two(dt.hour)}:${_two(dt.minute)}'; + ? '${pad2(dt.hour)}:${pad2(dt.minute)}:${pad2(dt.second)}' + : '${pad2(dt.hour)}:${pad2(dt.minute)}'; -/// "5 мая 2024". String formatDateWords(DateTime dt) => '${dt.day} ${kRuMonthsShort[dt.month - 1]} ${dt.year}'; -/// "05.04.2024". String formatDateNumeric(DateTime dt) => - '${_two(dt.day)}.${_two(dt.month)}.${dt.year}'; + '${pad2(dt.day)}.${pad2(dt.month)}.${dt.year}'; -/// "05.04.2024 14:30". String formatDateTimeNumeric(DateTime dt) => '${formatDateNumeric(dt)} ${formatClock(dt)}'; -/// "5 мая 2024, 14:30". String formatDateTimeWords(DateTime dt) => '${formatDateWords(dt)}, ${formatClock(dt)}'; -/// "Был(-а) только что / N мин назад / N ч назад / N дн назад / 5 мая 2024". String formatLastSeen(int secondsSinceEpoch) { final dt = DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch * 1000); final diff = DateTime.now().difference(dt); @@ -70,14 +97,12 @@ String formatLastSeen(int secondsSinceEpoch) { return 'Был(-а) ${formatDateWords(dt)}'; } -/// "+7 (912) 345-67-89" for RU numbers, "+digits" otherwise. -/// Accepts an int phone or a string; returns null if there is no usable number. String? formatPhone(dynamic raw) { String? digits; if (raw is int && raw > 0) { digits = raw.toString(); } else if (raw is String && raw.isNotEmpty && raw != '***') { - digits = raw.replaceAll(RegExp(r'[^0-9]'), ''); + digits = raw.replaceAll(_phoneNonDigits, ''); if (digits.isEmpty) return null; } if (digits == null) return null; @@ -88,7 +113,6 @@ String? formatPhone(dynamic raw) { return '+$digits'; } -/// 1 → "Мужской", 2 → "Женский", anything else → null. String? formatGender(dynamic raw) { if (raw is! int) return null; if (raw == 1) return 'Мужской'; diff --git a/lib/core/utils/ids.dart b/lib/core/utils/ids.dart new file mode 100644 index 0000000..a5c8ced --- /dev/null +++ b/lib/core/utils/ids.dart @@ -0,0 +1,20 @@ +import 'dart:math'; + +final Random _rng = Random.secure(); + +String randomHex(int bytes) { + final sb = StringBuffer(); + for (var i = 0; i < bytes; i++) { + sb.write(_rng.nextInt(256).toRadixString(16).padLeft(2, '0')); + } + return sb.toString(); +} + +String uuidV4() { + final b = List.generate(16, (_) => _rng.nextInt(256)); + b[6] = (b[6] & 0x0f) | 0x40; + b[8] = (b[8] & 0x3f) | 0x80; + String h(int i) => b[i].toRadixString(16).padLeft(2, '0'); + return '${h(0)}${h(1)}${h(2)}${h(3)}-${h(4)}${h(5)}-${h(6)}${h(7)}-' + '${h(8)}${h(9)}-${h(10)}${h(11)}${h(12)}${h(13)}${h(14)}${h(15)}'; +} diff --git a/lib/core/utils/log_redact.dart b/lib/core/utils/log_redact.dart index caad69f..1b9a373 100644 --- a/lib/core/utils/log_redact.dart +++ b/lib/core/utils/log_redact.dart @@ -2,13 +2,7 @@ import 'package:flutter/foundation.dart'; const _redacted = '***'; -const _sensitiveSubstrings = [ - 'password', - 'token', - 'phone', - 'secret', - 'auth', -]; +const _sensitiveSubstrings = ['password', 'token', 'secret', 'auth']; const _sensitiveExact = { 'code', @@ -19,7 +13,6 @@ const _sensitiveExact = { 'pin', 'qrlink', 'text', - 'msisdn', 'deviceid', 'mt_instanceid', 'instanceid', @@ -36,14 +29,33 @@ bool _isSensitiveKey(Object? key) { return false; } +bool _isPhoneKey(Object? key) { + if (key is! String) return false; + final k = key.toLowerCase(); + return k.contains('phone') || k == 'msisdn'; +} + +String _maskPhone(dynamic value) { + final text = value?.toString() ?? ''; + if (text.length <= 3) return text; + return '${text.substring(0, 3)}***'; +} + dynamic redactForLog(dynamic value) { if (value is Map) { final out = {}; value.forEach((k, v) { - out[k] = _isSensitiveKey(k) ? _redacted : redactForLog(v); + if (_isPhoneKey(k)) { + out[k] = _maskPhone(v); + } else { + out[k] = _isSensitiveKey(k) ? _redacted : redactForLog(v); + } }); return out; } + if (value is Uint8List) { + return ''; + } if (value is List) { return value.map(redactForLog).toList(); } diff --git a/lib/core/utils/media_cache.dart b/lib/core/utils/media_cache.dart index f165f7b..8a783d3 100644 --- a/lib/core/utils/media_cache.dart +++ b/lib/core/utils/media_cache.dart @@ -17,12 +17,15 @@ class MediaCache { static Directory? _dir; static int? _cachedSize; + static final Map> _inFlight = {}; static Future _cacheDir() async { final cached = _dir; if (cached != null) return cached; final base = await getApplicationSupportDirectory(); - final dir = Directory(p.join(base.path, 'media_cache${AppInstance.suffix}')); + final dir = Directory( + p.join(base.path, 'media_cache${AppInstance.suffix}'), + ); if (!await dir.exists()) { await dir.create(recursive: true); } @@ -63,6 +66,23 @@ class MediaCache { final existingFile = await existing(name); if (existingFile != null) return existingFile; + final running = _inFlight[name]; + if (running != null) return running; + + final future = _download(name, url, onProgress); + _inFlight[name] = future; + try { + return await future; + } finally { + _inFlight.remove(name); + } + } + + static Future _download( + String name, + String url, + void Function(double progress)? onProgress, + ) async { final file = await fileFor(name); final part = File('${file.path}.part'); final client = HttpClient(); @@ -166,8 +186,9 @@ class MediaCache { } } - files.sort((a, b) => - a.statSync().modified.compareTo(b.statSync().modified)); + files.sort( + (a, b) => a.statSync().modified.compareTo(b.statSync().modified), + ); for (final file in files) { if (total <= limit) break; diff --git a/lib/core/utils/names.dart b/lib/core/utils/names.dart new file mode 100644 index 0000000..505f34f --- /dev/null +++ b/lib/core/utils/names.dart @@ -0,0 +1,6 @@ +String displayName(Object? first, Object? last, {String fallback = ''}) { + final f = first?.toString().trim() ?? ''; + final l = last?.toString().trim() ?? ''; + final full = [f, l].where((s) => s.isNotEmpty).join(' '); + return full.isEmpty ? fallback : full; +} diff --git a/lib/core/utils/parse.dart b/lib/core/utils/parse.dart new file mode 100644 index 0000000..58fa459 --- /dev/null +++ b/lib/core/utils/parse.dart @@ -0,0 +1,9 @@ +int? parseIntOrNull(Object? v) { + if (v is int) return v; + if (v is num) return v.toInt(); + if (v is String) return int.tryParse(v); + return null; +} + +List parseIntList(Object? v) => + v is List ? v.map(parseIntOrNull).whereType().toList() : const []; diff --git a/lib/frontend/commands/info_command.dart b/lib/frontend/commands/info_command.dart index 97d4dc2..f5c77fc 100644 --- a/lib/frontend/commands/info_command.dart +++ b/lib/frontend/commands/info_command.dart @@ -1,5 +1,6 @@ import '../../core/cache/info_cache.dart'; import '../../core/utils/format.dart'; +import '../../models/contact_info.dart'; import 'slash_command.dart'; Future runInfo(CommandContext ctx) async { @@ -26,30 +27,19 @@ Future runInfo(CommandContext ctx) async { ); } -String _summary(Map c, int targetId) { - final flags = (c['options'] as List?)?.whereType().toList() ?? const []; - final region = (c['country'] as String?)?.trim(); +String _summary(ContactInfo c, int targetId) { + final flags = c.options; + final region = (c.raw['country'] as String?)?.trim(); - return 'Никнейм: ${_nick(c)}\n' - 'Дата регистрации: ${_date(c['registrationTime'])}\n' - 'Дата последнего изменения профиля: ${_date(c['updateTime'])}\n' - 'id: ${c['id'] ?? targetId}\n' + return 'Никнейм: ${c.displayName ?? '—'}\n' + 'Дата регистрации: ${_date(c.raw['registrationTime'])}\n' + 'Дата последнего изменения профиля: ${_date(c.raw['updateTime'])}\n' + 'id: ${c.id ?? targetId}\n' 'Регион: ${region == null || region.isEmpty ? '—' : region}\n' 'Флаги: ${flags.isEmpty ? '—' : flags.join(', ')}\n' 'ip: not fetched'; } -String _nick(Map c) { - final names = c['names']; - if (names is List && names.isNotEmpty && names.first is Map) { - final n = names.first as Map; - final name = (n['name'] as String?) ?? - '${n['firstName'] ?? ''} ${n['lastName'] ?? ''}'.trim(); - if (name.isNotEmpty) return name; - } - return '—'; -} - String _date(dynamic ms) { if (ms is! int || ms <= 0) return '—'; return formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(ms)); diff --git a/lib/frontend/debug/cache_section.dart b/lib/frontend/debug/cache_section.dart new file mode 100644 index 0000000..eeacb77 --- /dev/null +++ b/lib/frontend/debug/cache_section.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../core/utils/format.dart'; + +class DebugCacheSection extends StatelessWidget { + final int cacheSize; + final bool clearingCache; + final String cacheLimitLabel; + final VoidCallback onPickCacheLimit; + final VoidCallback onClearCache; + + const DebugCacheSection({ + super.key, + required this.cacheSize, + required this.clearingCache, + required this.cacheLimitLabel, + required this.onPickCacheLimit, + required this.onClearCache, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: onPickCacheLimit, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.data_usage, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Лимит кэша медиа', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + cacheLimitLabel, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Icon( + Symbols.chevron_right, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + ], + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: clearingCache ? null : onClearCache, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.delete_sweep, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Очистить кэш медиа', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + clearingCache + ? 'Очистка…' + : 'Занято: ${formatBytes(cacheSize)}', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + if (clearingCache) + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/frontend/debug/debug_toggle_tile.dart b/lib/frontend/debug/debug_toggle_tile.dart new file mode 100644 index 0000000..a38057f --- /dev/null +++ b/lib/frontend/debug/debug_toggle_tile.dart @@ -0,0 +1,70 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../widgets/glossy_pill.dart'; + +class DebugToggleTile extends StatelessWidget { + final IconData icon; + final String title; + final String Function(bool value)? subtitle; + final ValueListenable valueListenable; + final ValueChanged onChanged; + + const DebugToggleTile({ + super.key, + required this.icon, + required this.title, + this.subtitle, + required this.valueListenable, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return ValueListenableBuilder( + valueListenable: valueListenable, + builder: (context, value, _) { + final resolvedSubtitle = subtitle?.call(value); + return GlossyPill( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + depth: 6, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + if (resolvedSubtitle != null) ...[ + const SizedBox(height: 2), + Text( + resolvedSubtitle, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ], + ), + ), + Switch(value: value, onChanged: onChanged), + ], + ), + ); + }, + ); + } +} diff --git a/lib/frontend/debug/feature_toggles_section.dart b/lib/frontend/debug/feature_toggles_section.dart new file mode 100644 index 0000000..85ff610 --- /dev/null +++ b/lib/frontend/debug/feature_toggles_section.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../core/config/app_commands.dart'; +import '../../core/config/app_digital_id_mode.dart'; +import '../../core/config/app_link_preview.dart'; +import '../../core/config/app_pranks.dart'; +import '../../core/config/app_show_extra_info.dart'; +import '../../core/config/app_stories.dart'; +import '../../core/config/app_swipe_back_desktop.dart'; +import '../screens/digital_id/digital_id_web_screen.dart'; +import '../widgets/custom_notification.dart'; +import 'debug_toggle_tile.dart'; + +class DebugFeatureTogglesSection extends StatelessWidget { + const DebugFeatureTogglesSection({super.key}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: DebugToggleTile( + icon: Symbols.swipe_right, + title: 'Свайп-назад в десктоп-режиме', + subtitle: (_) => + 'Включает жест «провести от левого края, чтобы ' + 'закрыть» внутри встроенной панели чата на ' + 'десктопе — для тестирования курсором', + valueListenable: AppSwipeBackDesktop.current, + onChanged: AppSwipeBackDesktop.save, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: DebugToggleTile( + icon: Symbols.auto_awesome, + title: 'Приколь4ики', + valueListenable: AppPranks.current, + onChanged: AppPranks.save, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: DebugToggleTile( + icon: Symbols.badge, + title: 'Нативный Цифровой ID', + subtitle: (native) => native + ? 'Нативный экран (REST ext-api.max.ru)' + : 'Оригинальная страница в WebView', + valueListenable: AppDigitalIdNative.current, + onChanged: AppDigitalIdNative.save, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () async { + await resetDigitalIdWebData(); + if (!context.mounted) return; + showCustomNotification( + context, + 'Цифровой ID сброшен — Госуслуги спросят вход заново', + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.restart_alt, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Сбросить Цифровой ID', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Очистить куки и данные WebView', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: DebugToggleTile( + icon: Symbols.amp_stories, + title: 'Истории', + subtitle: (_) => 'Отображение ленты историй в списке чатов', + valueListenable: AppStories.current, + onChanged: AppStories.save, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: DebugToggleTile( + icon: Symbols.terminal, + title: 'Команды', + subtitle: (_) => 'Панель команд по вводу «/» в строке сообщения', + valueListenable: AppCommands.current, + onChanged: AppCommands.save, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: DebugToggleTile( + icon: Symbols.link, + title: 'Предпросмотр ссылок', + subtitle: (_) => 'Карточки с превью для ссылок в сообщениях', + valueListenable: AppLinkPreview.current, + onChanged: AppLinkPreview.save, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: DebugToggleTile( + icon: Symbols.info, + title: 'Доп. информация', + subtitle: (_) => + 'Раздел «Info» в настройках и вкладка с ' + 'технической информацией в профиле собеседника', + valueListenable: AppShowExtraInfo.current, + onChanged: AppShowExtraInfo.save, + ), + ), + ], + ); + } +} diff --git a/lib/frontend/debug/header_section.dart b/lib/frontend/debug/header_section.dart new file mode 100644 index 0000000..e0bc0cd --- /dev/null +++ b/lib/frontend/debug/header_section.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class DebugHeaderSection extends StatelessWidget { + const DebugHeaderSection({super.key}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Row( + children: [ + IconButton( + icon: Icon( + Symbols.arrow_back, + color: cs.onSurface, + size: 24, + weight: 400, + ), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + 'Для разработчиков', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ), + ], + ); + } +} diff --git a/lib/frontend/debug/id_search_section.dart b/lib/frontend/debug/id_search_section.dart new file mode 100644 index 0000000..b8ee1d3 --- /dev/null +++ b/lib/frontend/debug/id_search_section.dart @@ -0,0 +1,431 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../widgets/custom_notification.dart'; +import '../widgets/glossy_pill.dart'; + +class DebugIdSearchSection extends StatelessWidget { + final TextEditingController idController; + final bool isSearching; + final bool hasSearched; + final List hits; + final Map errors; + final VoidCallback onSearch; + + const DebugIdSearchSection({ + super.key, + required this.idController, + required this.isSearching, + required this.hasSearched, + required this.hits, + required this.errors, + required this.onSearch, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return GlossyPill( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + depth: 6, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Поиск по ID', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + 'Параллельно: contactInfo (32) + chatInfo (48) + publicSearch (60)', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: idController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: 'Введите ID', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + onSubmitted: (_) => onSearch(), + ), + ), + const SizedBox(width: 12), + FilledButton( + onPressed: isSearching ? null : onSearch, + child: isSearching + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Symbols.search, size: 20), + ), + ], + ), + if (hasSearched && !isSearching) ...[ + const SizedBox(height: 12), + if (hits.isEmpty && errors.isEmpty) + Padding( + padding: const EdgeInsets.all(12), + child: Text( + 'Ничего не найдено', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ), + for (final hit in hits) ...[ + _SearchResultCard(hit: hit), + const SizedBox(height: 8), + ], + for (final entry in errors.entries) ...[ + _ErrorChip(label: entry.key, message: entry.value), + const SizedBox(height: 6), + ], + ], + ], + ), + ); + } +} + +enum HitKind { dialog, chat, channel, bot, official, contact, user, unknown } + +class SearchHit { + final String source; + final int id; + final String title; + final String? subtitle; + final String? avatarUrl; + final List badges; + final bool isChatEntity; + + SearchHit({ + required this.source, + required this.id, + required this.title, + required this.avatarUrl, + required this.badges, + required this.isChatEntity, + this.subtitle, + }); + + static SearchHit? fromContact(String source, Map raw) { + final id = raw['id']; + if (id is! int) return null; + final namesRaw = raw['names']; + String title = 'User #$id'; + if (namesRaw is List && namesRaw.isNotEmpty) { + final n = namesRaw.first; + if (n is Map) { + final full = n['name']?.toString(); + if (full != null && full.isNotEmpty) title = full; + } + } + final opts = (raw['options'] is List) + ? (raw['options'] as List).whereType().toSet() + : {}; + final badges = []; + if (opts.contains('BOT')) badges.add(HitKind.bot); + if (opts.contains('OFFICIAL')) badges.add(HitKind.official); + if (badges.isEmpty) badges.add(HitKind.contact); + return SearchHit( + source: source, + id: id, + title: title, + subtitle: (raw['description'] as String?)?.trim().isNotEmpty == true + ? raw['description'] as String + : (raw['phone'] != null ? 'Телефон скрыт' : null), + avatarUrl: raw['baseUrl'] as String?, + badges: badges, + isChatEntity: false, + ); + } + + static SearchHit? fromChat(String source, Map raw) { + final id = raw['id']; + if (id is! int) return null; + final type = (raw['type'] as String?) ?? 'CHAT'; + final title = (raw['title'] as String?) ?? 'Chat #$id'; + final pCount = raw['participantsCount'] as int?; + final badges = []; + switch (type) { + case 'DIALOG': + badges.add(HitKind.dialog); + case 'CHANNEL': + badges.add(HitKind.channel); + case 'CHAT': + badges.add(HitKind.chat); + default: + badges.add(HitKind.unknown); + } + final opts = raw['options']; + if (opts is Map && opts['OFFICIAL'] == true) { + badges.add(HitKind.official); + } + String? subtitle; + if (type == 'CHANNEL') { + subtitle = pCount != null ? 'Канал · $pCount подписч.' : 'Канал'; + } else if (type == 'CHAT') { + subtitle = pCount != null ? 'Группа · $pCount участн.' : 'Группа'; + } else { + subtitle = 'Диалог'; + } + return SearchHit( + source: source, + id: id, + title: title, + subtitle: subtitle, + avatarUrl: raw['baseIconUrl'] as String?, + badges: badges, + isChatEntity: true, + ); + } +} + +class _SearchResultCard extends StatelessWidget { + final SearchHit hit; + const _SearchResultCard({required this.hit}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + ), + padding: const EdgeInsets.fromLTRB(12, 10, 8, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _HitAvatar(hit: hit, cs: cs), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Flexible( + child: Text( + hit.title, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + for (final b in hit.badges) ...[ + const SizedBox(width: 6), + _BadgeChip(kind: b, cs: cs), + ], + ], + ), + if (hit.subtitle != null) ...[ + const SizedBox(height: 2), + Text( + hit.subtitle!, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 2), + Row( + children: [ + Text( + 'id: ${hit.id}', + style: TextStyle( + color: cs.outline, + fontSize: 11, + fontFamily: 'monospace', + ), + ), + const SizedBox(width: 8), + Text( + 'via ${hit.source}', + style: TextStyle(color: cs.outline, fontSize: 11), + ), + ], + ), + ], + ), + ), + IconButton( + tooltip: 'Скопировать id', + icon: Icon( + Symbols.content_copy, + size: 18, + color: cs.onSurfaceVariant, + ), + onPressed: () async { + await Clipboard.setData(ClipboardData(text: hit.id.toString())); + if (context.mounted) { + showCustomNotification(context, 'id скопирован'); + } + }, + ), + ], + ), + ); + } +} + +class _HitAvatar extends StatelessWidget { + final SearchHit hit; + final ColorScheme cs; + const _HitAvatar({required this.hit, required this.cs}); + + @override + Widget build(BuildContext context) { + const size = 44.0; + final url = hit.avatarUrl; + if (url != null && url.isNotEmpty) { + return ClipOval( + child: CachedNetworkImage( + imageUrl: url, + width: size, + height: size, + fit: BoxFit.cover, + placeholder: (_, _) => _fallback(), + errorWidget: (_, _, _) => _fallback(), + ), + ); + } + return _fallback(); + } + + Widget _fallback() { + final initial = hit.title.isNotEmpty ? hit.title[0].toUpperCase() : '?'; + return Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: cs.primaryContainer, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text( + initial, + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +class _BadgeChip extends StatelessWidget { + final HitKind kind; + final ColorScheme cs; + const _BadgeChip({required this.kind, required this.cs}); + + @override + Widget build(BuildContext context) { + String label; + Color bg; + Color fg; + switch (kind) { + case HitKind.bot: + label = 'Bot'; + bg = cs.tertiaryContainer; + fg = cs.onTertiaryContainer; + case HitKind.official: + label = '✓'; + bg = cs.primary; + fg = cs.onPrimary; + case HitKind.contact: + label = 'Контакт'; + bg = cs.surface; + fg = cs.onSurfaceVariant; + case HitKind.user: + label = 'User'; + bg = cs.surface; + fg = cs.onSurfaceVariant; + case HitKind.dialog: + label = 'Диалог'; + bg = cs.secondaryContainer; + fg = cs.onSecondaryContainer; + case HitKind.chat: + label = 'Группа'; + bg = cs.secondaryContainer; + fg = cs.onSecondaryContainer; + case HitKind.channel: + label = 'Канал'; + bg = cs.tertiaryContainer; + fg = cs.onTertiaryContainer; + case HitKind.unknown: + label = '?'; + bg = cs.surface; + fg = cs.onSurfaceVariant; + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + label, + style: TextStyle(color: fg, fontSize: 10, fontWeight: FontWeight.w600), + ), + ); + } +} + +class _ErrorChip extends StatelessWidget { + final String label; + final String message; + const _ErrorChip({required this.label, required this.message}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: cs.errorContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Icon(Symbols.error_outline, size: 16, color: cs.onErrorContainer), + const SizedBox(width: 8), + Expanded( + child: Text( + '$label: $message', + style: TextStyle(color: cs.onErrorContainer, fontSize: 12), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} diff --git a/lib/frontend/debug/network_section.dart b/lib/frontend/debug/network_section.dart new file mode 100644 index 0000000..d169304 --- /dev/null +++ b/lib/frontend/debug/network_section.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../main.dart'; +import '../screens/profile/traffic_monitor_screen.dart'; +import '../widgets/connection_status.dart'; +import 'debug_toggle_tile.dart'; + +class DebugNetworkSection extends StatelessWidget { + final KometAppState? appState; + + const DebugNetworkSection({super.key, required this.appState}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final state = appState; + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: state == null + ? const SizedBox.shrink() + : DebugToggleTile( + icon: Symbols.speed, + title: 'Оверлей FPS', + subtitle: (_) => + 'Показ текущего фреймрейта поверх интерфейса', + valueListenable: state.fpsOverlayEnabled, + onChanged: state.setFpsOverlayEnabled, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: state == null + ? const SizedBox.shrink() + : DebugToggleTile( + icon: Symbols.vpn_key_off, + title: 'Обход VPN', + subtitle: (_) => + 'Если обнаружен VPN (tun-интерфейс), ' + 'подключаться напрямую через Wi-Fi или ' + 'моб. сеть в обход туннеля. Только Android', + valueListenable: state.vpnBypassEnabled, + onChanged: state.setVpnBypassEnabled, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: DebugToggleTile( + icon: Symbols.wifi_off, + title: 'Офлайн (тест)', + subtitle: (_) => + 'Показать индикаторы соединения во всех ' + 'экранах, не разрывая реальную сессию', + valueListenable: debugForceOffline, + onChanged: (v) => debugForceOffline.value = v, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: state == null + ? const SizedBox.shrink() + : DebugToggleTile( + icon: Symbols.gpp_bad, + title: 'Отключить проверку TLS', + subtitle: (_) => + 'Принимать любой сертификат сервера. ' + 'Только для отладки через MitM-прокси — ' + 'соединение становится уязвимым к ' + 'перехвату трафика', + valueListenable: state.tlsInsecureEnabled, + onChanged: state.setTlsInsecureEnabled, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const TrafficMonitorScreen()), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.lan, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Монитор трафика', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Реалтайм: домены, опкоды и payload внутри ' + 'сокет-соединения', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Icon( + Symbols.chevron_right, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + ], + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/frontend/debug/previews_section.dart b/lib/frontend/debug/previews_section.dart new file mode 100644 index 0000000..6befc07 --- /dev/null +++ b/lib/frontend/debug/previews_section.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../core/storage/app_database.dart'; +import '../screens/calls/call_screen.dart'; +import '../widgets/glossy_pill.dart'; +import '../widgets/login_success_screen.dart'; + +class DebugPreviewsSection extends StatelessWidget { + final bool micSignalOn; + final ValueChanged onMicSignalChanged; + + const DebugPreviewsSection({ + super.key, + required this.micSignalOn, + required this.onMicSignalChanged, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () async { + final profile = await AppDatabase.loadActiveProfile(); + if (!context.mounted) return; + final avatar = await precacheLoginAvatar( + context, + profile?.baseUrl, + ); + if (!context.mounted) return; + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + LoginSuccessScreen(preview: true, avatar: avatar), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.celebration, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'test hello', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Показать приветственную анимацию входа', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Icon( + Symbols.chevron_right, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + ], + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: GlossyPill( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + depth: 6, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Экран звонка', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + 'Превью экранов звонков', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + const SizedBox(height: 12), + _DebugCallButton( + label: 'Экран звонка (превью)', + icon: Symbols.phone, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const CallScreen(name: 'Кирил Г.'), + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Сигнал микрофона (тест)', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Шлёт change-media-settings в активный звонок, ' + 'не меняя реальный микрофон', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Switch(value: micSignalOn, onChanged: onMicSignalChanged), + ], + ), + ], + ), + ), + ), + ], + ); + } +} + +class _DebugCallButton extends StatelessWidget { + final String label; + final IconData icon; + final VoidCallback onTap; + + const _DebugCallButton({ + required this.label, + required this.icon, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22, fill: 1), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/frontend/debug/quick_actions_section.dart b/lib/frontend/debug/quick_actions_section.dart new file mode 100644 index 0000000..5758d1a --- /dev/null +++ b/lib/frontend/debug/quick_actions_section.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../screens/auth/login_screen.dart'; + +class DebugQuickActionsSection extends StatelessWidget { + final VoidCallback onExportLog; + + const DebugQuickActionsSection({super.key, required this.onExportLog}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: onExportLog, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.bug_report, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Отладочный лог', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Все запросы за последние 3 захода в приложение', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Icon( + Symbols.save_alt, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + ], + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const LoginScreen()), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.dialpad, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Экран ввода номера', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Открыть без выхода из аккаунта и обрыва сессии', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Icon( + Symbols.chevron_right, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + ], + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/frontend/debug/sync_probe_section.dart b/lib/frontend/debug/sync_probe_section.dart new file mode 100644 index 0000000..48c696b --- /dev/null +++ b/lib/frontend/debug/sync_probe_section.dart @@ -0,0 +1,180 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../../main.dart'; +import '../widgets/glossy_pill.dart'; + +class DebugSyncProbeSection extends StatefulWidget { + const DebugSyncProbeSection({super.key}); + + @override + State createState() => _DebugSyncProbeSectionState(); +} + +class _DebugSyncProbeSectionState extends State { + final _phoneController = TextEditingController(); + final _nameController = TextEditingController(); + bool _loading = false; + String? _result; + + @override + void dispose() { + _phoneController.dispose(); + _nameController.dispose(); + super.dispose(); + } + + Future _send() async { + final phone = _phoneController.text.trim(); + final name = _nameController.text.trim(); + if (phone.isEmpty) { + setState(() => _result = 'Введите номер'); + return; + } + setState(() { + _loading = true; + _result = null; + }); + try { + final packet = await api.sendRequest(Opcode.sync, { + 'contactList': { + phone: {'firstName': name}, + }, + }); + if (!mounted) return; + setState(() { + _loading = false; + _result = _pretty(packet.payload); + }); + } on PacketError catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _result = 'PacketError: ${e.message}'; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _result = 'Ошибка: $e'; + }); + } + } + + String _pretty(dynamic payload) { + const encoder = JsonEncoder.withIndent(' '); + try { + return encoder.convert(_jsonSafe(payload)); + } catch (_) { + return payload.toString(); + } + } + + dynamic _jsonSafe(dynamic v) { + if (v is Map) { + return v.map((k, val) => MapEntry(k.toString(), _jsonSafe(val))); + } + if (v is List) return v.map(_jsonSafe).toList(); + if (v is String || v is num || v is bool || v == null) return v; + return v.toString(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return GlossyPill( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + depth: 6, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sync contactList (21)', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + 'Резолв контакта по номеру и имени, полный ответ сервера', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + ), + const SizedBox(height: 12), + TextField( + controller: _phoneController, + keyboardType: TextInputType.phone, + enabled: !_loading, + decoration: InputDecoration( + hintText: '+6282233831826', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + ), + ), + const SizedBox(height: 10), + TextField( + controller: _nameController, + enabled: !_loading, + decoration: InputDecoration( + hintText: 'Имя', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + ), + ), + const SizedBox(height: 12), + FilledButton( + onPressed: _loading ? null : _send, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(44), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Отправить'), + ), + if (_result != null) ...[ + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: SelectableText( + _result!, + style: TextStyle( + color: cs.onSurface, + fontSize: 12, + fontFamily: 'monospace', + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 57a5db3..8e4484b 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -4,6 +4,7 @@ import 'package:komet/l10n/app_localizations.dart'; import 'package:flutter/services.dart'; import 'password_2fa_screen.dart'; import 'registration_screen.dart'; +import 'session_stale_recovery.dart'; import '../../../backend/api.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/utils/sms_code_listener.dart'; @@ -28,7 +29,7 @@ class CodeConfirmationScreen extends StatefulWidget { } class _CodeConfirmationScreenState extends State - with TickerProviderStateMixin { + with TickerProviderStateMixin, SessionStaleRecovery { final TextEditingController _codeController = TextEditingController(); final FocusNode _focusNode = FocusNode(); final SmsCodeListener _smsListener = SmsCodeListener(); @@ -45,21 +46,17 @@ class _CodeConfirmationScreenState extends State AnimationStatusListener? _routeAnimationListener; late String _token; - late int _epoch; - StreamSubscription? _stateSub; - bool _recovering = false; bool _verifying = false; - bool _dropNotified = false; - bool get _sessionStale => - api.sessionEpoch != _epoch || api.state != SessionState.online; + @override + String get connectionDroppedMessage => + 'Соединение прервалось, восстанавливаем…'; @override void initState() { super.initState(); _token = widget.token; - _epoch = api.sessionEpoch; - _stateSub = api.stateStream.listen(_onSessionState); + startSessionRecovery(); _startTimer(); _listenForSmsCode(); @@ -89,7 +86,7 @@ class _CodeConfirmationScreenState extends State _routeAnimation?.removeStatusListener(_routeAnimationListener!); } _smsListener.dispose(); - _stateSub?.cancel(); + stopSessionRecovery(); _timer?.cancel(); _errorTimer?.cancel(); _shakeController.dispose(); @@ -98,24 +95,10 @@ class _CodeConfirmationScreenState extends State super.dispose(); } - void _onSessionState(SessionState state) { - if (!mounted) return; - if (state != SessionState.online) { - if (!_dropNotified) { - _dropNotified = true; - showCustomNotification( - context, - 'Соединение прервалось, восстанавливаем…', - ); - } - return; - } - if (api.sessionEpoch != _epoch) _recoverStaleSession(); - } - - Future _recoverStaleSession() async { - if (_recovering) return; - setState(() => _recovering = true); + @override + Future recoverStaleSession() async { + if (recovering) return; + setState(() => recovering = true); try { if (api.state != SessionState.online) { final back = await api.stateStream @@ -135,8 +118,8 @@ class _CodeConfirmationScreenState extends State if (!mounted) return; setState(() { _token = fresh.token; - _epoch = api.sessionEpoch; - _dropNotified = false; + sessionEpoch = api.sessionEpoch; + dropNotified = false; _codeController.clear(); _errorMessage = null; }); @@ -151,7 +134,7 @@ class _CodeConfirmationScreenState extends State showCustomNotification(context, 'Не удалось обновить код: $e'); } } finally { - if (mounted) setState(() => _recovering = false); + if (mounted) setState(() => recovering = false); } } @@ -207,7 +190,7 @@ class _CodeConfirmationScreenState extends State } void _applyAutoCode(String code) { - if (_verifying || _recovering) return; + if (_verifying || recovering) return; if (_codeController.text == code) return; _codeController.text = code; _codeController.selection = TextSelection.collapsed(offset: code.length); @@ -232,10 +215,10 @@ class _CodeConfirmationScreenState extends State } Future _verifyCode() async { - if (_codeController.text.length != 6 || _recovering || _verifying) return; + if (_codeController.text.length != 6 || recovering || _verifying) return; - if (_sessionStale) { - _recoverStaleSession(); + if (sessionStale) { + recoverStaleSession(); return; } @@ -308,8 +291,8 @@ class _CodeConfirmationScreenState extends State ); } catch (e) { if (!mounted) return; - if (!verified && (isSessionStateError(e) || _sessionStale)) { - _recoverStaleSession(); + if (!verified && (isSessionStateError(e) || sessionStale)) { + recoverStaleSession(); } else { _showError(e.toString()); } @@ -529,7 +512,7 @@ class _CodeConfirmationScreenState extends State ), const SizedBox(width: 16), FloatingActionButton( - onPressed: (_recovering || _verifying) + onPressed: (recovering || _verifying) ? null : () { if (_codeController.text.length == 6) _verifyCode(); @@ -541,7 +524,7 @@ class _CodeConfirmationScreenState extends State shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(50), ), - child: _recovering + child: recovering ? SizedBox( width: 24, height: 24, diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 8c268a5..398b8de 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -66,7 +66,11 @@ class _LoginScreenState extends State { await resetDigitalIdSession(); try { await accountModule.switchAccount(returnId); - } catch (_) {} + } catch (_) { + if (!mounted) return; + showCustomNotification(context, 'Не удалось переключить аккаунт'); + return; + } if (!mounted) return; await Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute(builder: (_) => const AdaptiveShell()), @@ -151,7 +155,7 @@ class _LoginScreenState extends State { String _countryDisplayName(CountryName country) { final lang = Localizations.localeOf(context).languageCode; - return lang == 'ru' ? country.ru : country.en; + return country.displayName(lang); } String _phoneMaskHint(CountryName country) { diff --git a/lib/frontend/screens/auth/password_2fa_screen.dart b/lib/frontend/screens/auth/password_2fa_screen.dart index 89cf530..8ab80c5 100644 --- a/lib/frontend/screens/auth/password_2fa_screen.dart +++ b/lib/frontend/screens/auth/password_2fa_screen.dart @@ -1,10 +1,9 @@ -import 'dart:async'; import 'package:flutter/material.dart'; -import '../../../backend/api.dart'; import '../../../core/protocol/packet.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/login_success_screen.dart'; +import 'session_stale_recovery.dart'; class Password2FAScreen extends StatefulWidget { final String trackId; @@ -16,60 +15,41 @@ class Password2FAScreen extends StatefulWidget { State createState() => _Password2FAScreenState(); } -class _Password2FAScreenState extends State { +class _Password2FAScreenState extends State + with SessionStaleRecovery { final TextEditingController _passwordController = TextEditingController(); bool _isPasswordVisible = false; bool _isLoading = false; - late int _epoch; - StreamSubscription? _stateSub; - bool _recovering = false; - bool _dropNotified = false; - - bool get _sessionStale => - api.sessionEpoch != _epoch || api.state != SessionState.online; + @override + String get connectionDroppedMessage => 'Соединение прервалось…'; @override void initState() { super.initState(); - _epoch = api.sessionEpoch; - _stateSub = api.stateStream.listen(_onSessionState); + startSessionRecovery(); } @override void dispose() { - _stateSub?.cancel(); + stopSessionRecovery(); _passwordController.dispose(); super.dispose(); } - void _onSessionState(SessionState state) { - if (!mounted) return; - if (state != SessionState.online) { - if (!_dropNotified) { - _dropNotified = true; - showCustomNotification(context, 'Соединение прервалось…'); - } - return; - } - if (api.sessionEpoch != _epoch) _recoverStaleSession(); - } - - void _recoverStaleSession() { - if (_recovering || !mounted) return; - _recovering = true; - showCustomNotification( - context, - 'Соединение прервалось — войдите заново', - ); + @override + void recoverStaleSession() { + if (recovering || !mounted) return; + recovering = true; + showCustomNotification(context, 'Соединение прервалось — войдите заново'); Navigator.of(context).pop(); } Future _checkPassword() async { - if (_passwordController.text.isEmpty || _isLoading || _recovering) return; + if (_passwordController.text.isEmpty || _isLoading || recovering) return; - if (_sessionStale) { - _recoverStaleSession(); + if (sessionStale) { + recoverStaleSession(); return; } @@ -115,8 +95,8 @@ class _Password2FAScreenState extends State { _isLoading = false; }); - if (!passed && (isSessionStateError(e) || _sessionStale)) { - _recoverStaleSession(); + if (!passed && (isSessionStateError(e) || sessionStale)) { + recoverStaleSession(); } else { showCustomNotification(context, 'Неверный пароль: $e'); } diff --git a/lib/frontend/screens/auth/proxy_settings_sheet.dart b/lib/frontend/screens/auth/proxy_settings_sheet.dart index 2e920b1..f55ea1b 100644 --- a/lib/frontend/screens/auth/proxy_settings_sheet.dart +++ b/lib/frontend/screens/auth/proxy_settings_sheet.dart @@ -6,6 +6,7 @@ import 'package:komet/l10n/app_localizations.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/labeled_settings_field.dart'; import '../../widgets/sheet_helpers.dart'; class ProxySettingsSheet extends StatefulWidget { @@ -147,40 +148,40 @@ class _ProxySettingsSheetState extends State { child: isActive ? Column( children: [ - _buildTextField( + LabeledSettingsField( controller: _hostController, label: l10n.proxyHostLabel, hintText: '127.0.0.1', - cs: cs, keyboardType: TextInputType.url, + enabled: !_busy, ), const SizedBox(height: 16), - _buildTextField( + LabeledSettingsField( controller: _portController, label: l10n.proxyPortLabel, hintText: _selectedType == ProxyType.socks5 ? '1080' : '8080', - cs: cs, keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], + enabled: !_busy, ), const SizedBox(height: 16), - _buildTextField( + LabeledSettingsField( controller: _usernameController, label: l10n.proxyUsernameLabel, - cs: cs, keyboardType: TextInputType.text, + enabled: !_busy, ), const SizedBox(height: 16), - _buildTextField( + LabeledSettingsField( controller: _passwordController, label: l10n.proxyPasswordLabel, - cs: cs, keyboardType: TextInputType.visiblePassword, obscureText: true, + enabled: !_busy, ), const SizedBox(height: 8), ], @@ -242,54 +243,4 @@ class _ProxySettingsSheetState extends State { ), ); } - - Widget _buildTextField({ - required TextEditingController controller, - required String label, - required ColorScheme cs, - String? hintText, - TextInputType? keyboardType, - List? inputFormatters, - bool obscureText = false, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle( - color: cs.onSurfaceVariant, - fontWeight: FontWeight.w500, - fontSize: 14, - ), - ), - const SizedBox(height: 8), - TextField( - controller: controller, - keyboardType: keyboardType, - inputFormatters: inputFormatters, - enabled: !_busy, - obscureText: obscureText, - style: TextStyle(color: cs.onSurface, fontSize: 15), - decoration: InputDecoration( - hintText: hintText, - hintStyle: TextStyle( - color: cs.onSurfaceVariant.withValues(alpha: 0.6), - fontSize: 15, - ), - filled: true, - fillColor: cs.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - ), - ), - ], - ); - } } diff --git a/lib/frontend/screens/auth/registration_screen.dart b/lib/frontend/screens/auth/registration_screen.dart index ee67f55..2a6b1da 100644 --- a/lib/frontend/screens/auth/registration_screen.dart +++ b/lib/frontend/screens/auth/registration_screen.dart @@ -108,9 +108,7 @@ class _RegistrationScreenState extends State { ? cs.primaryContainer : cs.surfaceContainerHighest, elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(50), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(50)), child: _isSubmitting ? SizedBox( width: 22, @@ -122,9 +120,7 @@ class _RegistrationScreenState extends State { ) : Icon( Icons.arrow_forward, - color: _canSubmit - ? cs.onPrimaryContainer - : cs.onSurfaceVariant, + color: _canSubmit ? cs.onPrimaryContainer : cs.onSurfaceVariant, ), ), body: SafeArea( @@ -306,12 +302,10 @@ class _RegistrationScreenState extends State { child: CachedNetworkImage( imageUrl: avatar.url, fit: BoxFit.cover, - placeholder: (_, __) => Container( - color: cs.surfaceContainerHigh, - ), - errorWidget: (_, __, ___) => Container( - color: cs.surfaceContainerHigh, - ), + placeholder: (_, __) => + Container(color: cs.surfaceContainerHigh), + errorWidget: (_, __, ___) => + Container(color: cs.surfaceContainerHigh), ), ), ), diff --git a/lib/frontend/screens/auth/select_country_screen.dart b/lib/frontend/screens/auth/select_country_screen.dart index a74dfe0..31a5793 100644 --- a/lib/frontend/screens/auth/select_country_screen.dart +++ b/lib/frontend/screens/auth/select_country_screen.dart @@ -17,15 +17,29 @@ class SelectCountryScreen extends StatefulWidget { State createState() => _SelectCountryScreenState(); } +class _CountrySearchEntry { + final CountryName country; + final String ruLower; + final String enLower; + + const _CountrySearchEntry(this.country, this.ruLower, this.enLower); +} + class _SelectCountryScreenState extends State { bool _isSearching = false; final TextEditingController _searchController = TextEditingController(); late List _filteredCountries; + late final List<_CountrySearchEntry> _searchEntries; @override void initState() { super.initState(); _filteredCountries = widget.countries; + _searchEntries = widget.countries + .map( + (c) => _CountrySearchEntry(c, c.ru.toLowerCase(), c.en.toLowerCase()), + ) + .toList(); } @override @@ -40,11 +54,15 @@ class _SelectCountryScreenState extends State { _filteredCountries = widget.countries; } else { final q = query.toLowerCase(); - _filteredCountries = widget.countries.where((c) { - return c.ru.toLowerCase().contains(q) || - c.en.toLowerCase().contains(q) || - c.phoneCode.contains(q); - }).toList(); + _filteredCountries = _searchEntries + .where( + (e) => + e.ruLower.contains(q) || + e.enLower.contains(q) || + e.country.phoneCode.contains(q), + ) + .map((e) => e.country) + .toList(); } }); } @@ -127,7 +145,7 @@ class _SelectCountryScreenState extends State { ), ), title: Text( - lang == 'ru' ? country.ru : country.en, + country.displayName(lang), style: TextStyle( color: cs.onSurface, fontSize: 16, diff --git a/lib/frontend/screens/auth/server_settings_sheet.dart b/lib/frontend/screens/auth/server_settings_sheet.dart index 20aad75..7eb1707 100644 --- a/lib/frontend/screens/auth/server_settings_sheet.dart +++ b/lib/frontend/screens/auth/server_settings_sheet.dart @@ -9,6 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/labeled_settings_field.dart'; import '../../widgets/sheet_helpers.dart'; class ServerSettingsSheet extends StatefulWidget { @@ -137,21 +138,21 @@ class _ServerSettingsSheetState extends State { ), ), const SizedBox(height: 20), - _buildTextField( + LabeledSettingsField( controller: _hostController, label: l10n.serverHostLabel, hintText: ServerConfig.defaultHost, - cs: cs, keyboardType: TextInputType.url, + enabled: !_busy, ), const SizedBox(height: 16), - _buildTextField( + LabeledSettingsField( controller: _portController, label: l10n.serverPortLabel, hintText: '${ServerConfig.defaultPort}', - cs: cs, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], + enabled: !_busy, ), const SizedBox(height: 24), FilledButton( @@ -169,52 +170,4 @@ class _ServerSettingsSheetState extends State { ), ); } - - Widget _buildTextField({ - required TextEditingController controller, - required String label, - required ColorScheme cs, - String? hintText, - TextInputType? keyboardType, - List? inputFormatters, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle( - color: cs.onSurfaceVariant, - fontWeight: FontWeight.w500, - fontSize: 14, - ), - ), - const SizedBox(height: 8), - TextField( - controller: controller, - keyboardType: keyboardType, - inputFormatters: inputFormatters, - enabled: !_busy, - style: TextStyle(color: cs.onSurface, fontSize: 15), - decoration: InputDecoration( - hintText: hintText, - hintStyle: TextStyle( - color: cs.onSurfaceVariant.withValues(alpha: 0.6), - fontSize: 15, - ), - filled: true, - fillColor: cs.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - ), - ), - ], - ); - } } diff --git a/lib/frontend/screens/auth/session_stale_recovery.dart b/lib/frontend/screens/auth/session_stale_recovery.dart new file mode 100644 index 0000000..9896496 --- /dev/null +++ b/lib/frontend/screens/auth/session_stale_recovery.dart @@ -0,0 +1,40 @@ +import 'dart:async'; +import 'package:flutter/widgets.dart'; +import '../../../backend/api.dart'; +import '../../../main.dart'; +import '../../widgets/custom_notification.dart'; + +mixin SessionStaleRecovery on State { + int sessionEpoch = 0; + bool recovering = false; + bool dropNotified = false; + StreamSubscription? _stateSub; + + bool get sessionStale => + api.sessionEpoch != sessionEpoch || api.state != SessionState.online; + + String get connectionDroppedMessage; + + void recoverStaleSession(); + + void startSessionRecovery() { + sessionEpoch = api.sessionEpoch; + _stateSub = api.stateStream.listen(_onSessionState); + } + + void stopSessionRecovery() { + _stateSub?.cancel(); + } + + void _onSessionState(SessionState state) { + if (!mounted) return; + if (state != SessionState.online) { + if (!dropNotified) { + dropNotified = true; + showCustomNotification(context, connectionDroppedMessage); + } + return; + } + if (api.sessionEpoch != sessionEpoch) recoverStaleSession(); + } +} diff --git a/lib/frontend/screens/auth/token_login_screen.dart b/lib/frontend/screens/auth/token_login_screen.dart index 77ef864..382df99 100644 --- a/lib/frontend/screens/auth/token_login_screen.dart +++ b/lib/frontend/screens/auth/token_login_screen.dart @@ -88,7 +88,8 @@ class _TokenLoginScreenState extends State { deviceType: _selectedDeviceType, arch: _selectedArch, appVersion: _appVersionController.text.trim(), - buildNumber: int.tryParse(_buildNumberController.text.trim()) ?? + buildNumber: + int.tryParse(_buildNumberController.text.trim()) ?? SpoofingService.hardcodedBuildNumber, pushDeviceType: _pushDeviceTypeController.text.trim(), instanceId: _instanceIdController.text.trim(), @@ -214,45 +215,81 @@ class _TokenLoginScreenState extends State { (v) => setState(() => _selectedDeviceType = v), ), const SizedBox(height: 16), - _field(_deviceNameController, l10n.spoofFieldDeviceName, - Symbols.smartphone), + _field( + _deviceNameController, + l10n.spoofFieldDeviceName, + Symbols.smartphone, + ), const SizedBox(height: 16), - _field(_osVersionController, l10n.spoofFieldOsVersion, - Symbols.layers), + _field( + _osVersionController, + l10n.spoofFieldOsVersion, + Symbols.layers, + ), const SizedBox(height: 16), - _field(_screenController, l10n.spoofFieldScreen, Symbols.fullscreen), + _field( + _screenController, + l10n.spoofFieldScreen, + Symbols.fullscreen, + ), const SizedBox(height: 16), - _field(_timezoneController, l10n.spoofFieldTimezone, Symbols.public), + _field( + _timezoneController, + l10n.spoofFieldTimezone, + Symbols.public, + ), const SizedBox(height: 16), _field(_localeController, l10n.spoofFieldLocale, Symbols.language), const SizedBox(height: 16), - _field(_deviceLocaleController, l10n.spoofFieldDeviceLocale, - Symbols.translate), + _field( + _deviceLocaleController, + l10n.spoofFieldDeviceLocale, + Symbols.translate, + ), const SizedBox(height: 24), SectionHeader( l10n.spoofIdentifiersSectionTitle, padding: const EdgeInsets.only(bottom: 16, top: 4), fontSize: 20, ), - _field(_deviceIdController, l10n.spoofFieldDeviceId, Symbols.tag, - onChanged: true), + _field( + _deviceIdController, + l10n.spoofFieldDeviceId, + Symbols.tag, + onChanged: true, + ), const SizedBox(height: 16), - _field(_instanceIdController, l10n.spoofFieldInstanceId, - Symbols.fingerprint), + _field( + _instanceIdController, + l10n.spoofFieldInstanceId, + Symbols.fingerprint, + ), const SizedBox(height: 16), - _field(_clientSessionIdController, l10n.spoofFieldClientSessionId, - Symbols.vpn_key, - number: true), + _field( + _clientSessionIdController, + l10n.spoofFieldClientSessionId, + Symbols.vpn_key, + number: true, + ), const SizedBox(height: 16), - _field(_appVersionController, l10n.spoofFieldAppVersion, - Symbols.info), + _field( + _appVersionController, + l10n.spoofFieldAppVersion, + Symbols.info, + ), const SizedBox(height: 16), - _field(_buildNumberController, l10n.spoofFieldBuildNumber, - Symbols.numbers, - number: true), + _field( + _buildNumberController, + l10n.spoofFieldBuildNumber, + Symbols.numbers, + number: true, + ), const SizedBox(height: 16), - _field(_pushDeviceTypeController, l10n.spoofFieldPushDeviceType, - Symbols.notifications), + _field( + _pushDeviceTypeController, + l10n.spoofFieldPushDeviceType, + Symbols.notifications, + ), const SizedBox(height: 16), Text(l10n.spoofFieldArchitecture), const SizedBox(height: 8), diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart index 74eefc0..42ac11f 100644 --- a/lib/frontend/screens/calls/call_screen.dart +++ b/lib/frontend/screens/calls/call_screen.dart @@ -20,8 +20,10 @@ import '../../../core/calls/call_controller.dart'; import '../../../core/calls/call_info.dart'; import '../../../core/calls/call_session.dart'; import '../../../core/utils/format.dart'; +import '../../../l10n/app_localizations.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; +import '../../widgets/sheet_helpers.dart'; import 'komet_hub.dart'; const Color _kEndRed = Color(0xFFE5484D); @@ -49,8 +51,7 @@ class CallScreen extends StatefulWidget { State createState() => _CallScreenState(); } -class _CallScreenState extends State - with TickerProviderStateMixin { +class _CallScreenState extends State with TickerProviderStateMixin { CallSession? _session; StreamSubscription? _stateSub; StreamSubscription? _canceledSub; @@ -82,8 +83,7 @@ class _CallScreenState extends State final Map _peerInfo = {}; - bool get _isGroup => - widget.isGroup || (_session?.participantCount ?? 0) > 2; + bool get _isGroup => widget.isGroup || (_session?.participantCount ?? 0) > 2; bool get _tileVideoReady { if (_session?.topology == 'SERVER') return false; @@ -133,8 +133,8 @@ class _CallScreenState extends State if (name == null || avatar == null) { final info = await ContactInfoFetch.get(id); if (info != null) { - name ??= _contactName(info); - avatar ??= info['baseUrl'] as String?; + name ??= info.displayName; + avatar ??= info.avatarUrl; if (name != null) ContactCache.put(id, name); ContactCache.putAvatar(id, avatar); } @@ -146,25 +146,6 @@ class _CallScreenState extends State }); } - String? _contactName(Map info) { - final names = info['names']; - if (names is! List) return null; - Map? pick; - for (final n in names) { - if (n is! Map) continue; - pick ??= n; - if (n['type'] == 'ONEME') { - pick = n; - break; - } - } - if (pick == null) return null; - final first = (pick['firstName'] as String?) ?? ''; - final last = pick['lastName'] as String?; - final full = (last != null && last.isNotEmpty) ? '$first $last' : first; - return full.trim().isEmpty ? null : full.trim(); - } - Future _initRenderer() async { await _remoteRenderer.initialize(); await _localRenderer.initialize(); @@ -243,7 +224,8 @@ class _CallScreenState extends State void _showKometBadge() { if (!mounted) return; - showCustomNotification(context, 'Этот человек использует Komet! :3'); + final l10n = AppLocalizations.of(context)!; + showCustomNotification(context, l10n.callKometDetectedNotification); } void _onChatMessage(CallChatMessage message) { @@ -276,8 +258,8 @@ class _CallScreenState extends State if (name == null) { final info = await ContactInfoFetch.get(id); if (info != null) { - name = _contactName(info); - avatar ??= info['baseUrl'] as String?; + name = info.displayName; + avatar ??= info.avatarUrl; if (name != null) ContactCache.put(id, name); ContactCache.putAvatar(id, avatar); } @@ -398,9 +380,7 @@ class _CallScreenState extends State isScrollControlled: true, showDragHandle: true, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (_) => Theme( data: Theme.of(context).copyWith(colorScheme: cs), child: _CallInfoSheet( @@ -489,6 +469,7 @@ class _CallScreenState extends State } Widget _buildGroupBody(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; final participants = _session?.participants ?? const []; return SafeArea( child: Column( @@ -499,7 +480,7 @@ class _CallScreenState extends State const SizedBox(height: 8), Expanded( child: participants.isEmpty - ? Center(child: _statusWithDots(cs, 'Соединение')) + ? Center(child: _statusWithDots(cs, l10n.callStatusConnecting)) : _participantGrid(cs, participants), ), const SizedBox(height: 12), @@ -511,13 +492,15 @@ class _CallScreenState extends State } Widget _groupHeader(ColorScheme cs, int count) { + final l10n = AppLocalizations.of(context)!; final String subtitle; if (count == 0) { - subtitle = 'Соединение…'; + subtitle = l10n.callGroupConnecting; } else if (count <= 1) { - subtitle = 'Ожидание участников…'; + subtitle = l10n.callGroupWaitingParticipants; } else { - subtitle = _participantsLabel(count); + subtitle = + '$count ${pluralRu(count, 'участник', 'участника', 'участников')}'; } return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), @@ -549,8 +532,8 @@ class _CallScreenState extends State final cols = ps.length <= 1 ? 1 : ps.length <= 4 - ? 2 - : 3; + ? 2 + : 3; return GridView.count( crossAxisCount: cols, padding: const EdgeInsets.fromLTRB(20, 4, 20, 4), @@ -562,11 +545,14 @@ class _CallScreenState extends State } Widget _participantTile(ColorScheme cs, CallParticipant p) { + final l10n = AppLocalizations.of(context)!; final ext = p.externalId; final info = ext != null ? _peerInfo[ext] : null; final name = p.isSelf - ? 'Вы' - : (info?.name?.isNotEmpty == true ? info!.name! : 'Участник'); + ? l10n.callParticipantYou + : (info?.name?.isNotEmpty == true + ? info!.name! + : l10n.callParticipantFallback); final url = p.isSelf ? _avatarUrl : info?.avatar; final muted = p.isSelf ? _isMuted : !p.audioEnabled; final speaking = !muted && _session?.isSpeaking(p.id) == true; @@ -577,8 +563,9 @@ class _CallScreenState extends State color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(20), depth: 6, - borderSide: - speaking ? const BorderSide(color: _kAcceptGreen, width: 2.5) : null, + borderSide: speaking + ? const BorderSide(color: _kAcceptGreen, width: 2.5) + : null, padding: EdgeInsets.all(showVideo ? 0 : 12), child: showVideo ? _videoTile(cs, name, muted, p.handRaised, p.screenSharing) @@ -586,8 +573,14 @@ class _CallScreenState extends State ); } - Widget _avatarTile(ColorScheme cs, String name, String? url, bool muted, - bool hand, bool screen) { + Widget _avatarTile( + ColorScheme cs, + String name, + String? url, + bool muted, + bool hand, + bool screen, + ) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -607,22 +600,34 @@ class _CallScreenState extends State Positioned( top: -2, right: -2, - child: _tileBadge(cs, Symbols.front_hand, - cs.tertiaryContainer, cs.onTertiaryContainer), + child: _tileBadge( + cs, + Symbols.front_hand, + cs.tertiaryContainer, + cs.onTertiaryContainer, + ), ), if (screen) Positioned( top: -2, left: -2, - child: _tileBadge(cs, Symbols.screen_share, - cs.primaryContainer, cs.onPrimaryContainer), + child: _tileBadge( + cs, + Symbols.screen_share, + cs.primaryContainer, + cs.onPrimaryContainer, + ), ), if (muted) Positioned( bottom: -2, right: -2, - child: _tileBadge(cs, Symbols.mic_off, - cs.surfaceContainerHighest, cs.onSurfaceVariant), + child: _tileBadge( + cs, + Symbols.mic_off, + cs.surfaceContainerHighest, + cs.onSurfaceVariant, + ), ), ], ), @@ -647,7 +652,12 @@ class _CallScreenState extends State } Widget _videoTile( - ColorScheme cs, String name, bool muted, bool hand, bool screen) { + ColorScheme cs, + String name, + bool muted, + bool hand, + bool screen, + ) { return ClipRRect( borderRadius: BorderRadius.circular(20), child: Stack( @@ -666,8 +676,12 @@ class _CallScreenState extends State if (muted) Padding( padding: const EdgeInsets.only(right: 4), - child: Icon(Symbols.mic_off, - size: 16, color: Colors.white, fill: 1), + child: Icon( + Symbols.mic_off, + size: 16, + color: Colors.white, + fill: 1, + ), ), Flexible( child: Text( @@ -689,15 +703,23 @@ class _CallScreenState extends State Positioned( top: 8, right: 8, - child: _tileBadge(cs, Symbols.front_hand, cs.tertiaryContainer, - cs.onTertiaryContainer), + child: _tileBadge( + cs, + Symbols.front_hand, + cs.tertiaryContainer, + cs.onTertiaryContainer, + ), ), if (screen) Positioned( top: 8, left: 8, - child: _tileBadge(cs, Symbols.screen_share, cs.primaryContainer, - cs.onPrimaryContainer), + child: _tileBadge( + cs, + Symbols.screen_share, + cs.primaryContainer, + cs.onPrimaryContainer, + ), ), ], ), @@ -716,16 +738,6 @@ class _CallScreenState extends State ); } - String _participantsLabel(int n) { - final mod10 = n % 10; - final mod100 = n % 100; - if (mod10 == 1 && mod100 != 11) return '$n участник'; - if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) { - return '$n участника'; - } - return '$n участников'; - } - Widget _buildBody( ColorScheme cs, { required Widget avatar, @@ -774,7 +786,9 @@ class _CallScreenState extends State ), ), if (t > 0.001) - IgnorePointer(child: Opacity(opacity: t, child: _videoScrim(cs))), + IgnorePointer( + child: Opacity(opacity: t, child: _videoScrim(cs)), + ), SafeArea( child: Column( children: [ @@ -833,7 +847,9 @@ class _CallScreenState extends State } Widget _buildTopBar(ColorScheme cs, double t) { - final showTimer = t > 0.001 && + final l10n = AppLocalizations.of(context)!; + final showTimer = + t > 0.001 && _session != null && _state == CallSessionState.active && _session!.mediaConnected; @@ -845,7 +861,7 @@ class _CallScreenState extends State alignment: Alignment.centerLeft, child: IconButton( onPressed: () => Navigator.of(context).maybePop(), - tooltip: 'Свернуть', + tooltip: l10n.callTooltipMinimize, icon: Icon( Symbols.close_fullscreen, color: cs.onSurface, @@ -863,8 +879,7 @@ class _CallScreenState extends State if (_session?.peerIsKomet == true) IconButton( onPressed: _openKometHub, - tooltip: 'Komet', - //TODO: Бля иконку кометы в код дайтtе' мориарти 00. ал.о + tooltip: l10n.callTooltipKometHub, icon: Icon( Symbols.auto_awesome, color: cs.primary, @@ -874,7 +889,7 @@ class _CallScreenState extends State ), IconButton( onPressed: _showInfoSheet, - tooltip: 'О звонке', + tooltip: l10n.callInfoTitle, icon: Icon( Symbols.info, color: cs.onSurface, @@ -907,13 +922,13 @@ class _CallScreenState extends State } Widget? _peerStateBar(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; final session = _session; if (session == null) return null; final pills = [ - if (session.peerMuted) - _statePill(cs, Symbols.mic_off, 'Микрофон выключен'), + if (session.peerMuted) _statePill(cs, Symbols.mic_off, l10n.callPeerMicOff), if (session.peerVideo) - _statePill(cs, Symbols.videocam, 'Камера включена'), + _statePill(cs, Symbols.videocam, l10n.callPeerCameraOn), ]; if (pills.isEmpty) return null; return Wrap( @@ -949,12 +964,15 @@ class _CallScreenState extends State } Widget _buildAvatar(ColorScheme cs) { - final avatarSize = - (MediaQuery.of(context).size.shortestSide * 0.42).clamp(128.0, 172.0); + final avatarSize = (MediaQuery.of(context).size.shortestSide * 0.42).clamp( + 128.0, + 172.0, + ); return _avatarCircle(avatarSize, cs); } - String get _displayName => _name.isEmpty ? 'Неизвестный' : _name; + String get _displayName => + _name.isEmpty ? AppLocalizations.of(context)!.callUnknownName : _name; Widget _avatarCircle(double size, ColorScheme cs) => _circleAvatar(size, cs, name: _displayName, url: _avatarUrl); @@ -1033,11 +1051,12 @@ class _CallScreenState extends State } Widget _buildStatus(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; if (!_incomingPending && _state == CallSessionState.active) { final session = _session; if (session == null) return const SizedBox.shrink(); if (!session.mediaConnected) { - return _statusWithDots(cs, 'Соединение'); + return _statusWithDots(cs, l10n.callStatusConnecting); } return _ElapsedText( session: session, @@ -1052,7 +1071,7 @@ class _CallScreenState extends State if (_incomingPending) { return Text( - 'Входящий звонок', + l10n.callIncoming, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 16), ); } @@ -1060,13 +1079,13 @@ class _CallScreenState extends State String text; switch (_state) { case CallSessionState.connecting: - text = 'Соединение'; + text = l10n.callStatusConnecting; case CallSessionState.ringing: - text = 'Вызов'; + text = l10n.callStatusRinging; case CallSessionState.active: text = ''; case CallSessionState.ended: - text = 'Звонок завершён'; + text = l10n.callStatusEnded; } return _statusWithDots(cs, text); @@ -1076,10 +1095,7 @@ class _CallScreenState extends State return Row( mainAxisSize: MainAxisSize.min, children: [ - Text( - text, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 16), - ), + Text(text, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 16)), const SizedBox(width: 4), _CallingDots(animation: _dotsController, color: cs.onSurfaceVariant), ], @@ -1092,6 +1108,7 @@ class _CallScreenState extends State } Widget _incomingControls(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return Padding( padding: const EdgeInsets.symmetric(horizontal: 56), child: Row( @@ -1099,14 +1116,14 @@ class _CallScreenState extends State children: [ _CallButton( icon: Symbols.call_end, - label: 'Отклонить', + label: l10n.callDecline, background: _kEndRed, foreground: Colors.white, onTap: _decline, ), _CallButton( icon: Symbols.call, - label: 'Принять', + label: l10n.callAccept, background: _kAcceptGreen, foreground: Colors.white, onTap: _accept, @@ -1117,6 +1134,7 @@ class _CallScreenState extends State } Widget _activeControls(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; final video = _session?.localVideo == true; final screen = _session?.localScreen == true; return Padding( @@ -1126,14 +1144,14 @@ class _CallScreenState extends State children: [ _CallButton( icon: _isSpeaker ? Symbols.volume_up : Symbols.volume_down, - label: 'Динамик', + label: l10n.callSpeaker, background: _isSpeaker ? cs.primary : cs.surfaceContainerHighest, foreground: _isSpeaker ? cs.onPrimary : cs.onSurface, onTap: _toggleSpeaker, ), _CallButton( icon: video ? Symbols.videocam : Symbols.videocam_off, - label: 'Видео', + label: l10n.callVideoLabel, background: video ? cs.primary : cs.surfaceContainerHighest, foreground: video ? cs.onPrimary : cs.onSurface, busy: _videoBusy, @@ -1141,7 +1159,7 @@ class _CallScreenState extends State ), _CallButton( icon: Symbols.screen_share, - label: 'Экран', + label: l10n.callScreenLabel, background: screen ? cs.primary : cs.surfaceContainerHighest, foreground: screen ? cs.onPrimary : cs.onSurface, busy: _videoBusy, @@ -1149,14 +1167,14 @@ class _CallScreenState extends State ), _CallButton( icon: _isMuted ? Symbols.mic_off : Symbols.mic, - label: _isMuted ? 'Вкл. звук' : 'Выкл. звук', + label: _isMuted ? l10n.callUnmute : l10n.callMute, background: _isMuted ? cs.primary : cs.surfaceContainerHighest, foreground: _isMuted ? cs.onPrimary : cs.onSurface, onTap: _toggleMute, ), _CallButton( icon: Symbols.call_end, - label: 'Завершить', + label: l10n.callEndButton, background: _kEndRed, foreground: Colors.white, onTap: _hangup, @@ -1320,6 +1338,7 @@ class _CallInfoSheet extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; final cs = Theme.of(context).colorScheme; final info = session?.info; @@ -1328,41 +1347,64 @@ class _CallInfoSheet extends StatelessWidget { if (v != null && v.isNotEmpty) rows.add([k, v]); } - add('Клиент', _clientLine(info)); - add('Платформа', info?.peerPlatform); - add('Страна', incoming?.country); + add(l10n.callInfoClient, _clientLine(info)); + add(l10n.callInfoPlatform, info?.peerPlatform); + add(l10n.callInfoCountry, incoming?.country); final isContact = incoming?.isContact; - if (isContact != null) add('В контактах', isContact ? 'да' : 'нет'); - add('IP собеседника', info?.peerIp); - add('Сеть собеседника', info?.peerNetwork); - add('Путь соединения', info?.path); - add('Кодек', info?.audioCodec); - add('Сервер', info?.region); - add('Топология', info?.topology); + if (isContact != null) { + add(l10n.callInfoInContacts, isContact ? l10n.callValueYes : l10n.callValueNo); + } + add(l10n.callInfoPeerIp, info?.peerIp); + add(l10n.callInfoPeerNetwork, info?.peerNetwork); + add(l10n.callInfoPath, info?.path); + add(l10n.callInfoCodec, info?.audioCodec); + add(l10n.callInfoServer, info?.region); + add(l10n.callInfoTopology, info?.topology); add('Conversation ID', info?.conversationId); if (info?.dtlsFingerprint != null) { add('DTLS', _shortFp(info!.dtlsFingerprint!)); } if (session != null) { - add('Статус', session!.mediaConnected ? 'соединён' : 'соединение…'); - add('Микрофон собеседника', session!.peerMuted ? 'выключен' : 'включён'); - add('Камера собеседника', session!.peerVideo ? 'включена' : 'выключена'); + add( + l10n.callInfoStatus, + session!.mediaConnected + ? l10n.callStatusValueConnected + : l10n.callStatusValueConnecting, + ); + add( + l10n.callInfoPeerMic, + session!.peerMuted ? l10n.callMicValueOff : l10n.callMicValueOn, + ); + add( + l10n.callInfoPeerCamera, + session!.peerVideo ? l10n.callCameraValueOn : l10n.callCameraValueOff, + ); } final vtracks = renderer.srcObject?.getVideoTracks().length ?? 0; - add('Видео-дорожка', vtracks > 0 ? 'есть ($vtracks)' : 'нет'); + add( + l10n.callInfoVideoTrack, + vtracks > 0 + ? l10n.callInfoVideoTrackPresent(vtracks) + : l10n.callValueNo, + ); final w = renderer.value.width.toInt(); final h = renderer.value.height.toInt(); - add('Размер видео', (w > 0 && h > 0) ? '$w×$h' : '—'); - add('Отрисовка кадров', renderer.renderVideo ? 'да' : 'нет'); + add(l10n.callInfoVideoSize, (w > 0 && h > 0) ? '$w×$h' : '—'); + add( + l10n.callInfoFrameRendering, + renderer.renderVideo ? l10n.callValueYes : l10n.callValueNo, + ); final badges = [ - _badge(cs, Symbols.lock, 'Зашифрован'), - _badge(cs, Symbols.call, 'Аудио'), - if (info?.record == true) _badge(cs, Symbols.radio_button_checked, 'Запись'), + _badge(cs, Symbols.lock, l10n.callBadgeEncrypted), + _badge(cs, Symbols.call, l10n.callBadgeAudio), + if (info?.record == true) + _badge(cs, Symbols.radio_button_checked, l10n.callBadgeRecording), if (info?.denoise == true) - _badge(cs, Symbols.noise_control_on, 'Шумоподавление'), - if (info?.animoji == true) _badge(cs, Symbols.mood, 'Анимодзи'), + _badge(cs, Symbols.noise_control_on, l10n.callBadgeNoiseSuppression), + if (info?.animoji == true) + _badge(cs, Symbols.mood, l10n.callBadgeAnimoji), ]; return SafeArea( @@ -1375,7 +1417,7 @@ class _CallInfoSheet extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'О звонке', + l10n.callInfoTitle, style: TextStyle( color: cs.onSurface, fontSize: 20, @@ -1393,7 +1435,7 @@ class _CallInfoSheet extends StatelessWidget { const SizedBox(height: 16), if (rows.isEmpty) Text( - 'Данные появятся после соединения…', + l10n.callInfoNoDataYet, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), for (final r in rows) diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index ea92062..8dd0c37 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -147,9 +147,7 @@ class _CallsTabState extends State { return Material( color: Colors.transparent, child: InkWell( - onTap: () { - // Open call details or initiate call - }, + onTap: () {}, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), child: Row( @@ -268,17 +266,20 @@ class _CallsTabState extends State { ); } - Future _deleteCall(CallLogEntry call) async { + void _deleteCall(CallLogEntry call) { + if (_removing.contains(call.id)) return; setState(() => _removing.add(call.id)); final historyId = int.tryParse(call.id); if (historyId != null) { unawaited(CallsModule(api).deleteHistory([historyId])); } - await Future.delayed(const Duration(milliseconds: 260)); + } + + void _onRemovalComplete(String id) { if (!mounted) return; setState(() { - _calls.removeWhere((c) => c.id == call.id); - _removing.remove(call.id); + _calls.removeWhere((c) => c.id == id); + _removing.remove(id); }); } @@ -295,8 +296,11 @@ class _CallsTabState extends State { if (active != null) { await navigator.push( MaterialPageRoute( - builder: (_) => - CallScreen(name: call.name, avatarUrl: avatarUrl, session: active), + builder: (_) => CallScreen( + name: call.name, + avatarUrl: avatarUrl, + session: active, + ), ), ); return; @@ -437,6 +441,7 @@ class _CallsTabState extends State { return _RemovableCallEntry( key: ValueKey(call.id), removing: _removing.contains(call.id), + onDismissed: () => _onRemovalComplete(call.id), child: _buildCallItem(context, cs, call), ); }, @@ -451,11 +456,13 @@ class _CallsTabState extends State { class _RemovableCallEntry extends StatefulWidget { final bool removing; + final VoidCallback onDismissed; final Widget child; const _RemovableCallEntry({ required Key key, required this.removing, + required this.onDismissed, required this.child, }) : super(key: key); @@ -475,6 +482,16 @@ class _RemovableCallEntryState extends State<_RemovableCallEntry> curve: Curves.easeOutCubic, ); + @override + void initState() { + super.initState(); + _controller.addStatusListener(_onStatus); + } + + void _onStatus(AnimationStatus status) { + if (status == AnimationStatus.dismissed) widget.onDismissed(); + } + @override void didUpdateWidget(covariant _RemovableCallEntry oldWidget) { super.didUpdateWidget(oldWidget); @@ -483,6 +500,7 @@ class _RemovableCallEntryState extends State<_RemovableCallEntry> @override void dispose() { + _controller.removeStatusListener(_onStatus); _controller.dispose(); super.dispose(); } diff --git a/lib/frontend/screens/calls/komet_hub.dart b/lib/frontend/screens/calls/komet_hub.dart index c76d021..43b6b44 100644 --- a/lib/frontend/screens/calls/komet_hub.dart +++ b/lib/frontend/screens/calls/komet_hub.dart @@ -5,6 +5,8 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../core/calls/call_session.dart'; import '../../../core/games/checkers.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../widgets/sheet_helpers.dart'; Future showKometHub( BuildContext context, { @@ -16,9 +18,7 @@ Future showKometHub( isScrollControlled: true, showDragHandle: true, backgroundColor: scheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (_) => Theme( data: Theme.of(context).copyWith(colorScheme: scheme), child: _KometHub(session: session), @@ -58,15 +58,16 @@ class _KometHubState extends State<_KometHub> { } String get _title { + final l10n = AppLocalizations.of(context)!; switch (_page) { case _HubPage.menu: - return 'Komet'; + return l10n.hubTitleMenu; case _HubPage.chat: - return 'Анонимный чат'; + return l10n.hubChatPageTitle; case _HubPage.games: - return 'Игры'; + return l10n.hubGamesTitle; case _HubPage.checkers: - return 'Шашки'; + return l10n.hubCheckersTitle; } } @@ -74,7 +75,9 @@ class _KometHubState extends State<_KometHub> { Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Padding( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), child: ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.78, @@ -134,32 +137,60 @@ class _KometHubState extends State<_KometHub> { } Widget _menu(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return Column( mainAxisSize: MainAxisSize.min, children: [ - _tile(cs, Symbols.forum, 'Чат', 'Анонимные сообщения', - () => _go(_HubPage.chat)), - _tile(cs, Symbols.stadia_controller, 'Игры', 'Сыграть с собеседником', - () => _go(_HubPage.games)), + _tile( + cs, + Symbols.forum, + l10n.hubChatTileTitle, + l10n.hubChatTileSubtitle, + () => _go(_HubPage.chat), + ), + _tile( + cs, + Symbols.stadia_controller, + l10n.hubGamesTitle, + l10n.hubGamesTileSubtitle, + () => _go(_HubPage.games), + ), const SizedBox(height: 12), ], ); } Widget _games(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return Column( mainAxisSize: MainAxisSize.min, children: [ - _tile(cs, Symbols.grid_on, 'Шашки', 'Русские шашки', - () => _go(_HubPage.checkers)), - _tile(cs, Symbols.more_horiz, 'Скоро ещё…', 'В разработке', null), + _tile( + cs, + Symbols.grid_on, + l10n.hubCheckersTitle, + l10n.hubCheckersTileSubtitle, + () => _go(_HubPage.checkers), + ), + _tile( + cs, + Symbols.more_horiz, + l10n.hubMoreSoonTitle, + l10n.hubMoreSoonSubtitle, + null, + ), const SizedBox(height: 12), ], ); } - Widget _tile(ColorScheme cs, IconData icon, String title, String subtitle, - VoidCallback? onTap) { + Widget _tile( + ColorScheme cs, + IconData icon, + String title, + String subtitle, + VoidCallback? onTap, + ) { final enabled = onTap != null; return ListTile( onTap: onTap, @@ -244,6 +275,7 @@ class _KometChatViewState extends State<_KometChatView> { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; final cs = Theme.of(context).colorScheme; final messages = widget.session.chatLog; return Column( @@ -257,7 +289,7 @@ class _KometChatViewState extends State<_KometChatView> { const SizedBox(width: 6), Expanded( child: Text( - 'Напрямую через звонок, нигде не сохраняется', + l10n.hubChatPrivacyNote, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), ), @@ -284,7 +316,7 @@ class _KometChatViewState extends State<_KometChatView> { child: Padding( padding: const EdgeInsets.all(32), child: Text( - 'Сообщений пока нет', + AppLocalizations.of(context)!.hubChatEmpty, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), ), @@ -331,12 +363,14 @@ class _KometChatViewState extends State<_KometChatView> { onSubmitted: (_) => _send(), style: TextStyle(color: cs.onSurface, fontSize: 15), decoration: InputDecoration( - hintText: 'Сообщение…', + hintText: AppLocalizations.of(context)!.hubChatInputHint, hintStyle: TextStyle(color: cs.onSurfaceVariant), filled: true, fillColor: cs.surfaceContainerHighest, - contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 11), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 11, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(24), borderSide: BorderSide.none, @@ -445,8 +479,10 @@ class _CheckersViewState extends State<_CheckersView> { final prefix = [..._path, square]; final matching = _legal.where((p) => _startsWith(p, prefix)).toList(); if (matching.isEmpty) { - setState(() => - _path = _legal.any((p) => p.first == square) ? [square] : const []); + setState( + () => + _path = _legal.any((p) => p.first == square) ? [square] : const [], + ); return; } if (matching.any((p) => p.length == prefix.length)) { @@ -470,6 +506,7 @@ class _CheckersViewState extends State<_CheckersView> { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; final cs = Theme.of(context).colorScheme; return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), @@ -491,7 +528,7 @@ class _CheckersViewState extends State<_CheckersView> { TextButton.icon( onPressed: _reset, icon: const Icon(Symbols.refresh, size: 20), - label: const Text('Заново'), + label: Text(l10n.hubCheckersRestart), ), ], ), @@ -500,8 +537,8 @@ class _CheckersViewState extends State<_CheckersView> { const SizedBox(height: 10), Text( _me == CheckersSide.white - ? 'Вы играете белыми' - : 'Вы играете чёрными', + ? l10n.hubCheckersYouWhite + : l10n.hubCheckersYouBlack, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), ], @@ -510,9 +547,10 @@ class _CheckersViewState extends State<_CheckersView> { } String _status() { + final l10n = AppLocalizations.of(context)!; final w = _result; - if (w != null) return w == _me ? 'Вы выиграли 🎉' : 'Вы проиграли'; - return _turn == _me ? 'Ваш ход' : 'Ход соперника…'; + if (w != null) return w == _me ? l10n.hubCheckersWon : l10n.hubCheckersLost; + return _turn == _me ? l10n.hubCheckersYourMove : l10n.hubCheckersOpponentMove; } Widget _boardWidget(ColorScheme cs) { @@ -598,7 +636,11 @@ class _CheckersViewState extends State<_CheckersView> { width: option ? 2.5 : 1.5, ), boxShadow: const [ - BoxShadow(color: Colors.black38, blurRadius: 3, offset: Offset(0, 1)), + BoxShadow( + color: Colors.black38, + blurRadius: 3, + offset: Offset(0, 1), + ), ], ), child: king @@ -606,7 +648,9 @@ class _CheckersViewState extends State<_CheckersView> { Symbols.star, fill: 1, size: 16, - color: white ? const Color(0xFF8A6D00) : const Color(0xFFE7C200), + color: white + ? const Color(0xFF8A6D00) + : const Color(0xFFE7C200), ) : null, ), diff --git a/lib/frontend/screens/chats/chat/chat_controller.dart b/lib/frontend/screens/chats/chat/chat_controller.dart new file mode 100644 index 0000000..e91bc49 --- /dev/null +++ b/lib/frontend/screens/chats/chat/chat_controller.dart @@ -0,0 +1,212 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../../../../backend/modules/chats.dart'; +import '../../../../backend/modules/messages.dart'; +import '../../../../core/cache/message_session_cache.dart'; +import '../../../../core/config/komet_settings.dart'; +import '../../../../core/storage/app_database.dart'; +import '../../../../core/utils/logger.dart'; +import '../../../../main.dart'; + +class ChatController extends ChangeNotifier { + static const int historyPageSize = 30; + static const int historyInitialLimit = 50; + + int chatId = 0; + int myId = 0; + + List messages = []; + final ValueNotifier messagesRev = ValueNotifier(0); + + bool hasMoreHistory = true; + bool isLoadingMore = false; + bool historyKickedOff = false; + + bool Function() isMounted = () => true; + + void bump() { + messagesRev.value++; + } + + int prependOlder(List olderDesc) { + if (olderDesc.isEmpty) return 0; + final existing = messages.map((m) => m.id).toSet(); + final toAdd = []; + for (final m in olderDesc.reversed) { + if (existing.add(m.id)) toAdd.add(m); + } + if (toAdd.isEmpty) return 0; + messages = [...toAdd, ...messages]; + messagesRev.value++; + return toAdd.length; + } + + bool mergeMessages(List decodedDesc) { + final byId = {for (final m in messages) m.id: m}; + var changed = false; + for (final fresh in decodedDesc) { + final old = byId[fresh.id]; + if (old == null) { + byId[fresh.id] = fresh; + changed = true; + } else if (!_sameMessage(old, fresh)) { + byId[fresh.id] = fresh; + changed = true; + } + } + + if (!changed) return false; + + final merged = byId.values.toList() + ..sort((a, b) { + final byTime = a.time.compareTo(b.time); + return byTime != 0 ? byTime : a.id.compareTo(b.id); + }); + + messages = merged; + messagesRev.value++; + return true; + } + + bool _sameMessage(CachedMessage a, CachedMessage b) { + return a.id == b.id && + a.time == b.time && + a.status == b.status && + a.text == b.text && + a.senderId == b.senderId && + a.deleted == b.deleted; + } + + Future> loadInitialFromDb({ + required bool onlyVisible, + }) async { + final rows = await AppDatabase.loadMessages( + myId, + chatId, + limit: historyInitialLimit, + onlyVisible: onlyVisible, + ); + return CachedMessage.fromDbRowsAsync(rows); + } + + Future> loadOlderFromDb( + int beforeTime, + bool onlyVisible, + ) async { + final rows = await AppDatabase.loadMessagesBefore( + myId, + chatId, + beforeTime: beforeTime, + limit: historyPageSize, + onlyVisible: onlyVisible, + ); + return CachedMessage.fromDbRowsAsync(rows); + } + + void persistSessionCache() { + if (myId == 0 || messages.isEmpty) return; + MessageSessionCache.save( + myId, + chatId, + messages, + reachedStart: !hasMoreHistory, + ); + } + + Future loadMoreHistory({ + required void Function() onLoadingStarted, + required void Function(int added) onLoaded, + required void Function(Object error) onError, + }) async { + if (isLoadingMore || !hasMoreHistory || messages.isEmpty) return; + isLoadingMore = true; + onLoadingStarted(); + + final oldest = messages.first; + final onlyVisible = !KometSettings.viewDeleted.value; + + try { + var older = await loadOlderFromDb(oldest.time, onlyVisible); + + if (older.length < historyPageSize) { + final fetched = await messagesModule.fetchHistory( + myId, + chatId, + fromTime: oldest.time, + count: historyPageSize, + ); + if (fetched.isNotEmpty) { + if (KometSettings.viewDeleted.value) { + await chats.reconcileDeletedFromFetch(myId, chatId, fetched); + } + older = await loadOlderFromDb(oldest.time, onlyVisible); + } + } + + if (!isMounted()) return; + final added = prependOlder(older); + isLoadingMore = false; + if (added == 0) hasMoreHistory = false; + persistSessionCache(); + onLoaded(added); + } catch (e) { + logger.e('Error loading more history: $e'); + onError(e); + } + } + + Future loadRemainingHistory({ + required void Function(List decoded, {bool markLoaded}) + onApplyMerged, + required void Function() onLoadingFinished, + required void Function() onPreview, + required void Function() onSenderNames, + }) async { + final onlyVisible = !KometSettings.viewDeleted.value; + final fullDecoded = await loadInitialFromDb(onlyVisible: onlyVisible); + if (isMounted()) { + onApplyMerged(fullDecoded); + } + + if (fullDecoded.isNotEmpty && chats.wasHistoryFetched(chatId)) { + if (isMounted()) { + onLoadingFinished(); + } + onSenderNames(); + return; + } + + try { + final cachedRows = await AppDatabase.loadChat(myId, chatId); + if (cachedRows.isEmpty) { + onPreview(); + await chats.ensureChatCached(api, myId, chatId); + await chats.subscribeChat(api, chatId); + } + final serverMessages = await messagesModule.fetchHistory(myId, chatId); + chats.markHistoryFetched(chatId); + if (KometSettings.viewDeleted.value) { + await chats.reconcileDeletedFromFetch(myId, chatId, serverMessages); + } + final updatedDecoded = await loadInitialFromDb(onlyVisible: onlyVisible); + if (isMounted()) { + onApplyMerged(updatedDecoded, markLoaded: true); + } + unawaited(chats.reconcileLastMessageIfPlaceholder(myId, chatId)); + onSenderNames(); + } catch (e) { + logger.e('Error fetching history: $e'); + if (isMounted()) { + onLoadingFinished(); + } + } + } + + @override + void dispose() { + messagesRev.dispose(); + super.dispose(); + } +} diff --git a/lib/frontend/screens/chats/chat/chat_prank_controller.dart b/lib/frontend/screens/chats/chat/chat_prank_controller.dart new file mode 100644 index 0000000..20dae4e --- /dev/null +++ b/lib/frontend/screens/chats/chat/chat_prank_controller.dart @@ -0,0 +1,135 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../../../../backend/modules/messages.dart'; +import '../../../../core/config/app_pranks.dart'; +import '../../../../core/utils/haptics.dart'; +import '../../../widgets/theme_reveal.dart'; + +class ChatPrankController { + ChatPrankController({ + required this.vsync, + required this.contextOf, + required this.isMounted, + required this.onChanged, + }); + + final TickerProvider vsync; + final BuildContext Function() contextOf; + final bool Function() isMounted; + final VoidCallback onChanged; + + final GlobalKey bubbleKey = GlobalKey(); + final GlobalKey captureKey = GlobalKey(); + + bool _active = false; + String? _bubbleId; + OverlayEntry? _revealEntry; + AnimationController? _revealController; + ui.Image? _revealImage; + + bool get active => _active; + String? get bubbleId => _bubbleId; + + void checkTrigger(CachedMessage msg) { + if (!AppPranks.current.value || _active || _bubbleId != null) return; + if ((msg.text ?? '').trim().toUpperCase() != 'THE WORLD') return; + _bubbleId = msg.id; + onChanged(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (isMounted()) _runReveal(); + }); + } + + ThemeData pinkTheme(ThemeData base) { + final cs = base.colorScheme; + return base.copyWith( + scaffoldBackgroundColor: const Color(0xFFFFF0F5), + colorScheme: cs.copyWith( + surface: const Color(0xFFFFF0F5), + surfaceContainerHigh: const Color(0xFFFFE3EC), + surfaceContainerHighest: const Color(0xFFFFD9E6), + primary: const Color(0xFFE8579A), + primaryContainer: const Color(0xFFFFD6E5), + onPrimaryContainer: const Color(0xFF7A1F4B), + ), + ); + } + + void _runReveal() { + if (_active) return; + final context = contextOf(); + final overlay = Navigator.of(context).overlay; + final captureCtx = captureKey.currentContext; + final renderObject = captureCtx?.findRenderObject(); + if (overlay == null || renderObject is! RenderRepaintBoundary) { + _active = true; + onChanged(); + return; + } + + Offset center; + final bubbleBox = + bubbleKey.currentContext?.findRenderObject() as RenderBox?; + if (bubbleBox != null && bubbleBox.attached) { + center = bubbleBox.localToGlobal(bubbleBox.size.center(Offset.zero)); + } else { + final size = MediaQuery.sizeOf(context); + center = Offset(size.width / 2, size.height / 2); + } + + final ui.Image snapshot; + try { + final dpr = math.min(MediaQuery.of(context).devicePixelRatio, 2.0); + snapshot = renderObject.toImageSync(pixelRatio: dpr); + } catch (_) { + _active = true; + onChanged(); + return; + } + + dispose(); + + final controller = AnimationController( + vsync: vsync, + duration: const Duration(milliseconds: 650), + ); + final entry = ThemeRevealOverlay.build( + snapshot: snapshot, + center: center, + animation: controller, + ); + + _revealController = controller; + _revealEntry = entry; + _revealImage = snapshot; + + overlay.insert(entry); + _active = true; + onChanged(); + Haptics.success(); + + WidgetsBinding.instance.endOfFrame.then((_) { + if (_revealController != controller) return; + controller.forward().then((_) { + if (_revealController != controller) return; + dispose(); + }, onError: (_) {}); + }); + } + + void dispose() { + _revealEntry?.remove(); + _revealEntry = null; + _revealController?.dispose(); + _revealController = null; + final img = _revealImage; + _revealImage = null; + if (img != null) { + WidgetsBinding.instance.addPostFrameCallback((_) => img.dispose()); + } + } +} diff --git a/lib/frontend/screens/chats/chat/chat_search_controller.dart b/lib/frontend/screens/chats/chat/chat_search_controller.dart new file mode 100644 index 0000000..3c8e0b2 --- /dev/null +++ b/lib/frontend/screens/chats/chat/chat_search_controller.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import '../../../../core/utils/logger.dart'; +import '../../../../main.dart'; +import 'message_search_result.dart'; + +class ChatSearchController { + ChatSearchController({required this.chatId, required this.isMounted}) { + searchController.addListener(_onTextChanged); + } + + final int chatId; + final bool Function() isMounted; + + final TextEditingController searchController = TextEditingController(); + final ValueNotifier searchMode = ValueNotifier(false); + final ValueNotifier> results = ValueNotifier( + const [], + ); + final ValueNotifier loading = ValueNotifier(false); + final ValueNotifier performed = ValueNotifier(false); + Timer? _debounce; + int _seq = 0; + + void _onTextChanged() { + final query = searchController.text.trim(); + _debounce?.cancel(); + if (query.isEmpty) { + _seq++; + results.value = const []; + loading.value = false; + performed.value = false; + return; + } + _debounce = Timer(const Duration(milliseconds: 300), () { + runSearch(query); + }); + } + + void submit(String query) { + _debounce?.cancel(); + runSearch(query); + } + + Future runSearch(String query) async { + final trimmed = query.trim(); + if (trimmed.isEmpty) return; + final seq = ++_seq; + loading.value = true; + List> raw; + try { + raw = await messagesModule.searchMessages(chatId, trimmed); + } catch (e) { + logger.e('Search error: $e'); + raw = const []; + } + if (!isMounted() || seq != _seq) return; + final mapped = raw + .map(MessageSearchResult.fromRaw) + .whereType() + .toList(); + results.value = mapped; + loading.value = false; + performed.value = true; + } + + void reset() { + _debounce?.cancel(); + _seq++; + searchMode.value = false; + searchController.clear(); + results.value = const []; + loading.value = false; + performed.value = false; + } + + void dispose() { + _debounce?.cancel(); + searchController.removeListener(_onTextChanged); + searchController.dispose(); + searchMode.dispose(); + results.dispose(); + loading.dispose(); + performed.dispose(); + } +} diff --git a/lib/frontend/screens/chats/chat/command_panel_controller.dart b/lib/frontend/screens/chats/chat/command_panel_controller.dart new file mode 100644 index 0000000..58e252f --- /dev/null +++ b/lib/frontend/screens/chats/chat/command_panel_controller.dart @@ -0,0 +1,64 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../../../../core/config/app_commands.dart'; +import '../../../commands/command_registry.dart'; +import '../../../commands/slash_command.dart'; + +class CommandPanelController { + CommandPanelController({ + required TickerProvider vsync, + required this.textOf, + required this.onSelected, + }) { + anim = AnimationController( + vsync: vsync, + duration: const Duration(milliseconds: 200), + ); + AppCommands.current.addListener(update); + } + + final String Function() textOf; + final void Function(SlashCommand) onSelected; + + late final AnimationController anim; + final ValueNotifier> matches = ValueNotifier(const []); + bool _visible = false; + + List _matching(String raw) { + if (!AppCommands.current.value) return const []; + final text = raw.trimLeft(); + if (!text.startsWith('/')) return const []; + if (text.contains(RegExp(r'\s'))) return const []; + final query = text.toLowerCase(); + for (final c in kSlashCommands) { + if (!c.hidden && c.name.toLowerCase() == query) return const []; + } + return kSlashCommands + .where((c) => !c.hidden && c.name.toLowerCase().startsWith(query)) + .toList(growable: false); + } + + void update() { + final found = _matching(textOf()); + final show = found.isNotEmpty; + if (show && !listEquals(matches.value, found)) { + matches.value = found; + } + if (show == _visible) return; + _visible = show; + if (show) { + anim.forward(); + } else { + anim.reverse(); + } + } + + void select(SlashCommand c) => onSelected(c); + + void dispose() { + AppCommands.current.removeListener(update); + anim.dispose(); + matches.dispose(); + } +} diff --git a/lib/frontend/screens/chats/chat/message_search_result.dart b/lib/frontend/screens/chats/chat/message_search_result.dart new file mode 100644 index 0000000..d0c0ffb --- /dev/null +++ b/lib/frontend/screens/chats/chat/message_search_result.dart @@ -0,0 +1,35 @@ +class MessageSearchResult { + final String id; + final int time; + final int senderId; + final String text; + final List highlights; + + const MessageSearchResult({ + required this.id, + required this.time, + required this.senderId, + required this.text, + required this.highlights, + }); + + static MessageSearchResult? fromRaw(Map raw) { + final message = raw['message']; + if (message is! Map) return null; + final id = message['id']?.toString(); + if (id == null) return null; + final rawHighlights = raw['highlights']; + final highlights = rawHighlights is List + ? rawHighlights.whereType().toList() + : const []; + final time = message['time']; + final sender = message['sender']; + return MessageSearchResult( + id: id, + time: time is int ? time : int.tryParse('${time ?? 0}') ?? 0, + senderId: sender is int ? sender : int.tryParse('${sender ?? 0}') ?? 0, + text: message['text']?.toString() ?? '', + highlights: highlights, + ); + } +} diff --git a/lib/frontend/screens/chats/chat/sticker_panel_controller.dart b/lib/frontend/screens/chats/chat/sticker_panel_controller.dart new file mode 100644 index 0000000..2667a09 --- /dev/null +++ b/lib/frontend/screens/chats/chat/sticker_panel_controller.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import '../../../../core/config/komet_settings.dart'; + +class StickerPanelController { + StickerPanelController({ + required TickerProvider vsync, + required this.onSendTyping, + }) { + anim = AnimationController( + vsync: vsync, + duration: const Duration(milliseconds: 240), + reverseDuration: const Duration(milliseconds: 200), + ); + showPanel.addListener(_onToggle); + } + + final VoidCallback onSendTyping; + + late final AnimationController anim; + final ValueNotifier showPanel = ValueNotifier(false); + double panelHeight = 300; + Timer? _typingTimer; + + void hide() => showPanel.value = false; + + void _onToggle() { + if (showPanel.value) { + anim.forward(); + _sendTyping(); + _typingTimer?.cancel(); + _typingTimer = Timer.periodic( + const Duration(seconds: 4), + (_) => _sendTyping(), + ); + } else { + anim.reverse(); + _typingTimer?.cancel(); + _typingTimer = null; + } + } + + void _sendTyping() { + if (KometSettings.ghostMode.value) return; + onSendTyping(); + } + + void dispose() { + _typingTimer?.cancel(); + showPanel.removeListener(_onToggle); + anim.dispose(); + showPanel.dispose(); + } +} diff --git a/lib/frontend/screens/chats/chat/upload_status.dart b/lib/frontend/screens/chats/chat/upload_status.dart new file mode 100644 index 0000000..ad588c6 --- /dev/null +++ b/lib/frontend/screens/chats/chat/upload_status.dart @@ -0,0 +1,11 @@ +class UploadStatus { + final bool active; + final int sent; + final int total; + + const UploadStatus({this.active = false, this.sent = 0, this.total = 0}); + + bool get awaitingResponse => active && total > 0 && sent >= total; + double? get progressValue => + (!active || total == 0 || awaitingResponse) ? null : sent / total; +} diff --git a/lib/frontend/screens/chats/chat/video_note_controller.dart b/lib/frontend/screens/chats/chat/video_note_controller.dart new file mode 100644 index 0000000..cbe3842 --- /dev/null +++ b/lib/frontend/screens/chats/chat/video_note_controller.dart @@ -0,0 +1,243 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../../core/media/native_video_note_recorder.dart'; +import '../../../../core/utils/haptics.dart'; +import '../../../../core/utils/logger.dart'; +import '../../../widgets/custom_notification.dart'; +import 'voice_record_controller.dart'; + +class VideoNoteController { + VideoNoteController({ + required this.contextOf, + required this.isMounted, + required this.onRecorded, + required this.formatElapsed, + }); + + final BuildContext Function() contextOf; + final bool Function() isMounted; + final Future Function(File file, int durationMs) onRecorded; + final String Function(int ms) formatElapsed; + + final NativeVideoNoteRecorder _rec = NativeVideoNoteRecorder(); + final ValueNotifier _videoNoteMode = ValueNotifier(false); + final ValueNotifier _textureId = ValueNotifier(null); + final ValueNotifier _camReady = ValueNotifier(false); + final ValueNotifier _isRecording = ValueNotifier(false); + final ValueNotifier _elapsedMs = ValueNotifier(0); + final ValueNotifier _cancelDrag = ValueNotifier(0); + final Stopwatch _stopwatch = Stopwatch(); + Timer? _timer; + bool _cancelled = false; + bool _stopRequested = false; + OverlayEntry? _overlay; + + ValueListenable get videoNoteMode => _videoNoteMode; + ValueListenable get camReady => _camReady; + ValueListenable get isRecording => _isRecording; + + Future toggleMode() async { + final toVideo = !_videoNoteMode.value; + _videoNoteMode.value = toVideo; + Haptics.tap(); + if (toVideo) { + await _initCamera(); + } else { + await _disposeCamera(); + } + } + + Future _initCamera() async { + if (_rec.textureId != null) return; + if (!_rec.isAvailable) { + if (isMounted()) showCustomNotification(contextOf(), 'Камера недоступна'); + return; + } + try { + final ok = await _rec.init(); + if (!ok) { + if (isMounted()) { + showCustomNotification(contextOf(), 'Камера недоступна'); + } + return; + } + if (!isMounted() || !_videoNoteMode.value) { + await _disposeCamera(); + return; + } + _textureId.value = _rec.textureId; + _camReady.value = true; + } catch (e) { + logger.w('initNoteCamera: $e'); + if (isMounted()) showCustomNotification(contextOf(), 'Камера недоступна'); + } + } + + Future _disposeCamera() async { + _camReady.value = false; + _textureId.value = null; + await _rec.dispose(); + } + + Future start() async { + if (_isRecording.value) return; + _stopRequested = false; + if (_rec.textureId == null) { + await _initCamera(); + return; + } + try { + final ok = await _rec.start(); + if (!ok) { + _isRecording.value = false; + return; + } + if (!isMounted()) { + await _rec.stop(); + return; + } + _stopwatch + ..reset() + ..start(); + _elapsedMs.value = 0; + _cancelDrag.value = 0; + _cancelled = false; + _isRecording.value = true; + FocusManager.instance.primaryFocus?.unfocus(); + Haptics.send(); + _timer = Timer.periodic(const Duration(milliseconds: 100), (_) { + _elapsedMs.value = _stopwatch.elapsedMilliseconds; + }); + _showOverlay(); + if (_stopRequested) { + _stopRequested = false; + await stop(cancel: false); + } + } catch (e) { + logger.w('startNoteRecording: $e'); + _isRecording.value = false; + } + } + + void handleDrag(Offset offsetFromOrigin) { + if (!_isRecording.value) return; + final drag = (-offsetFromOrigin.dx / VoiceRecordController.cancelThreshold) + .clamp(0.0, 1.0); + _cancelDrag.value = drag; + if (drag >= 1.0 && !_cancelled) { + _cancelled = true; + Haptics.error(); + stop(cancel: true); + } + } + + void handleEnd() => stop(cancel: false); + + Future stop({required bool cancel}) async { + if (!_isRecording.value) { + _stopRequested = true; + return; + } + _timer?.cancel(); + _timer = null; + _stopwatch.stop(); + final elapsed = _stopwatch.elapsedMilliseconds; + _isRecording.value = false; + _cancelDrag.value = 0; + _hideOverlay(); + + final path = await _rec.stop(); + + final shouldCancel = + cancel || _cancelled || elapsed < VoiceRecordController.minMs; + if (shouldCancel || path == null) { + if (path != null) { + try { + await File(path).delete(); + } catch (_) {} + } + return; + } + + // Файл уже квадратный 480×480 (нативная запись) — шлём как есть. + await onRecorded(File(path), elapsed); + } + + void _showOverlay() { + _overlay?.remove(); + _overlay = OverlayEntry( + builder: (context) { + final texId = _textureId.value; + return Positioned.fill( + child: IgnorePointer( + child: Container( + color: Colors.black.withValues(alpha: 0.55), + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ClipOval( + child: SizedBox( + width: 260, + height: 260, + child: texId != null + ? Texture(textureId: texId) + : Container(color: Colors.black), + ), + ), + const SizedBox(height: 20), + ValueListenableBuilder( + valueListenable: _elapsedMs, + builder: (context, ms, _) => Text( + formatElapsed(ms), + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontFeatures: [ui.FontFeature.tabularFigures()], + ), + ), + ), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: _cancelDrag, + builder: (context, drag, _) => Opacity( + opacity: (0.5 + drag * 0.5).clamp(0.0, 1.0), + child: const Text( + '‹ влево — отмена', + style: TextStyle(color: Colors.white70, fontSize: 13), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + final overlay = Overlay.of(contextOf(), rootOverlay: true); + overlay.insert(_overlay!); + } + + void _hideOverlay() { + _overlay?.remove(); + _overlay = null; + } + + void dispose() { + _timer?.cancel(); + _overlay?.remove(); + _rec.dispose(); + _textureId.dispose(); + _videoNoteMode.dispose(); + _camReady.dispose(); + _isRecording.dispose(); + _elapsedMs.dispose(); + _cancelDrag.dispose(); + } +} diff --git a/lib/frontend/screens/chats/chat/view/chat_header.dart b/lib/frontend/screens/chats/chat/view/chat_header.dart new file mode 100644 index 0000000..f460400 --- /dev/null +++ b/lib/frontend/screens/chats/chat/view/chat_header.dart @@ -0,0 +1,475 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:komet/frontend/widgets/avatar_hero.dart'; +import 'package:komet/frontend/widgets/glossy_pill.dart'; +import 'package:komet/frontend/widgets/online_dot.dart'; + +class ChatHeaderRow extends StatelessWidget { + final bool glossy; + final ColorScheme cs; + final bool embedded; + final int chatId; + final String name; + final String imageUrl; + final String chatType; + final bool isOfficial; + final int myId; + final ValueListenable headerStatus; + final ValueListenable scheduledCount; + final ValueListenable otherUnread; + final VoidCallback? onClose; + final VoidCallback onOpenInfo; + final VoidCallback onOpenScheduled; + final VoidCallback onCall; + final void Function(BuildContext) onMenu; + + const ChatHeaderRow({ + super.key, + required this.glossy, + required this.cs, + required this.embedded, + required this.chatId, + required this.name, + required this.imageUrl, + required this.chatType, + required this.isOfficial, + required this.myId, + required this.headerStatus, + required this.scheduledCount, + required this.otherUnread, + required this.onClose, + required this.onOpenInfo, + required this.onOpenScheduled, + required this.onCall, + required this.onMenu, + }); + + @override + Widget build(BuildContext context) => + glossy ? _glossyRow(context) : _materialRow(context); + + Widget _glossyRow(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), + child: Row( + children: [ + _backWithBadge( + cs, + SizedBox( + width: 56, + height: 56, + child: GlossyPill( + onTap: () { + if (embedded) { + onClose?.call(); + } else { + Navigator.pop(context); + } + }, + child: Center( + child: Icon( + embedded ? Symbols.close : Symbols.arrow_back, + color: cs.onSurface, + weight: 500, + size: 24, + ), + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: GlossyPill( + onTap: onOpenInfo, + padding: const EdgeInsets.fromLTRB(6, 6, 16, 6), + child: Row( + children: [ + _withOnlineDot( + cs, + AvatarHero( + tag: 'chatAvatar_$chatId', + name: name, + imageUrl: imageUrl.isNotEmpty ? imageUrl : null, + child: imageUrl.isNotEmpty + ? CircleAvatar( + radius: 22, + backgroundImage: CachedNetworkImageProvider( + imageUrl, + maxWidth: 144, + maxHeight: 144, + ), + ) + : CircleAvatar( + radius: 22, + backgroundColor: cs.primaryContainer, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + name, + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isOfficial) ...[ + const SizedBox(width: 4), + Icon( + Symbols.verified, + color: cs.primary, + size: 16, + weight: 600, + fill: 1, + ), + ], + ], + ), + ValueListenableBuilder( + valueListenable: headerStatus, + builder: (context, status, _) => Text( + status, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + fontWeight: FontWeight.w400, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(width: 8), + GlossyPill( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: SizedBox( + height: 56, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ValueListenableBuilder( + valueListenable: scheduledCount, + builder: (_, count, _) => count > 0 + ? IconButton( + icon: Icon( + Symbols.schedule, + weight: 500, + color: cs.onSurface, + ), + onPressed: onOpenScheduled, + ) + : const SizedBox.shrink(), + ), + IconButton( + icon: Icon(Symbols.call, weight: 500, color: cs.onSurface), + onPressed: onCall, + ), + Builder( + builder: (btnContext) => IconButton( + icon: Icon( + Symbols.more_vert, + weight: 500, + color: cs.onSurface, + ), + onPressed: () => onMenu(btnContext), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _materialRow(BuildContext context) { + return Row( + children: [ + _backWithBadge( + cs, + IconButton( + icon: Icon( + embedded ? Symbols.close : Symbols.arrow_back, + weight: 400, + color: cs.onSurface, + ), + onPressed: () { + if (embedded) { + onClose?.call(); + } else { + Navigator.pop(context); + } + }, + ), + ), + Expanded( + child: InkWell( + onTap: onOpenInfo, + child: Row( + children: [ + _withOnlineDot( + cs, + AvatarHero( + tag: 'chatAvatar_$chatId', + name: name, + imageUrl: imageUrl.isNotEmpty ? imageUrl : null, + child: imageUrl.isNotEmpty + ? CircleAvatar( + radius: 18, + backgroundImage: CachedNetworkImageProvider( + imageUrl, + maxWidth: 144, + maxHeight: 144, + ), + ) + : CircleAvatar( + radius: 18, + backgroundColor: cs.primaryContainer, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 12, + ), + ), + ), + ), + dotSize: 11, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + name, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isOfficial) ...[ + const SizedBox(width: 4), + Icon( + Symbols.verified, + color: cs.primary, + size: 16, + weight: 600, + fill: 1, + ), + ], + ], + ), + ValueListenableBuilder( + valueListenable: headerStatus, + builder: (context, status, _) => Text( + status, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ValueListenableBuilder( + valueListenable: scheduledCount, + builder: (_, count, _) => count > 0 + ? IconButton( + icon: Icon( + Symbols.schedule, + weight: 400, + color: cs.onSurface, + ), + onPressed: onOpenScheduled, + ) + : const SizedBox.shrink(), + ), + IconButton( + icon: Icon(Symbols.call, weight: 400, color: cs.onSurface), + onPressed: onCall, + ), + Builder( + builder: (btnContext) => IconButton( + icon: Icon(Symbols.more_vert, weight: 400, color: cs.onSurface), + onPressed: () => onMenu(btnContext), + ), + ), + ], + ); + } + + Widget _withOnlineDot(ColorScheme cs, Widget avatar, {double dotSize = 12}) { + final otherId = chatId ^ myId; + final showDot = chatType == 'DIALOG' && myId != 0 && otherId > 0; + return Stack( + children: [ + avatar, + if (showDot) + Positioned( + right: 0, + bottom: 0, + child: OnlineDot( + userId: otherId, + borderColor: cs.surface, + size: dotSize, + ), + ), + ], + ); + } + + Widget _backWithBadge(ColorScheme cs, Widget button) { + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + button, + Positioned( + right: -2, + bottom: 0, + child: IgnorePointer(child: _backUnreadBadge(cs)), + ), + ], + ); + } + + Widget _backUnreadBadge(ColorScheme cs) { + return ValueListenableBuilder( + valueListenable: otherUnread, + builder: (context, count, _) { + return AnimatedScale( + scale: count > 0 ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutBack, + child: Container( + constraints: const BoxConstraints(minWidth: 18), + height: 18, + padding: const EdgeInsets.symmetric(horizontal: 5), + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(9), + border: Border.all(color: cs.surface, width: 1.5), + ), + alignment: Alignment.center, + child: _RollingCount( + count: count > 99 ? 99 : count, + style: TextStyle( + color: cs.onPrimary, + fontSize: 10.5, + fontWeight: FontWeight.w700, + height: 1.0, + ), + ), + ), + ); + }, + ); + } +} + +class _RollingCount extends StatefulWidget { + final int count; + final TextStyle style; + + const _RollingCount({required this.count, required this.style}); + + @override + State<_RollingCount> createState() => _RollingCountState(); +} + +class _RollingCountState extends State<_RollingCount> { + late int _count = widget.count; + bool _increasing = true; + + @override + void didUpdateWidget(_RollingCount old) { + super.didUpdateWidget(old); + if (widget.count != _count) { + _increasing = widget.count > _count; + _count = widget.count; + } + } + + @override + Widget build(BuildContext context) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, anim) { + final incoming = (child.key as ValueKey).value == _count; + final Offset begin; + if (incoming) { + begin = _increasing ? const Offset(0, -1) : const Offset(0, 1); + } else { + begin = _increasing ? const Offset(0, 1) : const Offset(0, -1); + } + return ClipRect( + child: FadeTransition( + opacity: anim, + child: SlideTransition( + position: Tween(begin: begin, end: Offset.zero).animate(anim), + child: child, + ), + ), + ); + }, + child: Text( + '${widget.count}', + key: ValueKey(widget.count), + style: widget.style, + ), + ); + } +} diff --git a/lib/frontend/screens/chats/chat/view/chat_list_shimmer.dart b/lib/frontend/screens/chats/chat/view/chat_list_shimmer.dart new file mode 100644 index 0000000..51e8764 --- /dev/null +++ b/lib/frontend/screens/chats/chat/view/chat_list_shimmer.dart @@ -0,0 +1,111 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +class ChatShimmerTile extends StatelessWidget { + const ChatShimmerTile({super.key, required this.shimmer}); + + final Animation shimmer; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return AnimatedBuilder( + animation: shimmer, + builder: (context, child) { + final opacity = 0.3 + 0.3 * sin(shimmer.value * pi * 2); + return Opacity(opacity: opacity, child: child); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + Expanded( + child: SizedBox( + height: 48, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 120, + height: 14, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(7), + ), + ), + Container( + width: double.infinity, + height: 12, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class FolderStripShimmer extends StatelessWidget { + const FolderStripShimmer({super.key, required this.shimmer}); + + final Animation shimmer; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return AnimatedBuilder( + animation: shimmer, + builder: (context, child) { + final opacity = 0.3 + 0.3 * sin(shimmer.value * pi * 2); + return Opacity( + opacity: opacity, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + physics: const BouncingScrollPhysics(), + children: [ + _pill(cs, 88), + const SizedBox(width: 8), + _pill(cs, 72), + const SizedBox(width: 8), + _pill(cs, 96), + const SizedBox(width: 8), + _pill(cs, 64), + const SizedBox(width: 8), + _pill(cs, 80), + ], + ), + ); + }, + ); + } + + Widget _pill(ColorScheme cs, double width) { + return Container( + width: width, + height: 32, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + ); + } +} diff --git a/lib/frontend/screens/chats/chat/view/chat_list_tile.dart b/lib/frontend/screens/chats/chat/view/chat_list_tile.dart new file mode 100644 index 0000000..41cad1b --- /dev/null +++ b/lib/frontend/screens/chats/chat/view/chat_list_tile.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import 'package:komet/core/storage/chat_activity_store.dart'; +import 'package:komet/frontend/widgets/animated_text_swap.dart'; + +class AnimatedChatTile extends StatefulWidget { + final Widget child; + final String id; + final int revision; + final bool isNew; + + const AnimatedChatTile({ + required super.key, + required this.child, + required this.id, + required this.revision, + required this.isNew, + }); + + @override + State createState() => _AnimatedChatTileState(); +} + +class _AnimatedChatTileState extends State + with SingleTickerProviderStateMixin { + static const Duration _moveDuration = Duration(milliseconds: 300); + static const Duration _enterDuration = Duration(milliseconds: 260); + + AnimationController? _controller; + double? _lastContentY; + late int _lastRevision; + double _moveDy = 0; + bool _entering = false; + + @override + void initState() { + super.initState(); + _lastRevision = widget.revision; + if (widget.isNew) { + _entering = true; + final c = _controller = AnimationController( + vsync: this, + duration: _enterDuration, + ); + c.forward(from: 0).whenComplete(() { + if (mounted) setState(() => _entering = false); + }); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _lastContentY = _measureContentY(); + }); + } + + @override + void didUpdateWidget(covariant AnimatedChatTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.revision == _lastRevision) return; + _lastRevision = widget.revision; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _runMove(); + }); + } + + double? _measureContentY() { + final box = context.findRenderObject(); + if (box is! RenderBox || !box.attached) return null; + try { + return RenderAbstractViewport.of(box).getOffsetToReveal(box, 0.0).offset; + } catch (_) { + return null; + } + } + + void _runMove() { + final newY = _measureContentY(); + final oldY = _lastContentY; + if (newY != null) _lastContentY = newY; + if (_entering || oldY == null || newY == null) return; + final dy = oldY - newY; + if (dy.abs() < 1.0 || dy.abs() > 2000) return; + final c = _controller ??= AnimationController(vsync: this); + c.duration = _moveDuration; + setState(() => _moveDy = dy); + c.forward(from: 0); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final c = _controller; + if (c == null) return SizedBox(child: widget.child); + return SizedBox( + child: AnimatedBuilder( + animation: c, + builder: (context, child) { + if (_entering) { + final t = Curves.easeOut.transform(c.value); + return Opacity( + opacity: t, + child: Transform.scale(scale: 0.94 + 0.06 * t, child: child), + ); + } + if (_moveDy != 0) { + final t = 1 - Curves.easeOutCubic.transform(c.value); + return Transform.translate( + offset: Offset(0, _moveDy * t), + child: child, + ); + } + return child!; + }, + child: widget.child, + ), + ); + } +} + +class ActivitySubtitle extends StatefulWidget { + const ActivitySubtitle({ + super.key, + required this.chatId, + required this.child, + }); + + final int chatId; + final Widget child; + + @override + State createState() => _ActivitySubtitleState(); +} + +class _ActivitySubtitleState extends State { + ChatActivity _lastActivity = ChatActivity.typing; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return ValueListenableBuilder( + valueListenable: ChatActivityStore.instance.listenable(widget.chatId), + child: widget.child, + builder: (context, activity, base) { + if (activity != null) _lastActivity = activity; + return AnimatedTextSwap( + showAlternate: activity != null, + alternate: Text( + _lastActivity.label.toLowerCase(), + style: TextStyle( + color: cs.primary, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + child: base!, + ); + }, + ); + } +} diff --git a/lib/frontend/screens/chats/chat/view/command_panel_view.dart b/lib/frontend/screens/chats/chat/view/command_panel_view.dart new file mode 100644 index 0000000..7a45c42 --- /dev/null +++ b/lib/frontend/screens/chats/chat/view/command_panel_view.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import 'package:komet/frontend/commands/slash_command.dart'; +import 'package:komet/frontend/screens/chats/chat/command_panel_controller.dart'; +import 'package:komet/frontend/widgets/command_suggestions_panel.dart'; + +class CommandPanelView extends StatelessWidget { + const CommandPanelView({super.key, required this.commandPanel}); + + final CommandPanelController commandPanel; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: commandPanel.anim, + child: ValueListenableBuilder>( + valueListenable: commandPanel.matches, + builder: (context, matches, _) => CommandSuggestionsPanel( + commands: matches, + onSelected: commandPanel.select, + ), + ), + builder: (context, child) { + final t = commandPanel.anim.value; + if (t == 0) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: IgnorePointer( + ignoring: t < 1, + child: Opacity(opacity: t, child: child), + ), + ); + }, + ); + } +} diff --git a/lib/frontend/screens/chats/chat/view/composer_input.dart b/lib/frontend/screens/chats/chat/view/composer_input.dart new file mode 100644 index 0000000..b2e3d68 --- /dev/null +++ b/lib/frontend/screens/chats/chat/view/composer_input.dart @@ -0,0 +1,1044 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/core/config/app_colors.dart'; +import 'package:komet/frontend/screens/chats/chat/upload_status.dart'; +import 'package:komet/frontend/screens/chats/chat/video_note_controller.dart'; +import 'package:komet/frontend/screens/chats/chat/voice_record_controller.dart'; +import 'package:komet/frontend/widgets/glossy_pill.dart'; +import 'package:komet/frontend/widgets/rich_message_controller.dart'; + +class ComposerInputBar extends StatelessWidget { + const ComposerInputBar({ + super.key, + required this.chatType, + required this.attachAnim, + required this.replyTo, + required this.myId, + required this.hasText, + required this.uploadStatus, + required this.messageController, + required this.messageFocusNode, + required this.voiceRec, + required this.note, + required this.onToggleStickerPanel, + required this.onSendText, + required this.onScheduleMessage, + required this.onOpenAttach, + required this.onOpenAttachScheduled, + required this.onSendHistory, + required this.onCancelReply, + required this.formatElapsed, + required this.contextMenuBuilder, + required this.isMuted, + required this.onToggleMute, + }); + + final String chatType; + final Animation attachAnim; + final ValueListenable replyTo; + final int myId; + final ValueListenable hasText; + final ValueListenable uploadStatus; + final RichMessageController messageController; + final FocusNode messageFocusNode; + final VoiceRecordController voiceRec; + final VideoNoteController note; + final VoidCallback onToggleStickerPanel; + final VoidCallback onSendText; + final VoidCallback onScheduleMessage; + final VoidCallback onOpenAttach; + final VoidCallback onOpenAttachScheduled; + final Future Function(FileHistoryEntry entry) onSendHistory; + final VoidCallback onCancelReply; + final String Function(int ms) formatElapsed; + final Widget Function(BuildContext, EditableTextState) contextMenuBuilder; + final bool isMuted; + final VoidCallback onToggleMute; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final mutedIcon = cs.onSurfaceVariant.withValues(alpha: 0.85); + + if (chatType == "CHANNEL") { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + child: GlossyPill( + onTap: onToggleMute, + color: Color.alphaBlend( + cs.surfaceContainerHighest.withValues(alpha: 0.92), + cs.surface, + ), + borderRadius: BorderRadius.circular(28), + padding: const EdgeInsets.symmetric(vertical: 16), + depth: 8, + borderSide: BorderSide( + color: cs.outlineVariant.withValues(alpha: 0.5), + width: 0.5, + ), + child: SizedBox( + width: double.infinity, + child: Center( + child: Text( + isMuted ? 'Включить уведомления' : 'Отключить уведомления', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + ); + } + + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _replyPreview(cs), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + constraints: const BoxConstraints( + minHeight: 54, + maxHeight: 180, + ), + child: GlossyPill( + color: Color.alphaBlend( + cs.surfaceContainerHighest.withValues(alpha: 0.92), + cs.surface, + ), + borderRadius: BorderRadius.circular(28), + depth: 8, + borderSide: BorderSide( + color: cs.outlineVariant.withValues(alpha: 0.5), + width: 0.5, + ), + child: Stack( + alignment: Alignment.center, + children: [ + AnimatedBuilder( + animation: attachAnim, + builder: (context, child) { + final t = attachAnim.value; + return IgnorePointer( + ignoring: t > 0.5, + child: Opacity( + opacity: (1 - t).clamp(0.0, 1.0), + child: child, + ), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onToggleStickerPanel, + child: Icon( + Symbols.face, + color: mutedIcon, + size: 24, + weight: 400, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Focus( + onKeyEvent: (node, event) { + if (event is KeyDownEvent && + event.logicalKey == + LogicalKeyboardKey.enter && + !HardwareKeyboard + .instance + .isShiftPressed) { + if (hasText.value) onSendText(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: TextField( + controller: messageController, + focusNode: messageFocusNode, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + ), + maxLines: null, + keyboardType: TextInputType.multiline, + textAlignVertical: + TextAlignVertical.center, + contextMenuBuilder: contextMenuBuilder, + decoration: InputDecoration( + hintText: 'Message', + hintStyle: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + border: InputBorder.none, + isDense: true, + contentPadding: + const EdgeInsets.symmetric( + vertical: 14, + ), + ), + ), + ), + ), + _AttachButton( + hasText: hasText, + onOpen: onOpenAttach, + onLongOpen: onOpenAttachScheduled, + uploadStatus: uploadStatus, + mutedIcon: mutedIcon, + cs: cs, + ), + ], + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: SizedBox( + height: 54, + child: AnimatedBuilder( + animation: attachAnim, + builder: (context, child) { + final t = attachAnim.value; + return IgnorePointer( + ignoring: t < 0.5, + child: Opacity( + opacity: t.clamp(0.0, 1.0), + child: child, + ), + ); + }, + child: _HistoryStrip( + anim: attachAnim, + cs: cs, + onTapEntry: onSendHistory, + ), + ), + ), + ), + Positioned.fill( + child: ValueListenableBuilder( + valueListenable: voiceRec.isRecording, + builder: (context, recording, _) => IgnorePointer( + ignoring: !recording, + child: AnimatedSlide( + offset: recording + ? Offset.zero + : const Offset(0.06, 0), + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + child: AnimatedOpacity( + opacity: recording ? 1 : 0, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + child: _voiceRecordingIndicator(cs), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + AnimatedBuilder( + animation: attachAnim, + builder: (context, child) { + final t = attachAnim.value; + return ClipRect( + clipper: _ButtonClipper(t), + child: Align( + alignment: Alignment.centerLeft, + widthFactor: (1 - t).clamp(0.0, 1.0), + child: child, + ), + ); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 8), + AnimatedBuilder( + animation: attachAnim, + builder: (context, child) { + final t = attachAnim.value; + return Transform.translate( + offset: Offset(t * 80, 0), + child: Opacity( + opacity: (1 - t * 1.5).clamp(0.0, 1.0), + child: child, + ), + ); + }, + child: ValueListenableBuilder( + valueListenable: hasText, + builder: (context, hasText, _) => + ValueListenableBuilder( + valueListenable: voiceRec.locked, + builder: (context, locked, _) => + ValueListenableBuilder( + valueListenable: voiceRec.isRecording, + builder: (context, recording, _) => + ValueListenableBuilder( + valueListenable: note.videoNoteMode, + builder: (context, videoMode, _) { + final sendMode = + hasText || locked; + final pill = GlossyPill( + color: sendMode + ? cs.primary + : recording + ? cs.error + : cs.surfaceContainerHighest, + borderRadius: + BorderRadius.circular(27), + onTap: hasText + ? onSendText + : locked + ? () => voiceRec.stop( + cancel: false, + ) + : null, + onLongPress: hasText + ? onScheduleMessage + : null, + depth: 8, + child: SizedBox( + width: 54, + height: 54, + child: Center( + child: Icon( + sendMode + ? Symbols.send + : videoMode + ? Symbols.videocam + : Symbols.mic, + color: sendMode + ? cs.onPrimary + : recording + ? cs.onError + : cs.onSurface, + size: 24, + weight: 400, + ), + ), + ), + ); + final visual = + _recordingButtonVisual( + pill: pill, + cs: cs, + active: + recording && !locked, + ); + return GestureDetector( + onTap: sendMode + ? null + : note.toggleMode, + onLongPressStart: sendMode + ? null + : (_) => videoMode + ? note.start() + : voiceRec.start(), + onLongPressMoveUpdate: sendMode + ? null + : (d) => videoMode + ? note.handleDrag( + d.offsetFromOrigin, + ) + : voiceRec.handleDrag( + d.offsetFromOrigin, + ), + onLongPressEnd: sendMode + ? null + : (_) => videoMode + ? note.handleEnd() + : voiceRec + .handleEnd(), + child: visual, + ); + }, + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _replyPreview(ColorScheme cs) { + return ValueListenableBuilder( + valueListenable: replyTo, + builder: (context, reply, _) { + if (reply == null) return const SizedBox.shrink(); + final name = reply.senderId == myId + ? 'Вы' + : (ContactCache.get(reply.senderId) ?? 'Сообщение'); + final info = ReplyInfo( + senderId: reply.senderId, + text: reply.text, + attachments: reply.attachments, + ); + final preview = info.previewText(); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 8, 2), + child: Row( + children: [ + Icon(Symbols.reply, size: 20, color: cs.primary), + const SizedBox(width: 10), + Container(width: 2, height: 34, color: cs.primary), + const SizedBox(width: 10), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ответ $name', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.primary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + if (preview.isNotEmpty) + Text( + preview, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Symbols.close, size: 20), + color: cs.onSurfaceVariant, + onPressed: onCancelReply, + ), + ], + ), + ); + }, + ); + } + + Widget _recordingButtonVisual({ + required Widget pill, + required ColorScheme cs, + required bool active, + }) { + return TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: active ? 1.0 : 0.0), + duration: const Duration(milliseconds: 220), + curve: Curves.easeOut, + builder: (context, a, _) { + if (a <= 0.001) return pill; + return ValueListenableBuilder( + valueListenable: voiceRec.amplitude, + builder: (context, amp, _) => TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: amp), + duration: const Duration(milliseconds: 110), + builder: (context, v, _) { + final glow = a * (88.0 + v * 76.0); + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Positioned( + left: 27 - glow / 2, + top: 27 - glow / 2, + child: Container( + width: glow, + height: glow, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.error.withValues( + alpha: a * (0.16 + v * 0.12), + ), + ), + ), + ), + _voiceLockChip(cs), + Transform.scale( + scale: 1.0 + a * 0.14 + a * v * 0.24, + child: pill, + ), + ], + ); + }, + ), + ); + }, + ); + } + + Widget _voiceLockChip(ColorScheme cs) { + return Positioned( + bottom: 62, + child: ValueListenableBuilder( + valueListenable: voiceRec.lockDrag, + builder: (context, lock, _) => Opacity( + opacity: (0.5 + lock * 0.5).clamp(0.0, 1.0), + child: Transform.translate( + offset: Offset(0, lock * 12), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 7, horizontal: 6), + decoration: BoxDecoration( + color: Color.alphaBlend( + cs.surfaceContainerHighest.withValues(alpha: 0.96), + cs.surface, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 6, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Symbols.lock, + size: 16, + color: lock > 0.6 ? cs.primary : cs.onSurfaceVariant, + ), + Icon( + Symbols.keyboard_arrow_up, + size: 14, + color: cs.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _voiceRecordingIndicator(ColorScheme cs) { + return Container( + color: Color.alphaBlend( + cs.surfaceContainerHighest.withValues(alpha: 0.92), + cs.surface, + ), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + ValueListenableBuilder( + valueListenable: voiceRec.amplitude, + builder: (context, amp, child) => TweenAnimationBuilder( + tween: Tween(begin: 0, end: amp), + duration: const Duration(milliseconds: 120), + builder: (context, v, child) => + Transform.scale(scale: 1.0 + v * 0.7, child: child), + child: child, + ), + child: _RecordingDot(color: cs.error), + ), + const SizedBox(width: 12), + ValueListenableBuilder( + valueListenable: voiceRec.elapsedMs, + builder: (context, ms, _) => Text( + formatElapsed(ms), + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontFeatures: const [ui.FontFeature.tabularFigures()], + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: ValueListenableBuilder( + valueListenable: voiceRec.cancelDrag, + builder: (context, drag, _) { + if (drag > 0.01) { + return Opacity( + opacity: (0.45 + drag * 0.55).clamp(0.0, 1.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Icon( + Symbols.arrow_back, + size: 16, + color: cs.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text( + 'Отмена', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + ), + ), + ], + ), + ); + } + return SizedBox( + height: 26, + child: ValueListenableBuilder( + valueListenable: voiceRec.waveRev, + builder: (context, _, _) => CustomPaint( + size: Size.infinite, + painter: _LiveWavePainter( + amps: voiceRec.amps, + color: cs.primary.withValues(alpha: 0.85), + ), + ), + ), + ); + }, + ), + ), + const SizedBox(width: 8), + ValueListenableBuilder( + valueListenable: voiceRec.locked, + builder: (context, locked, _) => locked + ? GestureDetector( + onTap: () => voiceRec.stop(cancel: true), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Icon(Symbols.delete, size: 22, color: cs.error), + ), + ) + : Text( + '‹ влево — отмена', + style: TextStyle(color: cs.mutedText, fontSize: 11), + ), + ), + ], + ), + ); + } +} + +class _AttachButton extends StatelessWidget { + final ValueListenable hasText; + final VoidCallback onOpen; + final VoidCallback onLongOpen; + final ValueListenable uploadStatus; + final Color mutedIcon; + final ColorScheme cs; + + const _AttachButton({ + required this.hasText, + required this.onOpen, + required this.onLongOpen, + required this.uploadStatus, + required this.mutedIcon, + required this.cs, + }); + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: Listenable.merge([hasText, uploadStatus]), + builder: (context, _) { + final isText = hasText.value; + final status = uploadStatus.value; + final iconColor = status.awaitingResponse + ? cs.primary + : (status.active + ? cs.onSurfaceVariant.withValues(alpha: 0.5) + : mutedIcon); + final disabled = isText || status.active; + final onTap = disabled ? null : onOpen; + final onLongPress = disabled ? null : onLongOpen; + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: isText ? 0 : 36, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: isText ? 0 : 1, + child: isText + ? const SizedBox.shrink() + : GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + onLongPress: onLongPress, + child: Padding( + padding: const EdgeInsets.only(left: 12), + child: Stack( + alignment: Alignment.center, + children: [ + if (status.active) + SizedBox( + width: 30, + height: 30, + child: CircularProgressIndicator( + strokeWidth: 2, + value: status.progressValue, + color: cs.primary, + ), + ), + Icon( + Symbols.attachment, + color: iconColor, + size: 22, + weight: 400, + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} + +class _HistoryStrip extends StatelessWidget { + final Animation anim; + final ColorScheme cs; + final Future Function(FileHistoryEntry entry) onTapEntry; + + const _HistoryStrip({ + required this.anim, + required this.cs, + required this.onTapEntry, + }); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FileHistoryCache.notifier, + builder: (context, history, _) { + if (history.isEmpty) { + return Center( + child: AnimatedBuilder( + animation: anim, + builder: (context, _) { + final v = anim.value.clamp(0.0, 1.0); + return Opacity( + opacity: v, + child: Text( + 'история пуста...', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ); + }, + ), + ); + } + return ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + itemCount: history.length, + itemBuilder: (ctx, idx) { + final e = history[idx]; + final startInterval = (idx * 0.05).clamp(0.0, 0.45); + return AnimatedBuilder( + animation: anim, + builder: (context, child) { + final raw = ((anim.value - startInterval) / 0.45).clamp( + 0.0, + 1.0, + ); + final v = Curves.easeOutCubic.transform(raw); + return Opacity( + opacity: v, + child: Transform.translate( + offset: Offset(-14 * (1 - v), 0), + child: child, + ), + ); + }, + child: Container( + width: 54, + margin: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + color: cs.surfaceContainerLow, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + ), + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onTapEntry(e), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _iconForFilename(e.filename), + color: cs.onSurfaceVariant, + size: 22, + ), + const SizedBox(height: 2), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 3, + ), + child: Text( + _labelForEntry(e), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 9, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + Positioned( + top: -2, + right: -2, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => FileHistoryCache.remove(e.fileId), + child: Container( + width: 18, + height: 18, + alignment: Alignment.center, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + shape: BoxShape.circle, + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.5), + width: 0.5, + ), + ), + child: Icon( + Symbols.close, + size: 12, + color: cs.onSurfaceVariant, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } +} + +class _ButtonClipper extends CustomClipper { + final double t; + const _ButtonClipper(this.t); + + @override + Rect getClip(Size size) { + if (t <= 0.001) { + return Rect.fromLTRB(-120, -260, size.width + 120, size.height + 40); + } + return Rect.fromLTRB(0, 0, size.width, size.height); + } + + @override + bool shouldReclip(_ButtonClipper old) => old.t != t; +} + +class _RecordingDot extends StatefulWidget { + final Color color; + const _RecordingDot({required this.color}); + + @override + State<_RecordingDot> createState() => _RecordingDotState(); +} + +class _RecordingDotState extends State<_RecordingDot> + with SingleTickerProviderStateMixin { + late final AnimationController _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(reverse: true); + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: Tween(begin: 1.0, end: 0.25).animate(_c), + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration(color: widget.color, shape: BoxShape.circle), + ), + ); + } +} + +class _LiveWavePainter extends CustomPainter { + final List amps; + final Color color; + + const _LiveWavePainter({required this.amps, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + const slot = 5.0; + const barW = 3.0; + final count = (size.width / slot).floor(); + if (count <= 0 || amps.isEmpty) return; + + final start = amps.length > count ? amps.length - count : 0; + final visible = amps.sublist(start); + final center = size.height / 2; + final paint = Paint()..color = color; + final offset = size.width - visible.length * slot; + + for (var i = 0; i < visible.length; i++) { + final h = (visible[i] * size.height).clamp(2.0, size.height); + final x = offset + i * slot + (slot - barW) / 2; + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(x, center - h / 2, barW, h), + const Radius.circular(barW / 2), + ), + paint, + ); + } + } + + @override + bool shouldRepaint(_LiveWavePainter old) => true; +} + +String _labelForEntry(FileHistoryEntry e) { + final n = e.filename; + if (n == null || n.isEmpty) return e.fileId.toString(); + final lastDot = n.lastIndexOf('.'); + return lastDot > 0 ? n.substring(0, lastDot) : n; +} + +IconData _iconForFilename(String? name) { + if (name == null || !name.contains('.')) return Symbols.description; + final ext = name.split('.').last.toLowerCase(); + switch (ext) { + case 'jpg': + case 'jpeg': + case 'png': + case 'gif': + case 'webp': + case 'bmp': + case 'heic': + case 'heif': + return Symbols.image; + case 'mp4': + case 'mov': + case 'avi': + case 'mkv': + case 'webm': + case '3gp': + return Symbols.movie; + case 'mp3': + case 'wav': + case 'ogg': + case 'flac': + case 'm4a': + case 'aac': + return Symbols.audio_file; + case 'pdf': + return Symbols.picture_as_pdf; + case 'zip': + case 'rar': + case '7z': + case 'tar': + case 'gz': + return Symbols.folder_zip; + case 'doc': + case 'docx': + case 'txt': + case 'rtf': + case 'odt': + case 'md': + return Symbols.article; + case 'xls': + case 'xlsx': + case 'csv': + return Symbols.table_chart; + case 'ppt': + case 'pptx': + return Symbols.slideshow; + case 'dart': + case 'js': + case 'ts': + case 'py': + case 'java': + case 'kt': + case 'swift': + case 'cpp': + case 'c': + case 'h': + case 'rs': + case 'go': + case 'rb': + case 'php': + case 'html': + case 'css': + case 'json': + case 'xml': + case 'yaml': + case 'yml': + return Symbols.code; + default: + return Symbols.description; + } +} diff --git a/lib/frontend/screens/chats/chat/view/search_view.dart b/lib/frontend/screens/chats/chat/view/search_view.dart new file mode 100644 index 0000000..2c7c683 --- /dev/null +++ b/lib/frontend/screens/chats/chat/view/search_view.dart @@ -0,0 +1,333 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import 'package:komet/core/config/app_animations.dart'; +import 'package:komet/core/config/app_chat_chrome.dart'; +import 'package:komet/core/utils/format.dart'; +import 'package:komet/frontend/widgets/animated_lottie_icon.dart'; +import 'package:komet/frontend/widgets/glossy_pill.dart'; +import 'package:komet/frontend/widgets/komet_avatar.dart'; +import 'package:komet/frontend/screens/chats/chat/chat_search_controller.dart'; +import 'package:komet/frontend/screens/chats/chat/message_search_result.dart'; + +class SearchTopBar extends StatelessWidget { + const SearchTopBar({ + super.key, + required this.cs, + required this.glossy, + required this.search, + required this.focusNode, + required this.onClose, + }); + + final ColorScheme cs; + final bool glossy; + final ChatSearchController search; + final FocusNode focusNode; + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + final field = TextField( + controller: search.searchController, + focusNode: focusNode, + textInputAction: TextInputAction.search, + onSubmitted: search.submit, + cursorColor: cs.primary, + style: TextStyle(color: cs.onSurface, fontSize: 16, fontFamily: 'Outfit'), + decoration: InputDecoration( + hintText: 'Поиск...', + hintStyle: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + fontFamily: 'Outfit', + ), + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 8), + ), + ); + + final backBtn = IconButton( + icon: Icon( + Symbols.arrow_back, + weight: glossy ? 500 : 400, + color: cs.onSurface, + ), + onPressed: onClose, + ); + final searchBtn = IconButton( + icon: AnimatedLottieIcon( + asset: AppAnimations.search, + color: cs.onSurface, + size: 24, + active: true, + animateOnMount: true, + ), + onPressed: () => search.submit(search.searchController.text), + ); + + if (!glossy) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + children: [ + backBtn, + Expanded(child: field), + searchBtn, + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.fromLTRB(10, 4, 10, 6), + child: GlossyPill( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: SizedBox( + height: 44, + child: Row( + children: [ + backBtn, + Expanded(child: field), + searchBtn, + ], + ), + ), + ), + ); + } +} + +class SearchOverlay extends StatelessWidget { + const SearchOverlay({ + super.key, + required this.cs, + required this.searchAnim, + required this.search, + required this.onOpenResult, + required this.senderName, + required this.senderAvatar, + }); + + final ColorScheme cs; + final Animation searchAnim; + final ChatSearchController search; + final void Function(MessageSearchResult) onOpenResult; + final String Function(int) senderName; + final String? Function(int) senderAvatar; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: searchAnim, + builder: (context, _) { + final s = Curves.easeOut.transform(searchAnim.value.clamp(0.0, 1.0)); + if (s == 0) return const SizedBox.shrink(); + final chrome = AppChatChrome.current.value; + final topPad = chrome == ChatChromeStyle.color + ? 0.0 + : MediaQuery.paddingOf(context).top; + return Positioned.fill( + child: IgnorePointer( + ignoring: s < 0.5, + child: Opacity( + opacity: s, + child: Container( + color: cs.surface, + padding: EdgeInsets.only(top: topPad), + child: _resultsContent(context), + ), + ), + ), + ); + }, + ); + } + + Widget _resultsContent(BuildContext context) { + return ValueListenableBuilder( + valueListenable: search.loading, + builder: (context, loading, _) => + ValueListenableBuilder>( + valueListenable: search.results, + builder: (context, results, _) { + if (results.isNotEmpty) { + return ListView.builder( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + padding: EdgeInsets.only( + top: 4, + bottom: MediaQuery.paddingOf(context).bottom + 16, + ), + itemCount: results.length, + itemBuilder: (context, index) => _tile(results[index]), + ); + } + if (loading) { + return Center( + child: SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator( + strokeWidth: 2.4, + color: cs.onSurfaceVariant, + ), + ), + ); + } + return ValueListenableBuilder( + valueListenable: search.performed, + builder: (context, performed, _) { + if (!performed) return const SizedBox.shrink(); + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'Поиск ничего не вернул...', + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + ), + ), + ); + }, + ); + }, + ), + ); + } + + Widget _tile(MessageSearchResult r) { + final name = senderName(r.senderId); + final date = formatDateWords(DateTime.fromMillisecondsSinceEpoch(r.time)); + return InkWell( + onTap: () => onOpenResult(r), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + KometAvatar( + name: name, + imageUrl: senderAvatar(r.senderId), + size: 44, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.primary, + fontSize: 15, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ), + const SizedBox(width: 8), + Text( + date, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: 2), + _highlighted(r.text, r.highlights), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _highlighted(String text, List highlights) { + final baseStyle = TextStyle(color: cs.onSurface, fontSize: 15); + final terms = highlights + .where((h) => h.trim().isNotEmpty) + .map((h) => h.toLowerCase()) + .toSet(); + if (text.isEmpty || terms.isEmpty) { + return Text( + text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: baseStyle, + ); + } + + final lower = text.toLowerCase(); + final ranges = >[]; + for (final term in terms) { + var start = 0; + while (true) { + final idx = lower.indexOf(term, start); + if (idx < 0) break; + ranges.add([idx, idx + term.length]); + start = idx + term.length; + } + } + if (ranges.isEmpty) { + return Text( + text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: baseStyle, + ); + } + + ranges.sort((a, b) => a[0].compareTo(b[0])); + final merged = >[]; + for (final r in ranges) { + if (merged.isNotEmpty && r[0] <= merged.last[1]) { + merged.last[1] = math.max(merged.last[1], r[1]); + } else { + merged.add([r[0], r[1]]); + } + } + + final highlightStyle = baseStyle.copyWith( + color: cs.primary, + fontWeight: FontWeight.w600, + backgroundColor: cs.primary.withValues(alpha: 0.18), + ); + final spans = []; + var cursor = 0; + for (final r in merged) { + if (r[0] > cursor) { + spans.add(TextSpan(text: text.substring(cursor, r[0]))); + } + spans.add( + TextSpan(text: text.substring(r[0], r[1]), style: highlightStyle), + ); + cursor = r[1]; + } + if (cursor < text.length) { + spans.add(TextSpan(text: text.substring(cursor))); + } + + return Text.rich( + TextSpan(style: baseStyle, children: spans), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ); + } +} diff --git a/lib/frontend/screens/chats/chat/view/selection_bar.dart b/lib/frontend/screens/chats/chat/view/selection_bar.dart new file mode 100644 index 0000000..8344868 --- /dev/null +++ b/lib/frontend/screens/chats/chat/view/selection_bar.dart @@ -0,0 +1,238 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/frontend/widgets/glossy_pill.dart'; + +class SelectionTopBar extends StatelessWidget { + final ColorScheme cs; + final Set selected; + final bool glossy; + final CachedMessage? copyMsg; + final CachedMessage? editMsg; + final VoidCallback onClear; + final void Function(CachedMessage) onCopy; + final void Function(CachedMessage) onEdit; + final VoidCallback onDelete; + + const SelectionTopBar({ + super.key, + required this.cs, + required this.selected, + required this.glossy, + required this.copyMsg, + required this.editMsg, + required this.onClear, + required this.onCopy, + required this.onEdit, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final count = selected.length; + final label = 'Выбрано $count'; + + if (!glossy) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + children: [ + IconButton( + icon: Icon(Symbols.close, color: cs.onSurface), + onPressed: onClear, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ), + if (copyMsg != null) + IconButton( + icon: Icon(Symbols.content_copy, color: cs.onSurface), + onPressed: () => onCopy(copyMsg!), + ), + if (editMsg != null) + IconButton( + icon: Icon(Symbols.edit, color: cs.onSurface), + onPressed: () => onEdit(editMsg!), + ), + IconButton( + icon: Icon(Symbols.delete, color: cs.onSurface), + onPressed: onDelete, + ), + ], + ), + ); + } + + Widget actionBtn(IconData icon, VoidCallback onTap) => IconButton( + icon: Icon(icon, weight: 500, color: cs.onSurface), + onPressed: onTap, + ); + + return Padding( + padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), + child: Row( + children: [ + SizedBox( + width: 56, + height: 56, + child: GlossyPill( + onTap: onClear, + child: Center( + child: Icon( + Symbols.close, + color: cs.onSurface, + weight: 500, + size: 24, + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: GlossyPill( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SizedBox( + height: 56, + child: Align( + alignment: Alignment.centerLeft, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ), + ), + ), + ), + const SizedBox(width: 8), + GlossyPill( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: SizedBox( + height: 56, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (copyMsg != null) + actionBtn(Symbols.content_copy, () => onCopy(copyMsg!)), + if (editMsg != null) + actionBtn(Symbols.edit, () => onEdit(editMsg!)), + actionBtn(Symbols.delete, onDelete), + ], + ), + ), + ), + ], + ), + ); + } +} + +class SelectionBottomBar extends StatelessWidget { + final ColorScheme cs; + final Set selected; + final VoidCallback onReply; + final VoidCallback onForward; + + const SelectionBottomBar({ + super.key, + required this.cs, + required this.selected, + required this.onReply, + required this.onForward, + }); + + @override + Widget build(BuildContext context) { + final single = selected.length == 1; + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + if (single) ...[ + Expanded( + child: _pill( + cs, + icon: Symbols.reply, + label: 'Ответить', + iconLeading: false, + onTap: onReply, + ), + ), + const SizedBox(width: 12), + ] else + const Spacer(), + Expanded( + child: _pill( + cs, + icon: Symbols.forward, + label: 'Переслать', + iconLeading: true, + onTap: onForward, + ), + ), + ], + ), + ), + ); + } + + Widget _pill( + ColorScheme cs, { + required IconData icon, + required String label, + required bool iconLeading, + required VoidCallback onTap, + }) { + final textWidget = Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ); + final iconWidget = Icon(icon, color: cs.onSurface, size: 22, weight: 500); + return GlossyPill( + onTap: onTap, + color: Color.alphaBlend( + cs.surfaceContainerHighest.withValues(alpha: 0.92), + cs.surface, + ), + borderRadius: BorderRadius.circular(28), + depth: 8, + borderSide: BorderSide( + color: cs.outlineVariant.withValues(alpha: 0.5), + width: 0.5, + ), + child: SizedBox( + height: 54, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: iconLeading + ? [iconWidget, const SizedBox(width: 8), textWidget] + : [textWidget, const SizedBox(width: 8), iconWidget], + ), + ), + ); + } +} diff --git a/lib/frontend/screens/chats/chat/view/shimmer_loading.dart b/lib/frontend/screens/chats/chat/view/shimmer_loading.dart new file mode 100644 index 0000000..76b4725 --- /dev/null +++ b/lib/frontend/screens/chats/chat/view/shimmer_loading.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; + +class ShimmerLoading extends StatelessWidget { + const ShimmerLoading({super.key, required this.shimmer}); + + final Animation shimmer; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: shimmer, + builder: (context, child) { + final cs = Theme.of(context).colorScheme; + final placeholder = cs.surfaceContainerHighest; + final opacity = 0.3 + (0.4 * shimmer.value); + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: 8, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + final hasImage = index % 3 == 0; + final hasReactions = index % 2 == 0; + final width1 = 60.0 + (index * 15 % 50); + final width2 = 120.0 + (index * 25 % 80); + + return Opacity( + opacity: opacity, + child: Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: placeholder, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: width1, + height: 10, + decoration: BoxDecoration( + color: placeholder, + borderRadius: BorderRadius.circular(5), + ), + ), + const SizedBox(height: 6), + Container( + width: width2, + height: 32, + decoration: BoxDecoration( + color: placeholder, + borderRadius: BorderRadius.circular(10), + ), + ), + if (hasImage) ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + height: 120, + decoration: BoxDecoration( + color: placeholder, + borderRadius: BorderRadius.circular(12), + ), + ), + ], + if (hasReactions) ...[ + const SizedBox(height: 8), + Row( + children: List.generate( + 3, + (i) => Container( + width: 32, + height: 16, + margin: const EdgeInsets.only(right: 6), + decoration: BoxDecoration( + color: placeholder, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/frontend/screens/chats/chat/view/sticker_panel_view.dart b/lib/frontend/screens/chats/chat/view/sticker_panel_view.dart new file mode 100644 index 0000000..226c977 --- /dev/null +++ b/lib/frontend/screens/chats/chat/view/sticker_panel_view.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import 'package:komet/frontend/screens/chats/chat/sticker_panel_controller.dart'; +import 'package:komet/frontend/widgets/sticker_panel.dart'; +import 'package:komet/models/sticker.dart'; + +class StickerPanelView extends StatelessWidget { + const StickerPanelView({ + super.key, + required this.stickers, + required this.onStickerTap, + }); + + final StickerPanelController stickers; + final void Function(StickerItem sticker) onStickerTap; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: stickers.anim, + child: StickerPanel( + height: stickers.panelHeight, + onStickerTap: onStickerTap, + ), + builder: (context, child) { + final t = Curves.easeOutCubic.transform( + stickers.anim.value.clamp(0.0, 1.0), + ); + if (t == 0) return const SizedBox.shrink(); + return ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: t, + child: child, + ), + ); + }, + ); + } +} diff --git a/lib/frontend/screens/chats/chat/voice_record_controller.dart b/lib/frontend/screens/chats/chat/voice_record_controller.dart new file mode 100644 index 0000000..493ae12 --- /dev/null +++ b/lib/frontend/screens/chats/chat/voice_record_controller.dart @@ -0,0 +1,251 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:record/record.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../../../../core/media/opus_ogg_encoder.dart'; +import '../../../../core/utils/haptics.dart'; +import '../../../widgets/custom_notification.dart'; + +class VoiceRecordController { + VoiceRecordController({ + required this.contextOf, + required this.isMounted, + required this.myId, + required this.onRecorded, + }); + + final BuildContext Function() contextOf; + final bool Function() isMounted; + final int Function() myId; + final Future Function(File file, int durationMs, List amps) + onRecorded; + + static const int minMs = 800; + static const double cancelThreshold = 110; + static const double _lockThreshold = 90; + + AudioRecorder? _recorder; + final ValueNotifier _isRecording = ValueNotifier(false); + final ValueNotifier _elapsedMs = ValueNotifier(0); + final ValueNotifier _cancelDrag = ValueNotifier(0); + final ValueNotifier _amplitude = ValueNotifier(0); + final ValueNotifier _waveRev = ValueNotifier(0); + final ValueNotifier _locked = ValueNotifier(false); + final ValueNotifier _lockDrag = ValueNotifier(0); + final Stopwatch _stopwatch = Stopwatch(); + final List _amps = []; + Timer? _timer; + StreamSubscription? _ampSub; + String? _path; + bool _cancelled = false; + bool _stopRequested = false; + bool _transcode = false; + + ValueListenable get isRecording => _isRecording; + ValueListenable get elapsedMs => _elapsedMs; + ValueListenable get cancelDrag => _cancelDrag; + ValueListenable get amplitude => _amplitude; + ValueListenable get waveRev => _waveRev; + ValueListenable get locked => _locked; + ValueListenable get lockDrag => _lockDrag; + List get amps => _amps; + + Future start() async { + if (_isRecording.value || myId() == 0) return; + _stopRequested = false; + final rec = _recorder ??= AudioRecorder(); + try { + final AudioEncoder encoder; + final String ext; + // Предпочитаем собственный кодер (libopus → Ogg/Opus): его формат сервер + // гарантированно принимает. Нативный Opus от record (напр. на Android) + // CDN не дообрабатывает — остаётся attachment.not.ready. + if (await OpusOggEncoder.ensureAvailable() && + await rec.isEncoderSupported(AudioEncoder.wav)) { + encoder = AudioEncoder.wav; + ext = 'wav'; + _transcode = true; + } else if (await rec.isEncoderSupported(AudioEncoder.opus)) { + encoder = AudioEncoder.opus; + ext = 'ogg'; + _transcode = false; + } else { + if (isMounted()) { + showCustomNotification( + contextOf(), + 'Голосовые сообщения недоступны на этой платформе', + ); + } + return; + } + if (!await rec.hasPermission()) { + if (isMounted()) { + showCustomNotification(contextOf(), 'Нет доступа к микрофону'); + } + return; + } + final dir = await getTemporaryDirectory(); + final path = + '${dir.path}/voice_${DateTime.now().millisecondsSinceEpoch}.$ext'; + _amps.clear(); + _cancelled = false; + _path = path; + await rec.start( + RecordConfig(encoder: encoder, numChannels: 1, sampleRate: 48000), + path: path, + ); + if (!isMounted()) { + try { + await rec.stop(); + } catch (_) {} + return; + } + _stopwatch + ..reset() + ..start(); + _elapsedMs.value = 0; + _cancelDrag.value = 0; + _locked.value = false; + _lockDrag.value = 0; + _isRecording.value = true; + FocusManager.instance.primaryFocus?.unfocus(); + Haptics.send(); + _timer = Timer.periodic(const Duration(milliseconds: 100), (_) { + _elapsedMs.value = _stopwatch.elapsedMilliseconds; + }); + _ampSub = rec.onAmplitudeChanged(const Duration(milliseconds: 70)).listen( + (amp) { + final norm = ((amp.current + 45) / 45).clamp(0.0, 1.0); + _amps.add(norm); + _amplitude.value = norm; + _waveRev.value++; + }, + ); + if (_stopRequested) { + _stopRequested = false; + await stop(cancel: false); + } + } catch (_) { + _isRecording.value = false; + if (isMounted()) { + showCustomNotification(contextOf(), 'Не удалось начать запись'); + } + } + } + + void handleDrag(Offset offsetFromOrigin) { + if (!_isRecording.value || _locked.value) return; + + final lock = (-offsetFromOrigin.dy / _lockThreshold).clamp(0.0, 1.0); + _lockDrag.value = lock; + if (lock >= 1.0) { + _locked.value = true; + _lockDrag.value = 0; + _cancelDrag.value = 0; + Haptics.send(); + return; + } + + final drag = (-offsetFromOrigin.dx / cancelThreshold).clamp(0.0, 1.0); + _cancelDrag.value = drag; + if (drag >= 1.0 && !_cancelled) { + _cancelled = true; + Haptics.error(); + stop(cancel: true); + } + } + + void handleEnd() { + if (_locked.value) return; + stop(cancel: false); + } + + Future stop({required bool cancel}) async { + if (!_isRecording.value) { + _stopRequested = true; + return; + } + final rec = _recorder; + if (rec == null) { + _isRecording.value = false; + return; + } + + _timer?.cancel(); + _timer = null; + await _ampSub?.cancel(); + _ampSub = null; + _stopwatch.stop(); + final elapsed = _stopwatch.elapsedMilliseconds; + _isRecording.value = false; + _cancelDrag.value = 0; + _amplitude.value = 0; + _locked.value = false; + _lockDrag.value = 0; + + String? path; + try { + path = await rec.stop(); + } catch (_) {} + path ??= _path; + final amps = List.from(_amps); + _amps.clear(); + + final shouldCancel = cancel || _cancelled || elapsed < minMs; + if (shouldCancel || path == null) { + if (path != null) { + try { + await File(path).delete(); + } catch (_) {} + } + return; + } + + var file = File(path); + if (_transcode) { + final ogg = await _transcodeWavToOgg(file); + if (ogg == null) { + if (isMounted()) { + showCustomNotification(contextOf(), 'Не удалось закодировать запись'); + } + return; + } + file = ogg; + } + await onRecorded(file, elapsed, amps); + } + + Future _transcodeWavToOgg(File wav) async { + try { + final bytes = await wav.readAsBytes(); + final ogg = await OpusOggEncoder.wavToOggOpus(bytes); + try { + await wav.delete(); + } catch (_) {} + if (ogg == null) return null; + final oggPath = '${wav.path.substring(0, wav.path.length - 3)}ogg'; + final out = File(oggPath); + await out.writeAsBytes(ogg, flush: true); + return out; + } catch (_) { + return null; + } + } + + void dispose() { + _timer?.cancel(); + _ampSub?.cancel(); + _recorder?.dispose(); + _isRecording.dispose(); + _elapsedMs.dispose(); + _cancelDrag.dispose(); + _amplitude.dispose(); + _waveRev.dispose(); + _locked.dispose(); + _lockDrag.dispose(); + } +} diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 18b73f8..fdaf2fd 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -7,6 +7,9 @@ import '../../../core/cache/info_cache.dart'; import '../../../core/config/app_show_extra_info.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/utils/format.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../models/chat_info.dart'; +import '../../../models/contact_info.dart'; import '../../widgets/avatar_hero.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/glossy_pill.dart'; @@ -60,19 +63,17 @@ class _ChatInfoScreenState extends State { int _myId = 0; bool _isLoading = true; bool _extraContactExpanded = false; - Map? _chatData; + ChatInfo? _chatInfo; String _selectedTab = ''; bool _descExpanded = false; - // DIALOG int? _otherId; - Map? _contactData; + ContactInfo? _contactData; int? _seenTime; bool _isOnline = false; int _presenceStatus = 0; bool _isBot = false; - // CHAT List<_MemberInfo> _members = []; int _onlineCount = 0; @@ -89,42 +90,43 @@ class _ChatInfoScreenState extends State { } List get _tabs { + final l10n = AppLocalizations.of(context)!; final showInfo = AppShowExtraInfo.current.value; switch (widget.chatType) { case 'DIALOG': if (_isBot) { return [ if (showInfo) 'Info', - 'Медиа', - 'Файлы', - 'Голосовые', - 'Ссылки', + l10n.chatInfoTabMedia, + l10n.chatInfoTabFiles, + l10n.chatInfoTabVoice, + l10n.chatInfoTabLinks, ]; } return [ - 'Общие чаты', - 'Медиа', + l10n.chatInfoTabGeneralChats, + l10n.chatInfoTabMedia, if (showInfo) 'Info', - 'Файлы', - 'Голосовые', - 'Ссылки', + l10n.chatInfoTabFiles, + l10n.chatInfoTabVoice, + l10n.chatInfoTabLinks, ]; case 'CHAT': return [ - 'Участники', + l10n.chatInfoTabMembers, if (showInfo) 'Info', - 'Медиа', - 'Файлы', - 'Голосовые', - 'Ссылки', + l10n.chatInfoTabMedia, + l10n.chatInfoTabFiles, + l10n.chatInfoTabVoice, + l10n.chatInfoTabLinks, ]; case 'CHANNEL': return [ if (showInfo) 'Info', - 'Медиа', - 'Файлы', - 'Голосовые', - 'Ссылки', + l10n.chatInfoTabMedia, + l10n.chatInfoTabFiles, + l10n.chatInfoTabVoice, + l10n.chatInfoTabLinks, ]; default: return [if (showInfo) 'Info']; @@ -137,15 +139,13 @@ class _ChatInfoScreenState extends State { final info = await ChatInfoFetch.get(widget.chatId); if (!mounted) return; - _chatData = info; + _chatInfo = info; if (widget.chatType == 'DIALOG') { _otherId = widget.dialogPeerId; if (_otherId == null && info != null) { - final parts = info['participants'] as Map? ?? {}; - for (final key in parts.keys) { - final id = key is int ? key : int.tryParse(key.toString()); - if (id != null && id != _myId) { + for (final id in info.participantIds) { + if (id != _myId) { _otherId = id; break; } @@ -156,8 +156,7 @@ class _ChatInfoScreenState extends State { final contact = await ContactInfoFetch.get(_otherId!); if (contact != null) { _contactData = contact; - final opts = _contactData!['options']; - _isBot = (opts is List) && opts.contains('BOT'); + _isBot = _contactData!.options.contains('BOT'); } final presence = await PresenceFetch.get(_otherId!); @@ -172,15 +171,8 @@ class _ChatInfoScreenState extends State { setState(() => _isLoading = false); return; } else if (widget.chatType == 'CHAT') { - final parts = _chatData!['participants'] as Map? ?? {}; - final admins = _chatData!['adminParticipants'] as Map? ?? {}; - final owner = _chatData!['owner'] as int?; - - final memberIds = []; - for (final k in parts.keys) { - final id = k is int ? k : int.tryParse(k.toString()); - if (id != null) memberIds.add(id); - } + final chatInfo = _chatInfo!; + final memberIds = chatInfo.participantIds; Map> presenceMap = {}; if (memberIds.isNotEmpty) { @@ -192,12 +184,10 @@ class _ChatInfoScreenState extends State { final pres = presenceMap[id]; final online = (pres?['status'] as int?) == 1; if (online) _onlineCount++; - final isAdmin = - admins.containsKey(id.toString()) || admins.containsKey(id); return _MemberInfo( id: id, - isAdmin: isAdmin, - isOwner: id == owner, + isAdmin: chatInfo.isAdmin(id), + isOwner: chatInfo.isOwner(id), isMe: id == _myId, seenTime: pres?['seen'] as int?, isOnline: online, @@ -214,13 +204,13 @@ class _ChatInfoScreenState extends State { if (mounted) { setState(() { _isLoading = false; - if (_selectedTab.isEmpty && _tabs.isNotEmpty) _selectedTab = _tabs.first; + if (_selectedTab.isEmpty && _tabs.isNotEmpty) { + _selectedTab = _tabs.first; + } }); } } - // ─── BUILD ─────────────────────────────────────────────────────────────── - @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -295,59 +285,86 @@ class _ChatInfoScreenState extends State { ); } - // ─── SUBTITLE ──────────────────────────────────────────────────────────── - String _subtitle() { + final l10n = AppLocalizations.of(context)!; switch (widget.chatType) { case 'DIALOG': - if (_isBot) return 'Бот'; - if (_isOnline) return 'В сети'; - if (_presenceStatus == 3) return 'был(-а) недавно'; + if (_isBot) return l10n.contactProfileBot; + if (_isOnline) return l10n.contactProfileOnline; + if (_presenceStatus == 3) return l10n.contactProfileRecentlyActive; if (_seenTime != null && _seenTime! > 0) { - return 'был(-а) ${_formatLastSeen(_seenTime!)}'; + return formatLastSeen(_seenTime!); } return ''; case 'CHAT': - final total = - (_chatData?['participantsCount'] as int?) ?? _members.length; - if (_onlineCount > 0) return '$_onlineCount из $total в сети'; - return _pluralCount(total, 'участник', 'участника', 'участников'); + final total = _chatInfo?.participantsCount ?? _members.length; + if (_onlineCount > 0) { + return l10n.chatInfoOnlineOfTotal('$_onlineCount', '$total'); + } + return '$total ${pluralRu(total, 'участник', 'участника', 'участников')}'; case 'CHANNEL': - final count = (_chatData?['participantsCount'] as int?) ?? 0; - return _pluralCount(count, 'подписчик', 'подписчика', 'подписчиков'); + final count = _chatInfo?.participantsCount ?? 0; + return '$count ${pluralRu(count, 'подписчик', 'подписчика', 'подписчиков')}'; default: return ''; } } - // ─── ACTION BUTTONS ────────────────────────────────────────────────────── - Widget _buildActions(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; final List<({IconData icon, String label, VoidCallback? onTap})> btns; if (widget.chatType == 'DIALOG') { if (_isBot) { btns = [ - (icon: Icons.chat_bubble, label: 'Чат', onTap: _openChat), - (icon: Icons.notifications, label: 'Звук', onTap: null), + ( + icon: Icons.chat_bubble, + label: l10n.contactProfileActionChat, + onTap: _openChat, + ), + ( + icon: Icons.notifications, + label: l10n.contactProfileActionSound, + onTap: null, + ), ]; } else { btns = [ - (icon: Icons.chat_bubble, label: 'Чат', onTap: _openChat), - (icon: Icons.notifications, label: 'Звук', onTap: null), - (icon: Icons.call, label: 'Звонок', onTap: null), + ( + icon: Icons.chat_bubble, + label: l10n.contactProfileActionChat, + onTap: _openChat, + ), + ( + icon: Icons.notifications, + label: l10n.contactProfileActionSound, + onTap: null, + ), + (icon: Icons.call, label: l10n.contactProfileActionCall, onTap: null), ]; } } else if (widget.chatType == 'CHANNEL') { btns = [ - (icon: Icons.notifications, label: 'Звук', onTap: null), - (icon: Icons.exit_to_app, label: 'Покинуть', onTap: null), + ( + icon: Icons.notifications, + label: l10n.contactProfileActionSound, + onTap: null, + ), + (icon: Icons.exit_to_app, label: l10n.chatInfoActionLeave, onTap: null), ]; } else { btns = [ - (icon: Icons.chat_bubble, label: 'Чат', onTap: null), - (icon: Icons.notifications, label: 'Звук', onTap: null), - (icon: Icons.exit_to_app, label: 'Покинуть', onTap: null), + ( + icon: Icons.chat_bubble, + label: l10n.contactProfileActionChat, + onTap: null, + ), + ( + icon: Icons.notifications, + label: l10n.contactProfileActionSound, + onTap: null, + ), + (icon: Icons.exit_to_app, label: l10n.chatInfoActionLeave, onTap: null), ]; } @@ -405,41 +422,47 @@ class _ChatInfoScreenState extends State { ); } - // ─── PERSISTENT INFO (shown above tabs for all types) ──────────────────── - Widget _buildPersistentInfo(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; final items = []; if (widget.chatType == 'DIALOG') { if (_isBot) { - final link = _contactData?['link'] as String?; + final link = _contactData?.raw['link'] as String?; if (link != null && link.isNotEmpty) { - items.add(_simpleInfoCard(cs, 'Ссылка', link, isLink: true)); + items.add( + _simpleInfoCard( + cs, + l10n.contactProfileInfoLink, + link, + isLink: true, + ), + ); } } else { - final phone = _contactData?['phone']; + final phone = _contactData?.raw['phone']; final phoneInt = phone is int ? phone : int.tryParse(phone?.toString() ?? ''); if (phoneInt != null && phoneInt > 0) { items.add( - _simpleInfoCard(cs, 'Номер телефона', formatPhone(phoneInt)!), + _simpleInfoCard(cs, l10n.loginPhoneNumber, formatPhone(phoneInt)!), ); } final bio = - (_contactData?['description'] as String?) ?? - (_contactData?['about'] as String?); + (_contactData?.raw['description'] as String?) ?? + (_contactData?.raw['about'] as String?); if (bio != null && bio.isNotEmpty) { if (items.isNotEmpty) items.add(const SizedBox(height: 8)); - items.add(_simpleInfoCard(cs, 'О себе', bio)); + items.add(_simpleInfoCard(cs, l10n.chatInfoBio, bio)); } } } else if (widget.chatType == 'CHANNEL') { - final link = _chatData?['link'] as String?; + final link = _chatInfo?.link; if (link != null && link.isNotEmpty) { items.add(_linkCard(cs, link)); } - final desc = _chatData?['description'] as String?; + final desc = _chatInfo?.description; if (desc != null && desc.isNotEmpty) { if (items.isNotEmpty) items.add(const SizedBox(height: 8)); items.add(_collapsibleDescCard(cs, desc)); @@ -477,7 +500,7 @@ class _ChatInfoScreenState extends State { Text( value, style: TextStyle( - color: isLink ? const Color(0xFF007AFF) : cs.onSurface, + color: isLink ? cs.primary : cs.onSurface, fontSize: 16, fontWeight: FontWeight.w500, ), @@ -489,6 +512,7 @@ class _ChatInfoScreenState extends State { } Widget _linkCard(ColorScheme cs, String link) { + final l10n = AppLocalizations.of(context)!; return GlossyPill( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(14), @@ -501,26 +525,16 @@ class _ChatInfoScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Ссылка-приглашение', + l10n.chatInfoInviteLink, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 4), - Text( - link, - style: const TextStyle( - color: Color(0xFF007AFF), - fontSize: 15, - ), - ), + Text(link, style: TextStyle(color: cs.primary, fontSize: 15)), ], ), ), IconButton( - icon: const Icon( - Icons.qr_code_2, - color: Color(0xFF007AFF), - size: 22, - ), + icon: Icon(Icons.qr_code_2, color: cs.primary, size: 22), onPressed: () {}, ), ], @@ -529,6 +543,7 @@ class _ChatInfoScreenState extends State { } Widget _collapsibleDescCard(ColorScheme cs, String desc) { + final l10n = AppLocalizations.of(context)!; const int collapsedLines = 3; final isLong = desc.length > 120; @@ -543,7 +558,7 @@ class _ChatInfoScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Описание', + l10n.contactProfileInfoDescription, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 4), @@ -551,17 +566,17 @@ class _ChatInfoScreenState extends State { desc, style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.4), maxLines: (_descExpanded || !isLong) ? null : collapsedLines, - overflow: - (_descExpanded || !isLong) ? null : TextOverflow.ellipsis, + overflow: (_descExpanded || !isLong) + ? null + : TextOverflow.ellipsis, ), if (isLong) ...[ const SizedBox(height: 6), GestureDetector( onTap: () => setState(() => _descExpanded = !_descExpanded), child: Text( - _descExpanded ? 'Свернуть' : 'Ещё', - style: - const TextStyle(color: Color(0xFF007AFF), fontSize: 13), + _descExpanded ? l10n.chatInfoCollapse : l10n.chatInfoShowMore, + style: TextStyle(color: cs.primary, fontSize: 13), ), ), ], @@ -571,8 +586,6 @@ class _ChatInfoScreenState extends State { ); } - // ─── TAB BAR ───────────────────────────────────────────────────────────── - Widget _buildTabBar(ColorScheme cs) { return LayoutBuilder( builder: (context, constraints) => ScrollConfiguration( @@ -644,8 +657,6 @@ class _ChatInfoScreenState extends State { ); } - // ─── TAB CONTENT ───────────────────────────────────────────────────────── - Widget _buildTabContent(ColorScheme cs) { if (_selectedTab.isEmpty) return const SizedBox.shrink(); return AnimatedSwitcher( @@ -655,24 +666,31 @@ class _ChatInfoScreenState extends State { } Widget _tabBody(ColorScheme cs) { - switch (_selectedTab) { - case 'Info': - return _buildInfoTabContent(cs); - case 'Участники': - return _buildMembersTabContent(cs); - case 'Общие чаты': - return _buildPlaceholder(cs, 'Нет общих чатов', Icons.group); - case 'Медиа': - return _buildPlaceholder(cs, 'Нет медиа', Icons.photo_library); - case 'Файлы': - return _buildPlaceholder(cs, 'Нет файлов', Icons.description); - case 'Голосовые': - return _buildPlaceholder(cs, 'Нет голосовых', Icons.mic); - case 'Ссылки': - return _buildPlaceholder(cs, 'Нет ссылок', Icons.link); - default: - return const SizedBox.shrink(); + final l10n = AppLocalizations.of(context)!; + if (_selectedTab == 'Info') return _buildInfoTabContent(cs); + if (_selectedTab == l10n.chatInfoTabMembers) { + return _buildMembersTabContent(cs); } + if (_selectedTab == l10n.chatInfoTabGeneralChats) { + return _buildPlaceholder(cs, l10n.chatInfoEmptyGeneralChats, Icons.group); + } + if (_selectedTab == l10n.chatInfoTabMedia) { + return _buildPlaceholder( + cs, + l10n.chatInfoEmptyMedia, + Icons.photo_library, + ); + } + if (_selectedTab == l10n.chatInfoTabFiles) { + return _buildPlaceholder(cs, l10n.chatInfoEmptyFiles, Icons.description); + } + if (_selectedTab == l10n.chatInfoTabVoice) { + return _buildPlaceholder(cs, l10n.chatInfoEmptyVoice, Icons.mic); + } + if (_selectedTab == l10n.chatInfoTabLinks) { + return _buildPlaceholder(cs, l10n.chatInfoEmptyLinks, Icons.link); + } + return const SizedBox.shrink(); } Widget _buildPlaceholder(ColorScheme cs, String label, IconData icon) { @@ -696,16 +714,15 @@ class _ChatInfoScreenState extends State { ); } - // ─── INFO TAB ──────────────────────────────────────────────────────────── - Widget _buildInfoTabContent(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; final items = []; if (widget.chatType == 'CHAT') { - final desc = _chatData?['description'] as String?; + final desc = _chatInfo?.description; if (desc != null && desc.isNotEmpty) { items - ..add(_infoCard(cs, 'Описание', desc)) + ..add(_infoCard(cs, l10n.contactProfileInfoDescription, desc)) ..add(const SizedBox(height: 8)); } } @@ -758,9 +775,8 @@ class _ChatInfoScreenState extends State { ); } - // ─── MEMBERS TAB ───────────────────────────────────────────────────────── - Widget _buildMembersTabContent(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return Container( decoration: BoxDecoration( color: cs.surfaceContainerHigh, @@ -768,7 +784,7 @@ class _ChatInfoScreenState extends State { ), child: Column( children: [ - _memberAction(cs, Icons.person_add, 'Добавить участника', () {}), + _memberAction(cs, Icons.person_add, l10n.chatInfoAddMember, () {}), ..._members.expand((m) => [_listDivider(cs), _memberTile(cs, m)]), ], ), @@ -788,7 +804,7 @@ class _ChatInfoScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( children: [ - Icon(icon, color: const Color(0xFF007AFF), size: 26), + Icon(icon, color: cs.primary, size: 26), const SizedBox(width: 14), Text(label, style: TextStyle(color: cs.onSurface, fontSize: 16)), ], @@ -805,24 +821,26 @@ class _ChatInfoScreenState extends State { ); Widget _memberTile(ColorScheme cs, _MemberInfo member) { + final l10n = AppLocalizations.of(context)!; final name = - ContactCache.get(member.id) ?? (member.isMe ? 'Вы' : '${member.id}'); + ContactCache.get(member.id) ?? + (member.isMe ? l10n.callParticipantYou : '${member.id}'); final avatar = ContactCache.getAvatar(member.id); final String sublabel; if (member.isMe) { - sublabel = 'Вы'; + sublabel = l10n.callParticipantYou; } else if (member.isOnline) { - sublabel = 'В сети'; + sublabel = l10n.contactProfileOnline; } else if (member.seenTime != null) { - sublabel = _formatLastSeen(member.seenTime!); + sublabel = formatLastSeen(member.seenTime!); } else { - sublabel = 'Был(-а) недавно'; + sublabel = l10n.contactProfileRecentlyActive; } final String? roleLabel = member.isOwner - ? 'владелец' - : (member.isAdmin ? 'Адмін' : null); + ? l10n.chatInfoRoleOwner + : (member.isAdmin ? l10n.chatInfoRoleAdmin : null); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), @@ -879,14 +897,13 @@ class _ChatInfoScreenState extends State { ); } - // ─── INFO ROWS ──────────────────────────────────────────────────────────── - Widget _buildAllInfoRows(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; final rows = <({String label, String value})>[]; - final chat = _chatData; + final chat = _chatInfo?.raw; if (chat == null) { return Text( - 'Нет данных', + l10n.chatInfoNoData, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ); } @@ -898,7 +915,7 @@ class _ChatInfoScreenState extends State { if (tsFormat && val is int && val > 1) { str = formatDateTimeNumeric(DateTime.fromMillisecondsSinceEpoch(val)); } else if (val is bool) { - str = 'да'; + str = l10n.callValueYes; } else { str = val.toString(); } @@ -907,54 +924,59 @@ class _ChatInfoScreenState extends State { } final type = widget.chatType; - add('ID чата', chat['id']); + add(l10n.chatInfoRowId, chat['id']); if (type == 'DIALOG') { - add('Создан', chat['created'], tsFormat: true); - add('Изменён', chat['modified'], tsFormat: true); - add('Статус', chat['status']); + add(l10n.chatInfoRowCreated, chat['created'], tsFormat: true); + add(l10n.chatInfoRowModified, chat['modified'], tsFormat: true); + add(l10n.callInfoStatus, chat['status']); } if (type == 'CHAT') { - add('Участников', chat['participantsCount']); + add(l10n.chatInfoRowMembersCount, chat['participantsCount']); final owner = chat['owner'] as int?; if (owner != null && owner != 0) { - add('Владелец', ContactCache.get(owner) ?? '$owner'); + add(l10n.chatInfoRowOwner, ContactCache.get(owner) ?? '$owner'); } - add('Создана', chat['created'], tsFormat: true); + add(l10n.chatInfoRowCreatedGroup, chat['created'], tsFormat: true); add( - 'Вступил', + l10n.chatInfoRowJoined, (chat['joinTime'] as int?) != null && (chat['joinTime'] as int) > 1 ? chat['joinTime'] : null, tsFormat: true, ); - add('Изменена', chat['modified'], tsFormat: true); - add('Есть боты', chat['hasBots'] as bool?); + add(l10n.chatInfoRowModifiedGroup, chat['modified'], tsFormat: true); + add(l10n.chatInfoRowHasBots, chat['hasBots'] as bool?); final blocked = chat['blockedParticipantsCount'] as int?; - if (blocked != null && blocked > 0) add('Заблокировано', blocked); + if (blocked != null && blocked > 0) { + add(l10n.chatInfoRowBlockedCount, blocked); + } final opts = chat['options'] as Map?; - add('Официальная', opts?['OFFICIAL'] as bool?); - add('Подпись адм.', opts?['SIGN_ADMIN'] as bool?); - add('Статус', chat['status']); + add(l10n.chatInfoRowOfficialGroup, opts?['OFFICIAL'] as bool?); + add(l10n.chatInfoRowSignAdmin, opts?['SIGN_ADMIN'] as bool?); + add(l10n.callInfoStatus, chat['status']); } if (type == 'CHANNEL') { - add('Подписчиков', chat['participantsCount']); - add('Создан', chat['created'], tsFormat: true); - add('Изменён', chat['modified'], tsFormat: true); + add(l10n.chatInfoRowSubscribersCount, chat['participantsCount']); + add(l10n.chatInfoRowCreated, chat['created'], tsFormat: true); + add(l10n.chatInfoRowModified, chat['modified'], tsFormat: true); final opts = chat['options'] as Map?; - add('Официальный', opts?['OFFICIAL'] as bool?); - add('Комментарии', opts?['COMMENTS'] as bool?); - add('РКН', opts?['A_PLUS_CHANNEL'] as bool?); - add('Подпись адм.', opts?['SIGN_ADMIN'] as bool?); - add('Только адм.', opts?['ONLY_ADMIN_CAN_ADD_MEMBER'] as bool?); - add('Статус', chat['status']); + add(l10n.chatInfoRowOfficialChannel, opts?['OFFICIAL'] as bool?); + add(l10n.chatInfoRowComments, opts?['COMMENTS'] as bool?); + add(l10n.chatInfoRowRkn, opts?['A_PLUS_CHANNEL'] as bool?); + add(l10n.chatInfoRowSignAdmin, opts?['SIGN_ADMIN'] as bool?); + add( + l10n.chatInfoRowOnlyAdmin, + opts?['ONLY_ADMIN_CAN_ADD_MEMBER'] as bool?, + ); + add(l10n.callInfoStatus, chat['status']); } if (rows.isEmpty) { return Text( - 'Нет данных', + l10n.chatInfoNoData, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ); } @@ -998,59 +1020,69 @@ class _ChatInfoScreenState extends State { } List<({String label, String value})> _buildExtraContactRows() { + final l10n = AppLocalizations.of(context)!; final c = _contactData; if (c == null) return const []; final rows = <({String label, String value})>[]; - final reg = c['registrationTime']; + final reg = c.raw['registrationTime']; if (reg is int && reg > 0) { rows.add(( - label: 'Регистрация', + label: l10n.contactProfileInfoRegistration, value: formatDateTimeNumeric(DateTime.fromMillisecondsSinceEpoch(reg)), )); } - final upd = c['updateTime']; + final upd = c.raw['updateTime']; if (upd is int && upd > 0) { rows.add(( - label: 'Обновлён', + label: l10n.contactProfileInfoUpdated, value: formatDateTimeNumeric(DateTime.fromMillisecondsSinceEpoch(upd)), )); } - final country = c['country']; + final country = c.raw['country']; if (country is String && country.isNotEmpty) { - rows.add((label: 'Страна', value: country)); + rows.add((label: l10n.contactProfileInfoCountry, value: country)); } - final gender = c['gender']; + final gender = c.raw['gender']; if (gender is int) { final g = formatGender(gender); - if (g != null) rows.add((label: 'Пол', value: g)); + if (g != null) rows.add((label: l10n.contactProfileInfoGender, value: g)); } - final phone = c['phone']; + final phone = c.raw['phone']; if (phone is int && phone > 0) { - rows.add((label: 'Телефон', value: '+$phone')); + rows.add((label: l10n.contactProfileInfoPhone, value: '+$phone')); } else if (phone is String && phone.isNotEmpty && phone != '***') { - rows.add((label: 'Телефон', value: phone)); + rows.add((label: l10n.contactProfileInfoPhone, value: phone)); } - final accStatus = c['accountStatus']; + final accStatus = c.raw['accountStatus']; if (accStatus is int && accStatus != 0) { - rows.add((label: 'Статус аккаунта', value: accStatus.toString())); + rows.add(( + label: l10n.contactProfileInfoAccountStatus, + value: accStatus.toString(), + )); } - final opts = c['options']; + final opts = c.raw['options']; if (opts is List && opts.isNotEmpty) { - rows.add((label: 'Флаги', value: opts.whereType().join(', '))); + rows.add(( + label: l10n.contactProfileInfoFlags, + value: opts.whereType().join(', '), + )); } - final link = c['link']; + final link = c.raw['link']; if (link is String && link.isNotEmpty) { - rows.add((label: 'Ссылка', value: link)); + rows.add((label: l10n.contactProfileInfoLink, value: link)); } return rows; } Widget? _trailingFor(String label, ColorScheme cs) { - if (label != 'ID чата') return null; + final l10n = AppLocalizations.of(context)!; + if (label != l10n.chatInfoRowId) return null; if (widget.chatType != 'DIALOG') return null; if (_contactData == null) return null; return IconButton( - tooltip: _extraContactExpanded ? 'Скрыть' : 'Подробнее', + tooltip: _extraContactExpanded + ? l10n.chatInfoHideExtra + : l10n.chatInfoShowMoreExtra, icon: AnimatedRotation( turns: _extraContactExpanded ? 0.125 : 0, duration: const Duration(milliseconds: 220), @@ -1099,8 +1131,6 @@ class _ChatInfoScreenState extends State { ); } - // ─── SHIMMER ───────────────────────────────────────────────────────────── - Widget _heroAvatar() { return AvatarHero( key: _avatarHeroKey, @@ -1151,25 +1181,4 @@ class _ChatInfoScreenState extends State { ], ); } - - // ─── HELPERS ───────────────────────────────────────────────────────────── - - String _formatLastSeen(int secondsSinceEpoch) { - final diff = - DateTime.now().millisecondsSinceEpoch - secondsSinceEpoch * 1000; - if (diff < 60000) return 'только что'; - if (diff < 3600000) return '${diff ~/ 60000} мин назад'; - if (diff < 86400000) return '${diff ~/ 3600000} ч назад'; - if (diff < 604800000) return '${diff ~/ 86400000} д назад'; - return 'давно'; - } - - String _pluralCount(int n, String one, String few, String many) { - final mod100 = n % 100; - final mod10 = n % 10; - if (mod100 >= 11 && mod100 <= 14) return '$n $many'; - if (mod10 == 1) return '$n $one'; - if (mod10 >= 2 && mod10 <= 4) return '$n $few'; - return '$n $many'; - } } diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index a71dddf..ff81f91 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:komet/backend/modules/messages.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'dart:math'; @@ -28,7 +27,8 @@ import '../profile/settings_tab.dart'; import '../auth/login_screen.dart'; import '../digital_id/digital_id_web_screen.dart'; import '../../widgets/account_switcher_overlay.dart'; -import '../../widgets/animated_text_swap.dart'; +import 'chat/view/chat_list_shimmer.dart'; +import 'chat/view/chat_list_tile.dart'; import '../../widgets/connection_status.dart'; import '../../../backend/api.dart'; import '../../../core/protocol/opcode_map.dart'; @@ -36,6 +36,7 @@ import '../../../core/protocol/packet.dart'; import '../../../core/utils/haptics.dart'; import '../../../core/config/app_animations.dart'; import '../../../core/config/app_stories.dart'; +import '../../../core/config/app_colors.dart'; import '../../../backend/models/chat_folder.dart'; import '../../../backend/modules/account.dart'; import '../../../backend/modules/chats.dart'; @@ -204,9 +205,6 @@ class _ChatListScreenState extends State Widget? _cachedChatsBody; Object? _chatsBodyCacheKey; - /// Возвращает дерево вкладки «Чаты», кэшируя его между ребилдами - /// родителя. Тап/драг навбара и FAB не трогают эти state-vars, - /// поэтому ключ остаётся прежним и subtree не пересобирается. Widget _getChatsBody() { final key = Object.hashAll([ identityHashCode(_chats), @@ -281,7 +279,7 @@ class _ChatListScreenState extends State final selected = _selectedChatObjects(); if (selected.isEmpty) return; final anyPinned = selected.any((c) => (c.favIndex ?? 0) > 0); - final err = await ChatsModule.togglePin( + final err = await chats.togglePin( api, chatIds: selected.map((c) => c.id).toList(), pin: !anyPinned, @@ -299,7 +297,7 @@ class _ChatListScreenState extends State final errors = []; for (final c in selected) { - final err = await ChatsModule.setChatMute( + final err = await chats.setChatMute( api, chatId: c.id, dontDisturbUntil: targetDDU, @@ -324,10 +322,7 @@ class _ChatListScreenState extends State final myId = _profile?.id; if (myId == null) return; - await ChatsModule.refreshChats( - api, - selectedBefore.map((c) => c.id).toList(), - ); + await chats.refreshChats(api, selectedBefore.map((c) => c.id).toList()); if (!mounted) return; final selectedAfter = _selectedChatObjects(); @@ -348,7 +343,7 @@ class _ChatListScreenState extends State final errors = []; for (final c in selectedAfter) { final forAll = kind == _DeleteKind.ownerGroup; - final err = await ChatsModule.deleteChat( + final err = await chats.deleteChat( api, chatId: c.id, lastEventTime: c.lastEventTime, @@ -540,13 +535,13 @@ class _ChatListScreenState extends State _requestReload(); } }); - ChatsModule.chatsChanged.addListener(_onChatsChanged); + chats.chatsChanged.addListener(_onChatsChanged); DraftStore.instance.revision.addListener(_onDraftsChanged); AppStories.current.addListener(_onStoriesEnabledChanged); _typingSub = api.pushStream .where((p) => p.opcode == Opcode.notifTyping) .listen(_onTypingPush); - _typingMsgSub = ChatsModule.messageEvents.listen(_onTypingMessageEvent); + _typingMsgSub = chats.messageEvents.listen(_onTypingMessageEvent); unawaited(_runReload()); } @@ -658,7 +653,7 @@ class _ChatListScreenState extends State } try { - final chats = await ChatsModule.getChats(p.id); + final loadedChats = await chats.getChats(p.id); var folders = await FoldersModule.loadFolders(p.id); final foldersKnown = await FoldersModule.hasReceivedFoldersList(p.id); @@ -677,7 +672,7 @@ class _ChatListScreenState extends State final pageCount = folders.isEmpty ? 1 : folders.length; _syncFolderChatScrollControllersForCount(pageCount); - final filteredChats = chats + final filteredChats = loadedChats .where((c) => !CloudStorageModule.isCloudStorageGroup(c)) .toList(); final newIds = filteredChats.map((c) => c.id.toString()).toSet(); @@ -712,7 +707,7 @@ class _ChatListScreenState extends State } _isInitialLoading = false; }); - _prefetchContactsForChats(chats); + _prefetchContactsForChats(loadedChats); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _jumpFolderPageToSelection(); @@ -767,7 +762,7 @@ class _ChatListScreenState extends State return 0; } - void _prefetchContactsForChats(List chats) { + Future _prefetchContactsForChats(List chats) async { final myId = _profile?.id; final ids = {}; for (final chat in chats) { @@ -786,11 +781,11 @@ class _ChatListScreenState extends State ids.removeAll(_inflightContactIds); if (ids.isEmpty) return; _inflightContactIds.addAll(ids); - for (final id in ids) { - messagesModule.searchContactById(id).whenComplete(() { - _inflightContactIds.remove(id); - _scheduleContactRebuild(); - }); + try { + await messagesModule.ensureContactNames(ids); + } finally { + _inflightContactIds.removeAll(ids); + _scheduleContactRebuild(); } } @@ -949,60 +944,6 @@ class _ChatListScreenState extends State return formatClock(DateTime.fromMillisecondsSinceEpoch(timestamp)); } - Widget _buildChatShimmer() { - final cs = Theme.of(context).colorScheme; - return AnimatedBuilder( - animation: _shimmerController, - builder: (context, child) { - final opacity = 0.3 + 0.3 * sin(_shimmerController.value * pi * 2); - return Opacity(opacity: opacity, child: child); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 12), - Expanded( - child: SizedBox( - height: 48, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 120, - height: 14, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(7), - ), - ), - Container( - width: double.infinity, - height: 12, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(6), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } - void _onStoriesRevealTick() { if (!mounted) return; final t = Curves.easeOutCubic.transform(_storiesRevealController.value); @@ -1130,7 +1071,7 @@ class _ChatListScreenState extends State void dispose() { appRouteObserver.unsubscribe(this); _settleTimer?.cancel(); - ChatsModule.chatsChanged.removeListener(_onChatsChanged); + chats.chatsChanged.removeListener(_onChatsChanged); DraftStore.instance.revision.removeListener(_onDraftsChanged); AppStories.current.removeListener(_onStoriesEnabledChanged); _loginSub?.cancel(); @@ -1179,7 +1120,6 @@ class _ChatListScreenState extends State if (index == _currentNavIndex && !_navPageAnimController.isAnimating) { return; } - // Detent "click" when crossing into a different tab. Haptics.selection(); double fromT; if (_navPageAnimController.isAnimating) { @@ -1404,7 +1344,7 @@ class _ChatListScreenState extends State }, ), child: _showFoldersShimmer - ? _buildFolderStripShimmer(cs) + ? FolderStripShimmer(shimmer: _shimmerController) : LayoutBuilder( builder: (context, constraints) { final availableWidth = constraints.maxWidth - 40; @@ -1516,141 +1456,141 @@ class _ChatListScreenState extends State ) else SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - if (_isInitialLoading) { - return _buildChatShimmer(); - } - - if (hasSeparator && index == pinnedCount) { - return Padding( - key: const ValueKey('pinned_divider'), - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Divider( - height: 1, - thickness: 0.5, - color: cs.outlineVariant.withValues(alpha: 0.5), - ), - ); - } - - final chatIndex = hasSeparator && index > pinnedCount - ? index - 1 - : index; - final chat = chats[chatIndex]; - final isPinned = (chat.favIndex ?? 0) > 0; - - if (chat.type.isNotEmpty && - chat.type == "DIALOG" && - chat.id != 0) { - int secondId = _profile?.id ?? 0; - for (final entry in chat.participants.entries) { - if (entry.key != _profile?.id) { - secondId = entry.key; - break; - } + delegate: SliverChildBuilderDelegate( + (context, index) { + if (_isInitialLoading) { + return ChatShimmerTile(shimmer: _shimmerController); } - final name = ContactCache.get(secondId) ?? chat.title; - final avatar = - ContactCache.getAvatar(secondId) ?? chat.iconUrl; - // ContactCache.isOfficial covers contacts loaded via opcode 32; - // chat.isOfficial covers contacts from the login payload. - final isVerified = - ContactCache.isOfficial(secondId) || chat.isOfficial; - final isPlaceholder = - chat.lastMsgText == ChatsModule.lastMsgPlaceholder; - final previewText = isPlaceholder - ? 'зайдите в чат для подгрузки' - : (chat.lastMsgTextOneLine ?? ''); - return _animateChatTile( - chat.id.toString(), - _buildChatItem( - chat.id.toString(), - name ?? "Пользователь", - previewText, - _formatTime(chat.lastMsgTime), - avatar ?? "", - presenceUserId: secondId, - unreadCount: chat.unreadCount, - isMuted: chat.isMuted, - isVerified: isVerified, - isPinned: isPinned, - chatType: "DIALOG", - messageItalic: isPlaceholder, - draft: _draftFor(chat.id), - ownStatus: _ownStatusFor(chat, isPlaceholder), - ownRead: chat.lastMsgReadByOthers, - messageRanges: isPlaceholder - ? const [] - : chat.lastMsgFormatRanges, - ), - ); - } else { - final isPlaceholder = - chat.lastMsgText == ChatsModule.lastMsgPlaceholder; - final sender = chat.lastMsgSenderId != null - ? ContactCache.get(chat.lastMsgSenderId!) - : null; + if (hasSeparator && index == pinnedCount) { + return Padding( + key: const ValueKey('pinned_divider'), + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Divider( + height: 1, + thickness: 0.5, + color: cs.outlineVariant.withValues(alpha: 0.5), + ), + ); + } - String fullMsg = ""; - List messageRanges = const []; - if (isPlaceholder) { - fullMsg = 'зайдите в чат для подгрузки'; + final chatIndex = hasSeparator && index > pinnedCount + ? index - 1 + : index; + final chat = chats[chatIndex]; + final isPinned = (chat.favIndex ?? 0) > 0; + + if (chat.type.isNotEmpty && + chat.type == "DIALOG" && + chat.id != 0) { + int secondId = _profile?.id ?? 0; + for (final entry in chat.participants.entries) { + if (entry.key != _profile?.id) { + secondId = entry.key; + break; + } + } + final name = ContactCache.get(secondId) ?? chat.title; + final avatar = + ContactCache.getAvatar(secondId) ?? chat.iconUrl; + final isVerified = + ContactCache.isOfficial(secondId) || chat.isOfficial; + + final isPlaceholder = chat.isLastMsgDeleted; + final previewText = isPlaceholder + ? 'зайдите в чат для подгрузки' + : (chat.lastMsgTextOneLine ?? ''); + return _animateChatTile( + chat.id.toString(), + _buildChatItem( + chat.id.toString(), + name ?? "Пользователь", + previewText, + _formatTime(chat.lastMsgTime), + avatar ?? "", + presenceUserId: secondId, + unreadCount: chat.unreadCount, + isMuted: chat.isMuted, + isVerified: isVerified, + isPinned: isPinned, + chatType: "DIALOG", + messageItalic: isPlaceholder, + draft: _draftFor(chat.id), + ownStatus: _ownStatusFor(chat, isPlaceholder), + ownRead: chat.lastMsgReadByOthers, + messageRanges: isPlaceholder + ? const [] + : chat.lastMsgFormatRanges, + ), + ); } else { - var prefixLen = 0; - if (sender?.isNotEmpty == true && chat.id != 0) { - final prefix = "$sender: "; - fullMsg += prefix; - prefixLen = prefix.length; - } - if (chat.lastMsgText?.isNotEmpty == true) { - fullMsg += chat.lastMsgText ?? ""; - final ranges = chat.lastMsgFormatRanges; - messageRanges = prefixLen == 0 - ? ranges - : [ - for (final r in ranges) - FormatRange( - format: r.format, - start: r.start + prefixLen, - length: r.length, - attributes: r.attributes, - ), - ]; - } - } + final isPlaceholder = chat.isLastMsgDeleted; + final sender = chat.lastMsgSenderId != null + ? ContactCache.get(chat.lastMsgSenderId!) + : null; - return _animateChatTile( - chat.id.toString(), - _buildChatItem( - chat.id.toString(), - chat.id == 0 ? "Избранное" : chat.title ?? "Чат", - fullMsg, - _formatTime(chat.lastMsgTime), - (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) - ? chat.iconUrl! - : '', - unreadCount: chat.unreadCount, - isMuted: chat.isMuted, - isVerified: chat.isOfficial, - isPinned: isPinned, - chatType: chat.type, - messageItalic: isPlaceholder, - draft: chat.id == 0 ? null : _draftFor(chat.id), - ownStatus: _ownStatusFor(chat, isPlaceholder), - ownRead: chat.lastMsgReadByOthers, - messageRanges: messageRanges, - ), - ); - } - }, childCount: totalItems, findChildIndexCallback: (Key key) { - if (key is! ValueKey) return null; - final v = key.value; - if (!v.startsWith('chat_')) return null; - final idx = idToIndex[v.substring(5)]; - if (idx == null) return null; - return hasSeparator && idx >= pinnedCount ? idx + 1 : idx; - }), + String fullMsg = ""; + List messageRanges = const []; + if (isPlaceholder) { + fullMsg = 'зайдите в чат для подгрузки'; + } else { + var prefixLen = 0; + if (sender?.isNotEmpty == true && chat.id != 0) { + final prefix = "$sender: "; + fullMsg += prefix; + prefixLen = prefix.length; + } + if (chat.lastMsgText?.isNotEmpty == true) { + fullMsg += chat.lastMsgText ?? ""; + final ranges = chat.lastMsgFormatRanges; + messageRanges = prefixLen == 0 + ? ranges + : [ + for (final r in ranges) + FormatRange( + format: r.format, + start: r.start + prefixLen, + length: r.length, + attributes: r.attributes, + ), + ]; + } + } + + return _animateChatTile( + chat.id.toString(), + _buildChatItem( + chat.id.toString(), + chat.id == 0 ? "Избранное" : chat.title ?? "Чат", + fullMsg, + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + unreadCount: chat.unreadCount, + isMuted: chat.isMuted, + isVerified: chat.isOfficial, + isPinned: isPinned, + chatType: chat.type, + messageItalic: isPlaceholder, + draft: chat.id == 0 ? null : _draftFor(chat.id), + ownStatus: _ownStatusFor(chat, isPlaceholder), + ownRead: chat.lastMsgReadByOthers, + messageRanges: messageRanges, + ), + ); + } + }, + childCount: totalItems, + findChildIndexCallback: (Key key) { + if (key is! ValueKey) return null; + final v = key.value; + if (!v.startsWith('chat_')) return null; + final idx = idToIndex[v.substring(5)]; + if (idx == null) return null; + return hasSeparator && idx >= pinnedCount ? idx + 1 : idx; + }, + ), ), SliverPadding( padding: EdgeInsets.only( @@ -2104,8 +2044,8 @@ class _ChatListScreenState extends State radius: 26, backgroundImage: CachedNetworkImageProvider( imageUrl, - maxWidth: 144, - maxHeight: 144, + maxWidth: kAvatarThumbSize, + maxHeight: kAvatarThumbSize, ), ), ), @@ -2131,45 +2071,6 @@ class _ChatListScreenState extends State return f.title; } - Widget _buildFolderStripShimmer(ColorScheme cs) { - return AnimatedBuilder( - animation: _shimmerController, - builder: (context, child) { - final opacity = 0.3 + 0.3 * sin(_shimmerController.value * pi * 2); - return Opacity( - opacity: opacity, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - physics: const BouncingScrollPhysics(), - children: [ - _folderShimmerPill(cs, 88), - const SizedBox(width: 8), - _folderShimmerPill(cs, 72), - const SizedBox(width: 8), - _folderShimmerPill(cs, 96), - const SizedBox(width: 8), - _folderShimmerPill(cs, 64), - const SizedBox(width: 8), - _folderShimmerPill(cs, 80), - ], - ), - ); - }, - ); - } - - Widget _folderShimmerPill(ColorScheme cs, double width) { - return Container( - width: width, - height: 32, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(10), - ), - ); - } - Widget _buildFolderChip(String title, {required String folderId}) { final cs = Theme.of(context).colorScheme; final isSelected = _selectedFolderId == folderId; @@ -2240,7 +2141,7 @@ class _ChatListScreenState extends State default: if (read) { icon = Symbols.done_all; - color = const Color(0xFF4FC3F7); + color = kReadReceiptBlue; } else { icon = Symbols.check; color = cs.outline; @@ -2253,7 +2154,7 @@ class _ChatListScreenState extends State } Widget _animateChatTile(String id, Widget child) { - return _AnimatedChatTile( + return AnimatedChatTile( key: ValueKey('chat_$id'), id: id, revision: _chatListRevision, @@ -2273,8 +2174,14 @@ class _ChatListScreenState extends State return Text.rich( TextSpan( children: [ - TextSpan(text: 'Черновик: ', style: TextStyle(color: cs.error)), - TextSpan(text: draft, style: TextStyle(color: cs.outline)), + TextSpan( + text: 'Черновик: ', + style: TextStyle(color: cs.error), + ), + TextSpan( + text: draft, + style: TextStyle(color: cs.outline), + ), ], style: const TextStyle( fontSize: 14, @@ -2303,7 +2210,11 @@ class _ChatListScreenState extends State ); } return Text.rich( - FormattedMessageText.buildInlineSpan(message, messageRanges, previewStyle), + FormattedMessageText.buildInlineSpan( + message, + messageRanges, + previewStyle, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ); @@ -2345,7 +2256,11 @@ class _ChatListScreenState extends State radius: 24, backgroundColor: cs.surfaceContainerHighest, backgroundImage: imageUrl.isNotEmpty - ? CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144) + ? CachedNetworkImageProvider( + imageUrl, + maxWidth: kAvatarThumbSize, + maxHeight: kAvatarThumbSize, + ) : null, child: imageUrl.isEmpty ? Text( @@ -2386,8 +2301,8 @@ class _ChatListScreenState extends State precacheImage( CachedNetworkImageProvider( imageUrl, - maxWidth: 144, - maxHeight: 144, + maxWidth: kAvatarThumbSize, + maxHeight: kAvatarThumbSize, ), context, ), @@ -2532,7 +2447,7 @@ class _ChatListScreenState extends State crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( - child: _ActivitySubtitle( + child: ActivitySubtitle( chatId: int.tryParse(id) ?? 0, child: messageLine, ), @@ -2710,8 +2625,8 @@ class _ChatListScreenState extends State radius: 12, backgroundImage: CachedNetworkImageProvider( imageUrl, - maxWidth: 144, - maxHeight: 144, + maxWidth: kAvatarThumbSize, + maxHeight: kAvatarThumbSize, ), ), ), @@ -2727,162 +2642,3 @@ class _StoriesUi extends ChangeNotifier { void notify() => notifyListeners(); } - -class _AnimatedChatTile extends StatefulWidget { - final Widget child; - final String id; - final int revision; - final bool isNew; - - const _AnimatedChatTile({ - required Key key, - required this.child, - required this.id, - required this.revision, - required this.isNew, - }) : super(key: key); - - @override - State<_AnimatedChatTile> createState() => _AnimatedChatTileState(); -} - -class _AnimatedChatTileState extends State<_AnimatedChatTile> - with SingleTickerProviderStateMixin { - static const Duration _moveDuration = Duration(milliseconds: 300); - static const Duration _enterDuration = Duration(milliseconds: 260); - - AnimationController? _controller; - double? _lastContentY; - late int _lastRevision; - double _moveDy = 0; - bool _entering = false; - - @override - void initState() { - super.initState(); - _lastRevision = widget.revision; - if (widget.isNew) { - _entering = true; - final c = _controller = AnimationController( - vsync: this, - duration: _enterDuration, - ); - c.forward(from: 0).whenComplete(() { - if (mounted) setState(() => _entering = false); - }); - } - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _lastContentY = _measureContentY(); - }); - } - - @override - void didUpdateWidget(covariant _AnimatedChatTile oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.revision == _lastRevision) return; - _lastRevision = widget.revision; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _runMove(); - }); - } - - double? _measureContentY() { - final box = context.findRenderObject(); - if (box is! RenderBox || !box.attached) return null; - try { - return RenderAbstractViewport.of(box).getOffsetToReveal(box, 0.0).offset; - } catch (_) { - return null; - } - } - - void _runMove() { - final newY = _measureContentY(); - final oldY = _lastContentY; - if (newY != null) _lastContentY = newY; - debugPrint('[FLIP] move id=${widget.id} oldY=$oldY newY=$newY'); - if (_entering || oldY == null || newY == null) return; - final dy = oldY - newY; - if (dy.abs() < 1.0 || dy.abs() > 2000) return; - final c = _controller ??= AnimationController(vsync: this); - c.duration = _moveDuration; - setState(() => _moveDy = dy); - c.forward(from: 0); - } - - @override - void dispose() { - _controller?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final c = _controller; - if (c == null) return SizedBox(child: widget.child); - return SizedBox( - child: AnimatedBuilder( - animation: c, - builder: (context, child) { - if (_entering) { - final t = Curves.easeOut.transform(c.value); - return Opacity( - opacity: t, - child: Transform.scale(scale: 0.94 + 0.06 * t, child: child), - ); - } - if (_moveDy != 0) { - final t = 1 - Curves.easeOutCubic.transform(c.value); - return Transform.translate( - offset: Offset(0, _moveDy * t), - child: child, - ); - } - return child!; - }, - child: widget.child, - ), - ); - } -} - -class _ActivitySubtitle extends StatefulWidget { - const _ActivitySubtitle({required this.chatId, required this.child}); - - final int chatId; - final Widget child; - - @override - State<_ActivitySubtitle> createState() => _ActivitySubtitleState(); -} - -class _ActivitySubtitleState extends State<_ActivitySubtitle> { - ChatActivity _lastActivity = ChatActivity.typing; - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return ValueListenableBuilder( - valueListenable: ChatActivityStore.instance.listenable(widget.chatId), - child: widget.child, - builder: (context, activity, base) { - if (activity != null) _lastActivity = activity; - return AnimatedTextSwap( - showAlternate: activity != null, - alternate: Text( - _lastActivity.label.toLowerCase(), - style: TextStyle( - color: cs.primary, - fontSize: 14, - fontWeight: FontWeight.w500, - height: 1.2, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - child: base!, - ); - }, - ); - } -} diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 710d826..14b4814 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -2,11 +2,8 @@ import 'dart:async'; import 'dart:io' show File; import 'dart:math' as math; import 'dart:ui' as ui; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:file_picker/file_picker.dart'; import 'package:geolocator/geolocator.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:record/record.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -15,17 +12,13 @@ import 'package:komet/backend/modules/chats.dart'; import 'package:komet/backend/modules/file_uploader.dart'; import 'package:komet/backend/modules/upload_notification_service.dart'; import 'package:komet/core/media/gallery_source.dart'; -import 'package:komet/core/media/opus_ogg_encoder.dart'; -import 'package:komet/core/media/native_video_note_recorder.dart'; import 'package:komet/core/utils/format.dart'; -import 'package:komet/core/utils/logger.dart'; import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; import 'package:komet/frontend/screens/contacts/contact_profile_screen.dart'; import 'package:komet/frontend/screens/chats/chat_list_screen.dart'; import 'package:komet/frontend/screens/chats/poll_create_screen.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:komet/frontend/widgets/chat_menu_overlay.dart'; -import 'package:komet/frontend/widgets/animated_lottie_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart'; import '../../../backend/api.dart'; @@ -43,11 +36,27 @@ import '../../../core/storage/draft_store.dart'; import '../../../core/cache/info_cache.dart'; import '../../../core/cache/message_session_cache.dart'; import '../../../core/utils/haptics.dart'; -import '../../../core/config/app_animations.dart'; +import '../../../core/utils/logger.dart'; import '../../../core/config/app_cache_extent.dart'; +import '../../../core/config/app_colors.dart'; import '../../../core/config/app_message_actions_style.dart'; import '../../../core/config/app_swipe_back_desktop.dart'; -import '../../../core/config/app_pranks.dart'; +import 'chat/chat_prank_controller.dart'; +import 'chat/chat_controller.dart'; +import 'chat/voice_record_controller.dart'; +import 'chat/video_note_controller.dart'; +import 'chat/command_panel_controller.dart'; +import 'chat/sticker_panel_controller.dart'; +import 'chat/chat_search_controller.dart'; +import 'chat/message_search_result.dart'; +import 'chat/upload_status.dart'; +import 'chat/view/search_view.dart'; +import 'chat/view/composer_input.dart'; +import 'chat/view/sticker_panel_view.dart'; +import 'chat/view/command_panel_view.dart'; +import 'chat/view/selection_bar.dart'; +import 'chat/view/chat_header.dart'; +import 'chat/view/shimmer_loading.dart'; import '../../../core/config/app_commands.dart'; import '../../../core/config/app_visual_style.dart'; import '../../../core/config/app_chat_chrome.dart'; @@ -56,20 +65,14 @@ import '../../../models/attachment.dart'; import '../../../models/sticker.dart'; import '../../commands/command_registry.dart'; import '../../commands/slash_command.dart'; -import '../../widgets/avatar_hero.dart'; -import '../../widgets/komet_avatar.dart'; -import '../../widgets/glossy_pill.dart'; import '../../widgets/rich_message_controller.dart'; import '../../../core/utils/text_format.dart'; -import '../../widgets/command_suggestions_panel.dart'; -import '../../widgets/online_dot.dart'; +import '../../widgets/confirm_dialog.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/message_bubble.dart'; -import '../../widgets/theme_reveal.dart'; import '../../widgets/message_actions_overlay.dart'; import '../../widgets/attachment_panel.dart'; import '../../widgets/attachment/attachment_sheet.dart'; -import '../../widgets/sticker_panel.dart'; import '../../widgets/sticker_pack_sheet.dart'; import '../../widgets/swipe_to_pop.dart'; import '../../widgets/swipe_route.dart'; @@ -79,18 +82,6 @@ import '../../widgets/chat_wallpaper_view.dart'; import 'scheduled_messages_screen.dart'; import 'chat_wallpaper_preview_screen.dart'; -class _UploadStatus { - final bool active; - final int sent; - final int total; - - const _UploadStatus({this.active = false, this.sent = 0, this.total = 0}); - - bool get awaitingResponse => active && total > 0 && sent >= total; - double? get progressValue => - (!active || total == 0 || awaitingResponse) ? null : sent / total; -} - class _DateSeparatorItem { final DateTime date; final GlobalKey key; @@ -103,128 +94,6 @@ class _MessageItem { const _MessageItem(this.message, this.index); } -class _MessageSearchResult { - final String id; - final int time; - final int senderId; - final String text; - final List highlights; - - const _MessageSearchResult({ - required this.id, - required this.time, - required this.senderId, - required this.text, - required this.highlights, - }); - - static _MessageSearchResult? fromRaw(Map raw) { - final message = raw['message']; - if (message is! Map) return null; - final id = message['id']?.toString(); - if (id == null) return null; - final rawHighlights = raw['highlights']; - final highlights = rawHighlights is List - ? rawHighlights.whereType().toList() - : const []; - final time = message['time']; - final sender = message['sender']; - return _MessageSearchResult( - id: id, - time: time is int ? time : int.tryParse('${time ?? 0}') ?? 0, - senderId: sender is int ? sender : int.tryParse('${sender ?? 0}') ?? 0, - text: message['text']?.toString() ?? '', - highlights: highlights, - ); - } -} - -class _RecordingDot extends StatefulWidget { - final Color color; - const _RecordingDot({required this.color}); - - @override - State<_RecordingDot> createState() => _RecordingDotState(); -} - -class _RecordingDotState extends State<_RecordingDot> - with SingleTickerProviderStateMixin { - late final AnimationController _c = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 900), - )..repeat(reverse: true); - - @override - void dispose() { - _c.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return FadeTransition( - opacity: Tween(begin: 1.0, end: 0.25).animate(_c), - child: Container( - width: 12, - height: 12, - decoration: BoxDecoration(color: widget.color, shape: BoxShape.circle), - ), - ); - } -} - -class _LiveWavePainter extends CustomPainter { - final List amps; - final Color color; - - const _LiveWavePainter({required this.amps, required this.color}); - - @override - void paint(Canvas canvas, Size size) { - const slot = 5.0; - const barW = 3.0; - final count = (size.width / slot).floor(); - if (count <= 0 || amps.isEmpty) return; - - final start = amps.length > count ? amps.length - count : 0; - final visible = amps.sublist(start); - final center = size.height / 2; - final paint = Paint()..color = color; - final offset = size.width - visible.length * slot; - - for (var i = 0; i < visible.length; i++) { - final h = (visible[i] * size.height).clamp(2.0, size.height); - final x = offset + i * slot + (slot - barW) / 2; - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH(x, center - h / 2, barW, h), - const Radius.circular(barW / 2), - ), - paint, - ); - } - } - - @override - bool shouldRepaint(_LiveWavePainter old) => true; -} - -class _ButtonClipper extends CustomClipper { - final double t; - const _ButtonClipper(this.t); - - @override - Rect getClip(Size size) { - if (t <= 0.001) { - return Rect.fromLTRB(-120, -260, size.width + 120, size.height + 40); - } - return Rect.fromLTRB(0, 0, size.width, size.height); - } - - @override - bool shouldReclip(_ButtonClipper old) => old.t != t; -} - class _FrostedPanel extends StatelessWidget { final Color tint; final Border? border; @@ -293,10 +162,7 @@ class ForwardRequest { final int sourceChatId; final List optimistic; - const ForwardRequest({ - required this.sourceChatId, - required this.optimistic, - }); + const ForwardRequest({required this.sourceChatId, required this.optimistic}); } class ChatScreen extends StatefulWidget { @@ -334,12 +200,9 @@ class _ChatScreenState extends State final ValueNotifier _hasText = ValueNotifier(false); bool _isLoading = true; final ValueNotifier _showAttachmentPanel = ValueNotifier(false); - final ValueNotifier _showStickerPanel = ValueNotifier(false); - double _stickerPanelHeight = 300; - Timer? _stickerTypingTimer; - late final AnimationController _stickerAnim; - final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier( - const _UploadStatus(), + late final StickerPanelController _stickers; + final ValueNotifier _uploadStatus = ValueNotifier( + const UploadStatus(), ); StreamSubscription? _uploadSub; StreamSubscription? _pushSub; @@ -350,34 +213,19 @@ class _ChatScreenState extends State final Map>> _photoUploadProgress = {}; final ValueNotifier _scheduledCount = ValueNotifier(0); - AudioRecorder? _voiceRecorder; - final ValueNotifier _isRecordingVoice = ValueNotifier(false); - final ValueNotifier _voiceElapsedMs = ValueNotifier(0); - final ValueNotifier _voiceCancelDrag = ValueNotifier(0); - final ValueNotifier _voiceAmplitude = ValueNotifier(0); - final ValueNotifier _voiceWaveRev = ValueNotifier(0); - final ValueNotifier _voiceLocked = ValueNotifier(false); - final ValueNotifier _voiceLockDrag = ValueNotifier(0); - final Stopwatch _voiceStopwatch = Stopwatch(); - final List _voiceAmps = []; - Timer? _voiceTimer; - StreamSubscription? _voiceAmpSub; - String? _voicePath; - bool _voiceCancelled = false; - bool _voiceStopRequested = false; - bool _voiceTranscode = false; + late final VoiceRecordController _voiceRec = VoiceRecordController( + contextOf: () => context, + isMounted: () => mounted, + myId: () => _myId, + onRecorded: _sendVoice, + ); - final ValueNotifier _videoNoteMode = ValueNotifier(false); - final NativeVideoNoteRecorder _noteRec = NativeVideoNoteRecorder(); - final ValueNotifier _noteTextureId = ValueNotifier(null); - final ValueNotifier _noteCamReady = ValueNotifier(false); - final ValueNotifier _isRecordingNote = ValueNotifier(false); - final ValueNotifier _noteElapsedMs = ValueNotifier(0); - final ValueNotifier _noteCancelDrag = ValueNotifier(0); - final Stopwatch _noteStopwatch = Stopwatch(); - Timer? _noteTimer; - bool _noteCancelled = false; - bool _noteStopRequested = false; + late final VideoNoteController _note = VideoNoteController( + contextOf: () => context, + isMounted: () => mounted, + onRecorded: _sendVideoNote, + formatElapsed: formatVoiceElapsed, + ); ValueListenable>? _photoProgressFor(CachedMessage m) => _photoUploadProgress[m.id]; @@ -401,6 +249,7 @@ class _ChatScreenState extends State for (final id in dead) { _reactionNotifiers.remove(id)?.dispose(); } + _messageKeys.removeWhere((id, _) => !liveIds.contains(id)); } int _otherStatus = 0; @@ -409,57 +258,58 @@ class _ChatScreenState extends State final ValueNotifier _replyTo = ValueNotifier(null); final ValueNotifier _highlightMessageId = ValueNotifier(null); + Timer? _highlightTimer; - final ValueNotifier _searchMode = ValueNotifier(false); + late final ChatSearchController _search; late final AnimationController _searchAnim; - final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocusNode = FocusNode(); - final ValueNotifier> _searchResults = - ValueNotifier(const []); - final ValueNotifier _searchLoading = ValueNotifier(false); - final ValueNotifier _searchPerformed = ValueNotifier(false); - Timer? _searchDebounce; - int _searchSeq = 0; - bool _prankActive = false; - String? _prankBubbleId; - final GlobalKey _prankBubbleKey = GlobalKey(); - final GlobalKey _prankCaptureKey = GlobalKey(); - OverlayEntry? _prankRevealEntry; - AnimationController? _prankRevealController; - ui.Image? _prankRevealImage; + late final ChatPrankController _prank = ChatPrankController( + vsync: this, + contextOf: () => context, + isMounted: () => mounted, + onChanged: () { + if (mounted) setState(() {}); + }, + ); final ValueNotifier _headerStatusNotifier = ValueNotifier(''); final ValueNotifier _otherReadTime = ValueNotifier(0); int _tempIdCounter = 0; late final AnimationController _attachAnim; - late final AnimationController _commandAnim; - bool _commandPanelVisible = false; - final ValueNotifier> _commandMatches = - ValueNotifier(const []); + late final CommandPanelController _commandPanel; String _nextTempId() => 'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}'; late AnimationController _shimmerController; Timer? _shimmerStartTimer; - bool _historyKickedOff = false; bool _previewChat = false; bool _forwardRequestDone = false; - List _messages = []; - final ValueNotifier _messagesRev = ValueNotifier(0); + + final ChatController _chatController = ChatController(); + + List get _messages => _chatController.messages; + set _messages(List v) => _chatController.messages = v; + ValueNotifier get _messagesRev => _chatController.messagesRev; + bool get _historyKickedOff => _chatController.historyKickedOff; + set _historyKickedOff(bool v) => _chatController.historyKickedOff = v; + + final GlobalKey _messageListKey = GlobalKey(); + _ChatMessageList? _messageListWidget; final Set _deletingIds = {}; - static const int _historyPageSize = 30; - static const int _historyInitialLimit = 50; static const double _avgMessageHeight = 72.0; static const double _historyPrefetchExtent = _avgMessageHeight * 8; static const double _glossyHeaderHeight = 76.0; static const double _glossySearchHeight = 58.0; - bool _isLoadingMore = false; - bool _hasMoreHistory = true; + bool get _isLoadingMore => _chatController.isLoadingMore; + set _isLoadingMore(bool v) => _chatController.isLoadingMore = v; + bool get _hasMoreHistory => _chatController.hasMoreHistory; + set _hasMoreHistory(bool v) => _chatController.hasMoreHistory = v; List? _combinedItemsCache; int? _combinedItemsKey; bool _floatingDateScheduled = false; - int _myId = 0; + int get _myId => _chatController.myId; + set _myId(int v) => _chatController.myId = v; CachedChat? chat; ChatWallpaper? _wallpaper; final ValueNotifier _composerHeight = ValueNotifier(96); @@ -481,12 +331,13 @@ class _ChatScreenState extends State @override void initState() { super.initState(); + _chatController.chatId = widget.chatId; + _chatController.isMounted = () => mounted; unawaited(PushService.clearChatNotification(widget.chatId)); WidgetsBinding.instance.addObserver(this); - ChatsModule.chatsChanged.addListener(_onChatsBump); + chats.chatsChanged.addListener(_onChatsBump); _messageController.addListener(_onTextChanged); _messageFocusNode.addListener(_onComposerFocusChanged); - _showStickerPanel.addListener(_onStickerPanelToggle); _scrollController.addListener(_onScrollForDate); _scrollController.addListener(_maybeLoadMoreHistory); AppVisualStyle.current.addListener(_onVisualStyleChanged); @@ -500,15 +351,15 @@ class _ChatScreenState extends State duration: const Duration(milliseconds: 320), reverseDuration: const Duration(milliseconds: 240), ); - _stickerAnim = AnimationController( + _stickers = StickerPanelController( vsync: this, - duration: const Duration(milliseconds: 240), - reverseDuration: const Duration(milliseconds: 200), + onSendTyping: () => messagesModule.sendTyping(widget.chatId, 'STICKER'), ); _showAttachmentPanel.addListener(_onAttachPanelToggle); - _commandAnim = AnimationController( + _commandPanel = CommandPanelController( vsync: this, - duration: const Duration(milliseconds: 200), + textOf: () => _messageController.text, + onSelected: _onCommandSelected, ); _selectionAnim = AnimationController( vsync: this, @@ -520,8 +371,10 @@ class _ChatScreenState extends State duration: const Duration(milliseconds: 280), reverseDuration: const Duration(milliseconds: 220), ); - _searchController.addListener(_onSearchTextChanged); - AppCommands.current.addListener(_updateCommandPanel); + _search = ChatSearchController( + chatId: widget.chatId, + isMounted: () => mounted, + ); _pushSub = api.pushStream .where( (p) => @@ -530,7 +383,7 @@ class _ChatScreenState extends State p.opcode == Opcode.notifMsgDelayed, ) .listen(_onIncomingPush); - _messageEventSub = ChatsModule.messageEvents + _messageEventSub = chats.messageEvents .where((e) => e.chatId == widget.chatId) .listen(_onMessageEvent); ChatActivityStore.instance @@ -563,7 +416,7 @@ class _ChatScreenState extends State Future _loadParticipantsCount() async { if (widget.chatType != 'CHAT' && widget.chatType != 'CHANNEL') return; - final info = await ChatsModule.getChatInfo(api, widget.chatId); + final info = await chats.getChatInfo(api, widget.chatId); if (!mounted) return; final count = info?['participantsCount'] as int?; if (count != null && count != _participantsCount) { @@ -580,12 +433,14 @@ class _ChatScreenState extends State unawaited(_loadWallpaper()); unawaited(_refreshBadge()); - ChatsModule.getChat(_myId, widget.chatId) + chats + .getChat(_myId, widget.chatId) .then((value) { if (mounted && value.isNotEmpty) { setState(() { chat = value.first; }); + _bumpMessages(); _seedPresenceFromChat(); _recomputeHeaderStatus(); _syncOtherReadTime(); @@ -677,12 +532,12 @@ class _ChatScreenState extends State if (newest.id == _lastMarkedId) return; _lastMarkedId = newest.id; unawaited( - ChatsModule.markRead(api, _myId, widget.chatId, newest.id, newest.time), + chats.markRead(api, _myId, widget.chatId, newest.id, newest.time), ); } Future _markMessageUnread(CachedMessage message) async { - final unread = await ChatsModule.markUnread( + final unread = await chats.markUnread( api, _myId, widget.chatId, @@ -722,8 +577,10 @@ class _ChatScreenState extends State Future _refreshBadge() async { if (_myId == 0) return; - final total = - await AppDatabase.sumUnread(_myId, excludeChatId: widget.chatId); + final total = await AppDatabase.sumUnread( + _myId, + excludeChatId: widget.chatId, + ); if (mounted) _otherUnread.value = total; } @@ -737,78 +594,20 @@ class _ChatScreenState extends State unawaited(_loadOtherPresence()); } unawaited(_refreshScheduledCount()); - await _loadRemainingHistory(); - } - - Future _loadRemainingHistory() async { - final onlyVisible = !KometSettings.viewDeleted.value; - final fullRows = await AppDatabase.loadMessages( - _myId, - widget.chatId, - limit: _historyInitialLimit, - onlyVisible: onlyVisible, + await _chatController.loadRemainingHistory( + onApplyMerged: _applyMergedMessages, + onLoadingFinished: () { + setState(() { + _isLoading = false; + _onLoadingFinished(); + }); + }, + onPreview: () => _previewChat = true, + onSenderNames: () { + _loadForwardedSenderNames(); + _loadGroupSenderNames(); + }, ); - final fullDecoded = await CachedMessage.fromDbRowsAsync(fullRows); - if (mounted) { - _applyMergedMessages(fullDecoded); - } - - if (fullRows.isNotEmpty && - ChatsModule.wasHistoryFetched(widget.chatId)) { - if (mounted) { - setState(() { - _isLoading = false; - _onLoadingFinished(); - }); - } - _loadForwardedSenderNames(); - _loadGroupSenderNames(); - return; - } - - try { - final cachedRows = await AppDatabase.loadChat(_myId, widget.chatId); - if (cachedRows.isEmpty) { - _previewChat = true; - await ChatsModule.ensureChatCached(api, _myId, widget.chatId); - await ChatsModule.subscribeChat(api, widget.chatId); - } - final serverMessages = await messagesModule.fetchHistory( - _myId, - widget.chatId, - ); - ChatsModule.markHistoryFetched(widget.chatId); - if (KometSettings.viewDeleted.value) { - await ChatsModule.reconcileDeletedFromFetch( - _myId, - widget.chatId, - serverMessages, - ); - } - final updatedRows = await AppDatabase.loadMessages( - _myId, - widget.chatId, - limit: _historyInitialLimit, - onlyVisible: onlyVisible, - ); - final updatedDecoded = await CachedMessage.fromDbRowsAsync(updatedRows); - if (mounted) { - _applyMergedMessages(updatedDecoded, markLoaded: true); - } - unawaited( - ChatsModule.reconcileLastMessageIfPlaceholder(_myId, widget.chatId), - ); - _loadForwardedSenderNames(); - _loadGroupSenderNames(); - } catch (e) { - logger.e('Error fetching history: $e'); - if (mounted) { - setState(() { - _isLoading = false; - _onLoadingFinished(); - }); - } - } } void _maybeLoadMoreHistory() { @@ -823,85 +622,20 @@ class _ChatScreenState extends State } Future _loadMoreHistory() async { - if (_isLoadingMore || !_hasMoreHistory || _messages.isEmpty) return; - _isLoadingMore = true; - setState(() {}); - - final oldest = _messages.first; - final onlyVisible = !KometSettings.viewDeleted.value; - - try { - var older = await _loadOlderFromDb(oldest.time, onlyVisible); - - if (older.length < _historyPageSize) { - final fetched = await messagesModule.fetchHistory( - _myId, - widget.chatId, - fromTime: oldest.time, - count: _historyPageSize, - ); - if (fetched.isNotEmpty) { - if (KometSettings.viewDeleted.value) { - await ChatsModule.reconcileDeletedFromFetch( - _myId, - widget.chatId, - fetched, - ); - } - older = await _loadOlderFromDb(oldest.time, onlyVisible); + await _chatController.loadMoreHistory( + onLoadingStarted: _bumpMessages, + onLoaded: (added) { + if (added > 0) _syncReactionNotifiersFromMessages(); + _bumpMessages(); + _loadForwardedSenderNames(); + _loadGroupSenderNames(); + }, + onError: (_) { + if (mounted) { + _isLoadingMore = false; + _bumpMessages(); } - } - - if (!mounted) return; - final added = _prependOlder(older); - setState(() { - _isLoadingMore = false; - if (added == 0) _hasMoreHistory = false; - }); - _persistSessionCache(); - _loadForwardedSenderNames(); - _loadGroupSenderNames(); - } catch (e) { - logger.e('Error loading more history: $e'); - if (mounted) setState(() => _isLoadingMore = false); - } - } - - Future> _loadOlderFromDb( - int beforeTime, - bool onlyVisible, - ) async { - final rows = await AppDatabase.loadMessagesBefore( - _myId, - widget.chatId, - beforeTime: beforeTime, - limit: _historyPageSize, - onlyVisible: onlyVisible, - ); - return CachedMessage.fromDbRowsAsync(rows); - } - - int _prependOlder(List olderDesc) { - if (olderDesc.isEmpty) return 0; - final existing = _messages.map((m) => m.id).toSet(); - final toAdd = []; - for (final m in olderDesc.reversed) { - if (existing.add(m.id)) toAdd.add(m); - } - if (toAdd.isEmpty) return 0; - _messages = [...toAdd, ..._messages]; - _messagesRev.value++; - _syncReactionNotifiersFromMessages(); - return toAdd.length; - } - - void _persistSessionCache() { - if (_myId == 0 || _messages.isEmpty) return; - MessageSessionCache.save( - _myId, - widget.chatId, - _messages, - reachedStart: !_hasMoreHistory, + }, ); } @@ -909,32 +643,11 @@ class _ChatScreenState extends State List decodedDesc, { bool markLoaded = false, }) { - final byId = {for (final m in _messages) m.id: m}; - var changed = false; - for (final fresh in decodedDesc) { - final old = byId[fresh.id]; - if (old == null) { - byId[fresh.id] = fresh; - changed = true; - } else if (!_sameMessage(old, fresh)) { - byId[fresh.id] = fresh; - changed = true; - } - } + final changed = _chatController.mergeMessages(decodedDesc); if (!changed && !markLoaded) return; - final merged = byId.values.toList() - ..sort((a, b) { - final byTime = a.time.compareTo(b.time); - return byTime != 0 ? byTime : a.id.compareTo(b.id); - }); - setState(() { - if (changed) { - _messages = merged; - _messagesRev.value++; - } if (markLoaded) { _isLoading = false; _onLoadingFinished(); @@ -943,7 +656,7 @@ class _ChatScreenState extends State if (changed) { _syncReactionNotifiersFromMessages(); _pruneReactionNotifiers(); - _persistSessionCache(); + _chatController.persistSessionCache(); } } @@ -970,15 +683,6 @@ class _ChatScreenState extends State return true; } - bool _sameMessage(CachedMessage a, CachedMessage b) { - return a.id == b.id && - a.time == b.time && - a.status == b.status && - a.text == b.text && - a.senderId == b.senderId && - a.deleted == b.deleted; - } - @override void deactivate() { _saveDraft(); @@ -989,6 +693,12 @@ class _ChatScreenState extends State void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) { + if (_voiceRec.isRecording.value) { + unawaited(_voiceRec.stop(cancel: true)); + } + if (_note.isRecording.value) { + unawaited(_note.stop(cancel: true)); + } _saveDraft(); } super.didChangeAppLifecycleState(state); @@ -1007,12 +717,12 @@ class _ChatScreenState extends State @override void dispose() { - _persistSessionCache(); + _chatController.persistSessionCache(); if (_previewChat) { - unawaited(ChatsModule.subscribeChat(api, widget.chatId, subscribe: false)); + unawaited(chats.subscribeChat(api, widget.chatId, subscribe: false)); } WidgetsBinding.instance.removeObserver(this); - ChatsModule.chatsChanged.removeListener(_onChatsBump); + chats.chatsChanged.removeListener(_onChatsBump); _otherUnread.dispose(); _saveDraft(); _messageController.removeListener(_onTextChanged); @@ -1033,25 +743,8 @@ class _ChatScreenState extends State _pushSub?.cancel(); _messageEventSub?.cancel(); _connSub?.cancel(); - _voiceTimer?.cancel(); - _voiceAmpSub?.cancel(); - _voiceRecorder?.dispose(); - _noteTimer?.cancel(); - _noteOverlay?.remove(); - _noteRec.dispose(); - _noteTextureId.dispose(); - _videoNoteMode.dispose(); - _noteCamReady.dispose(); - _isRecordingNote.dispose(); - _noteElapsedMs.dispose(); - _noteCancelDrag.dispose(); - _isRecordingVoice.dispose(); - _voiceElapsedMs.dispose(); - _voiceCancelDrag.dispose(); - _voiceAmplitude.dispose(); - _voiceWaveRev.dispose(); - _voiceLocked.dispose(); - _voiceLockDrag.dispose(); + _voiceRec.dispose(); + _note.dispose(); debugForceOffline.removeListener(_recomputeHeaderStatus); for (final n in _reactionNotifiers.values) { n.dispose(); @@ -1067,36 +760,27 @@ class _ChatScreenState extends State PresenceFetch.revision.removeListener(_onPresenceChanged); _headerStatusNotifier.dispose(); _otherReadTime.dispose(); - _messagesRev.dispose(); - _finishPrankReveal(); + _chatController.dispose(); + _prank.dispose(); _uploadStatus.dispose(); _attachAnim.dispose(); - AppCommands.current.removeListener(_updateCommandPanel); - _commandAnim.dispose(); + _commandPanel.dispose(); _selectionAnim.dispose(); - _searchDebounce?.cancel(); _searchAnim.dispose(); - _searchController.removeListener(_onSearchTextChanged); - _searchController.dispose(); _searchFocusNode.dispose(); - _searchMode.dispose(); - _searchResults.dispose(); - _searchLoading.dispose(); - _searchPerformed.dispose(); + _search.dispose(); _selectedIds.dispose(); - _commandMatches.dispose(); _messageController.dispose(); _messageFocusNode.removeListener(_onComposerFocusChanged); _messageFocusNode.dispose(); - _stickerTypingTimer?.cancel(); - _stickerAnim.dispose(); - _showStickerPanel.removeListener(_onStickerPanelToggle); - _showStickerPanel.dispose(); + _stickers.dispose(); _scrollController.dispose(); _shimmerStartTimer?.cancel(); _shimmerController.dispose(); _replyTo.dispose(); + _highlightTimer?.cancel(); _highlightMessageId.dispose(); + _messageKeys.clear(); super.dispose(); } @@ -1105,36 +789,7 @@ class _ChatScreenState extends State if (newHasText != _hasText.value) { _hasText.value = newHasText; } - _updateCommandPanel(); - } - - List _matchingCommands(String raw) { - if (!AppCommands.current.value) return const []; - final text = raw.trimLeft(); - if (!text.startsWith('/')) return const []; - if (text.contains(RegExp(r'\s'))) return const []; - final query = text.toLowerCase(); - for (final c in kSlashCommands) { - if (!c.hidden && c.name.toLowerCase() == query) return const []; - } - return kSlashCommands - .where((c) => !c.hidden && c.name.toLowerCase().startsWith(query)) - .toList(growable: false); - } - - void _updateCommandPanel() { - final matches = _matchingCommands(_messageController.text); - final show = matches.isNotEmpty; - if (show && !listEquals(_commandMatches.value, matches)) { - _commandMatches.value = matches; - } - if (show == _commandPanelVisible) return; - _commandPanelVisible = show; - if (show) { - _commandAnim.forward(); - } else { - _commandAnim.reverse(); - } + _commandPanel.update(); } void _onCommandSelected(SlashCommand c) { @@ -1151,8 +806,9 @@ class _ChatScreenState extends State final draft = DraftStore.instance.get(_myId, widget.chatId); if (draft == null || draft.isEmpty) return; _messageController.text = draft; - _messageController.selection = - TextSelection.collapsed(offset: draft.length); + _messageController.selection = TextSelection.collapsed( + offset: draft.length, + ); } void _saveDraft() { @@ -1170,33 +826,6 @@ class _ChatScreenState extends State } } - Widget _buildCommandPanel() { - return AnimatedBuilder( - animation: _commandAnim, - child: ValueListenableBuilder>( - valueListenable: _commandMatches, - builder: (context, matches, _) => CommandSuggestionsPanel( - commands: matches, - onSelected: _onCommandSelected, - ), - ), - builder: (context, child) { - final t = _commandAnim.value; - if (t == 0) return const SizedBox.shrink(); - return Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), - child: IgnorePointer( - ignoring: t < 1, - child: Opacity( - opacity: t, - child: child, - ), - ), - ); - }, - ); - } - int _computeOtherReadTime() { final c = chat; if (c == null) return 0; @@ -1214,108 +843,9 @@ class _ChatScreenState extends State if (_otherReadTime.value != t) _otherReadTime.value = t; } - void _checkPrankTrigger(CachedMessage msg) { - if (!AppPranks.current.value || _prankActive || _prankBubbleId != null) { - return; - } - if ((msg.text ?? '').trim().toUpperCase() != 'THE WORLD') return; - setState(() => _prankBubbleId = msg.id); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _runPrankReveal(); - }); - } - - ThemeData _prankPinkTheme(ThemeData base) { - final cs = base.colorScheme; - return base.copyWith( - scaffoldBackgroundColor: const Color(0xFFFFF0F5), - colorScheme: cs.copyWith( - surface: const Color(0xFFFFF0F5), - surfaceContainerHigh: const Color(0xFFFFE3EC), - surfaceContainerHighest: const Color(0xFFFFD9E6), - primary: const Color(0xFFE8579A), - primaryContainer: const Color(0xFFFFD6E5), - onPrimaryContainer: const Color(0xFF7A1F4B), - ), - ); - } - - void _runPrankReveal() { - if (_prankActive) return; - final overlay = Navigator.of(context).overlay; - final captureCtx = _prankCaptureKey.currentContext; - final renderObject = captureCtx?.findRenderObject(); - if (overlay == null || renderObject is! RenderRepaintBoundary) { - setState(() => _prankActive = true); - return; - } - - Offset center; - final bubbleBox = - _prankBubbleKey.currentContext?.findRenderObject() as RenderBox?; - if (bubbleBox != null && bubbleBox.attached) { - center = bubbleBox.localToGlobal(bubbleBox.size.center(Offset.zero)); - } else { - final size = MediaQuery.sizeOf(context); - center = Offset(size.width / 2, size.height / 2); - } - - final ui.Image snapshot; - try { - final dpr = math.min(MediaQuery.of(context).devicePixelRatio, 2.0); - snapshot = renderObject.toImageSync(pixelRatio: dpr); - } catch (_) { - setState(() => _prankActive = true); - return; - } - - _finishPrankReveal(); - - final controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 650), - ); - final entry = ThemeRevealOverlay.build( - snapshot: snapshot, - center: center, - animation: controller, - ); - - _prankRevealController = controller; - _prankRevealEntry = entry; - _prankRevealImage = snapshot; - - overlay.insert(entry); - setState(() => _prankActive = true); - Haptics.success(); - - WidgetsBinding.instance.endOfFrame.then((_) { - if (_prankRevealController != controller) return; - controller.forward().then((_) { - if (_prankRevealController != controller) return; - _finishPrankReveal(); - }, onError: (_) {}); - }); - } - - void _finishPrankReveal() { - _prankRevealEntry?.remove(); - _prankRevealEntry = null; - _prankRevealController?.dispose(); - _prankRevealController = null; - final img = _prankRevealImage; - _prankRevealImage = null; - if (img != null) { - WidgetsBinding.instance.addPostFrameCallback((_) => img.dispose()); - } - } - String? _effectiveStatus(CachedMessage msg) { if (msg.senderId != _myId) return null; if (msg.status == 'sending' || msg.status == 'error') return msg.status; - if (chat == null) return 'sent'; - final otherReadTime = _otherReadTime.value; - if (otherReadTime > 0 && otherReadTime >= msg.time) return 'read'; return 'sent'; } @@ -1435,8 +965,7 @@ class _ChatScreenState extends State final msgs = _selectedMessages(_selectedIds.value); if (msgs.isEmpty) return; - final serverMsgs = - msgs.where((m) => !m.id.startsWith('temp_')).toList(); + final serverMsgs = msgs.where((m) => !m.id.startsWith('temp_')).toList(); if (serverMsgs.isEmpty) { for (final m in msgs) { _startDeleteAnimation(m.id); @@ -1548,7 +1077,7 @@ class _ChatScreenState extends State if (optimistic.isNotEmpty) { final last = optimistic.last; unawaited( - ChatsModule.applyOutgoing( + chats.applyOutgoing( _myId, widget.chatId, messageId: last.id, @@ -1567,7 +1096,7 @@ class _ChatScreenState extends State ForwardTarget target, List sources, ) async { - await ChatsModule.ensureChatCached(api, _myId, target.chatId); + await chats.ensureChatCached(api, _myId, target.chatId); final now = DateTime.now().millisecondsSinceEpoch; final optimistic = []; var i = 0; @@ -1587,17 +1116,15 @@ class _ChatScreenState extends State } final cached = MessageSessionCache.get(_myId, target.chatId); if (cached != null) { - MessageSessionCache.save( - _myId, - target.chatId, - [...cached.messages, ...optimistic], - reachedStart: cached.reachedStart, - ); + MessageSessionCache.save(_myId, target.chatId, [ + ...cached.messages, + ...optimistic, + ], reachedStart: cached.reachedStart); } if (optimistic.isNotEmpty) { final last = optimistic.last; unawaited( - ChatsModule.applyOutgoing( + chats.applyOutgoing( _myId, target.chatId, messageId: last.id, @@ -1619,7 +1146,10 @@ class _ChatScreenState extends State } } - Future _sendOneForward(CachedMessage optimistic, int sourceChatId) async { + Future _sendOneForward( + CachedMessage optimistic, + int sourceChatId, + ) async { final link = optimistic.payload?['link']; final rawWireId = link is Map ? link['messageId'] : null; final wireId = rawWireId is int ? rawWireId : null; @@ -1643,7 +1173,7 @@ class _ChatScreenState extends State } unawaited(_persistOutgoing(sent, removeId: optimistic.id)); unawaited( - ChatsModule.applyOutgoing( + chats.applyOutgoing( _myId, widget.chatId, messageId: sent.id, @@ -1722,8 +1252,31 @@ class _ChatScreenState extends State ); }, ), - _buildInputArea(context), - _buildStickerPanel(context), + ComposerInputBar( + chatType: widget.chatType, + attachAnim: _attachAnim, + replyTo: _replyTo, + myId: _myId, + hasText: _hasText, + uploadStatus: _uploadStatus, + messageController: _messageController, + messageFocusNode: _messageFocusNode, + voiceRec: _voiceRec, + note: _note, + onToggleStickerPanel: _toggleStickerPanel, + onSendText: _sendMessage, + onScheduleMessage: _scheduleMessage, + onOpenAttach: _openAttachmentSheet, + onOpenAttachScheduled: _openAttachmentSheetScheduled, + onSendHistory: _sendHistoryFile, + onCancelReply: _cancelReply, + formatElapsed: formatVoiceElapsed, + contextMenuBuilder: (ctx, state) => + _formatContextMenu(_messageController, ctx, state), + isMuted: chat?.isMuted ?? false, + onToggleMute: _toggleChatMute, + ), + StickerPanelView(stickers: _stickers, onStickerTap: _sendSticker), ], ), ), @@ -1744,8 +1297,12 @@ class _ChatScreenState extends State }, child: ValueListenableBuilder>( valueListenable: _selectedIds, - builder: (context, selected, _) => - _buildSelectionBottomBar(cs, selected), + builder: (context, selected, _) => SelectionBottomBar( + cs: cs, + selected: selected, + onReply: _replySelected, + onForward: _forwardSelected, + ), ), ), ], @@ -1785,83 +1342,6 @@ class _ChatScreenState extends State ); } - Widget _buildSelectionBottomBar(ColorScheme cs, Set selected) { - final single = selected.length == 1; - return SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - children: [ - if (single) ...[ - Expanded( - child: _selectionActionPill( - cs, - icon: Symbols.reply, - label: 'Ответить', - iconLeading: false, - onTap: _replySelected, - ), - ), - const SizedBox(width: 12), - ] else - const Spacer(), - Expanded( - child: _selectionActionPill( - cs, - icon: Symbols.forward, - label: 'Переслать', - iconLeading: true, - onTap: _forwardSelected, - ), - ), - ], - ), - ), - ); - } - - Widget _selectionActionPill( - ColorScheme cs, { - required IconData icon, - required String label, - required bool iconLeading, - required VoidCallback onTap, - }) { - final textWidget = Text( - label, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ); - final iconWidget = Icon(icon, color: cs.onSurface, size: 22, weight: 500); - return GlossyPill( - onTap: onTap, - color: Color.alphaBlend( - cs.surfaceContainerHighest.withValues(alpha: 0.92), - cs.surface, - ), - borderRadius: BorderRadius.circular(28), - depth: 8, - borderSide: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.5), - width: 0.5, - ), - child: SizedBox( - height: 54, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: iconLeading - ? [iconWidget, const SizedBox(width: 8), textWidget] - : [textWidget, const SizedBox(width: 8), iconWidget], - ), - ), - ); - } - bool _canEditMessage(CachedMessage message) { if (message.senderId != _myId) return false; if (message.id.startsWith('temp_')) return false; @@ -2005,11 +1485,9 @@ class _ChatScreenState extends State final forEveryone = await _showDeleteMessageDialog(canForEveryone); if (forEveryone == null || !mounted) return; - final ok = await messagesModule.deleteMessages( - widget.chatId, - [message.id], - forEveryone: forEveryone, - ); + final ok = await messagesModule.deleteMessages(widget.chatId, [ + message.id, + ], forEveryone: forEveryone); if (!mounted) return; if (!ok) { Haptics.error(); @@ -2036,7 +1514,7 @@ class _ChatScreenState extends State _bumpMessages(); try { await AppDatabase.deleteMessage(_myId, widget.chatId, messageId); - await ChatsModule.reconcileLastMessage(_myId, widget.chatId); + await chats.reconcileLastMessage(_myId, widget.chatId); } catch (_) {} } @@ -2097,10 +1575,8 @@ class _ChatScreenState extends State child: const Text('Отмена'), ), TextButton( - onPressed: () => Navigator.pop( - ctx, - canForEveryone && alsoForEveryone, - ), + onPressed: () => + Navigator.pop(ctx, canForEveryone && alsoForEveryone), child: Text('Удалить', style: TextStyle(color: cs.error)), ), ], @@ -2123,7 +1599,7 @@ class _ChatScreenState extends State _clearTyping(message.senderId); Haptics.tap(); _scrollToBottom(); - _checkPrankTrigger(message); + _prank.checkTrigger(message); _markRead(); case MessageEditedEvent(:final message): final idx = _messages.indexWhere((m) => m.id == message.id); @@ -2166,9 +1642,9 @@ class _ChatScreenState extends State } void _onPresenceChanged() { - if (!mounted || widget.chatType != 'DIALOG' || _myId == 0) return; - final otherId = widget.chatId ^ _myId; - if (otherId <= 0) return; + if (!mounted) return; + final otherId = _resolveOtherId(); + if (otherId == null) return; final p = PresenceFetch.live(otherId); if (p == null) return; _otherStatus = (p['status'] as int?) ?? 0; @@ -2176,76 +1652,11 @@ class _ChatScreenState extends State _recomputeHeaderStatus(); } - Widget _withOnlineDot(ColorScheme cs, Widget avatar, {double dotSize = 12}) { - final otherId = widget.chatId ^ _myId; - final showDot = widget.chatType == 'DIALOG' && _myId != 0 && otherId > 0; - return Stack( - children: [ - avatar, - if (showDot) - Positioned( - right: 0, - bottom: 0, - child: OnlineDot( - userId: otherId, - borderColor: cs.surface, - size: dotSize, - ), - ), - ], - ); - } - void _onVisualStyleChanged() { - if (mounted) setState(() {}); - } - - Widget _backWithBadge(ColorScheme cs, Widget button) { - return Stack( - clipBehavior: Clip.none, - alignment: Alignment.center, - children: [ - button, - Positioned( - right: -2, - bottom: 0, - child: IgnorePointer(child: _backUnreadBadge(cs)), - ), - ], - ); - } - - Widget _backUnreadBadge(ColorScheme cs) { - return ValueListenableBuilder( - valueListenable: _otherUnread, - builder: (context, count, _) { - return AnimatedScale( - scale: count > 0 ? 1.0 : 0.0, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutBack, - child: Container( - constraints: const BoxConstraints(minWidth: 18), - height: 18, - padding: const EdgeInsets.symmetric(horizontal: 5), - decoration: BoxDecoration( - color: cs.primary, - borderRadius: BorderRadius.circular(9), - border: Border.all(color: cs.surface, width: 1.5), - ), - alignment: Alignment.center, - child: _RollingCount( - count: count > 99 ? 99 : count, - style: TextStyle( - color: cs.onPrimary, - fontSize: 10.5, - fontWeight: FontWeight.w700, - height: 1.0, - ), - ), - ), - ); - }, - ); + if (mounted) { + setState(() {}); + _bumpMessages(); + } } PreferredSizeWidget _buildAppBar(ColorScheme cs) { @@ -2271,24 +1682,24 @@ class _ChatScreenState extends State child: const SizedBox.expand(), ) : (chrome == ChatChromeStyle.none && !glossy) - ? IgnorePointer( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - cs.surface, - cs.surface, - cs.surface.withValues(alpha: 0.0), - ], - stops: const [0.0, 0.72, 1.0], - ), - ), - child: const SizedBox.expand(), + ? IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + cs.surface, + cs.surface, + cs.surface.withValues(alpha: 0.0), + ], + stops: const [0.0, 0.72, 1.0], ), - ) - : null, + ), + child: const SizedBox.expand(), + ), + ) + : null, foregroundColor: cs.onSurface, surfaceTintColor: Colors.transparent, iconTheme: IconThemeData(color: cs.onSurface), @@ -2320,9 +1731,35 @@ class _ChatScreenState extends State opacity: (1 - t) * (1 - s), child: Transform.translate( offset: Offset(0, -height * 0.4 * t), - child: glossy - ? _glossyHeaderRow(cs) - : _materialHeaderRow(cs), + child: ChatHeaderRow( + glossy: glossy, + cs: cs, + embedded: widget.embedded, + chatId: widget.chatId, + name: widget.name, + imageUrl: widget.imageUrl, + chatType: widget.chatType, + isOfficial: chat?.isOfficial ?? false, + myId: _myId, + headerStatus: _headerStatusNotifier, + scheduledCount: _scheduledCount, + otherUnread: _otherUnread, + onClose: widget.onClose, + onOpenInfo: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatInfoScreen( + chatId: widget.chatId, + name: widget.name, + imageUrl: widget.imageUrl, + chatType: widget.chatType, + ), + ), + ), + onOpenScheduled: _openScheduledMessages, + onCall: _startCall, + onMenu: _openChatMenu, + ), ), ), ), @@ -2333,7 +1770,17 @@ class _ChatScreenState extends State opacity: t, child: Transform.translate( offset: Offset(0, height * 0.4 * (1 - t)), - child: _selectionTopBar(cs, selected, glossy), + child: SelectionTopBar( + cs: cs, + selected: selected, + glossy: glossy, + copyMsg: _singleCopyableText(selected), + editMsg: _singleEditable(selected), + onClear: _clearSelection, + onCopy: _copySelected, + onEdit: _editSelected, + onDelete: _deleteSelected, + ), ), ), ), @@ -2342,7 +1789,13 @@ class _ChatScreenState extends State ignoring: s < 0.5, child: Opacity( opacity: s, - child: _searchTopBar(cs, glossy), + child: SearchTopBar( + cs: cs, + glossy: glossy, + search: _search, + focusNode: _searchFocusNode, + onClose: _closeSearch, + ), ), ), ], @@ -2354,516 +1807,6 @@ class _ChatScreenState extends State ); } - Widget _searchTopBar(ColorScheme cs, bool glossy) { - final field = TextField( - controller: _searchController, - focusNode: _searchFocusNode, - textInputAction: TextInputAction.search, - onSubmitted: (value) { - _searchDebounce?.cancel(); - _runSearch(value); - }, - cursorColor: cs.primary, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontFamily: 'Outfit', - ), - decoration: InputDecoration( - hintText: 'Поиск...', - hintStyle: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 16, - fontFamily: 'Outfit', - ), - border: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.symmetric(vertical: 8), - ), - ); - - final backBtn = IconButton( - icon: Icon(Symbols.arrow_back, weight: glossy ? 500 : 400, - color: cs.onSurface), - onPressed: _closeSearch, - ); - final searchBtn = IconButton( - icon: AnimatedLottieIcon( - asset: AppAnimations.search, - color: cs.onSurface, - size: 24, - active: true, - animateOnMount: true, - ), - onPressed: () { - _searchDebounce?.cancel(); - _runSearch(_searchController.text); - }, - ); - - if (!glossy) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Row( - children: [backBtn, Expanded(child: field), searchBtn], - ), - ); - } - - return Padding( - padding: const EdgeInsets.fromLTRB(10, 4, 10, 6), - child: GlossyPill( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: SizedBox( - height: 44, - child: Row( - children: [backBtn, Expanded(child: field), searchBtn], - ), - ), - ), - ); - } - - Widget _glossyHeaderRow(ColorScheme cs) { - return Padding( - padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), - child: Row( - children: [ - _backWithBadge( - cs, - SizedBox( - width: 56, - height: 56, - child: GlossyPill( - onTap: () { - if (widget.embedded) { - widget.onClose?.call(); - } else { - Navigator.pop(context); - } - }, - child: Center( - child: Icon( - widget.embedded ? Symbols.close : Symbols.arrow_back, - color: cs.onSurface, - weight: 500, - size: 24, - ), - ), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: GlossyPill( - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChatInfoScreen( - chatId: widget.chatId, - name: widget.name, - imageUrl: widget.imageUrl, - chatType: widget.chatType, - ), - ), - ), - padding: const EdgeInsets.fromLTRB(6, 6, 16, 6), - child: Row( - children: [ - _withOnlineDot( - cs, - AvatarHero( - tag: 'chatAvatar_${widget.chatId}', - name: widget.name, - imageUrl: widget.imageUrl.isNotEmpty - ? widget.imageUrl - : null, - child: widget.imageUrl.isNotEmpty - ? CircleAvatar( - radius: 22, - backgroundImage: CachedNetworkImageProvider( - widget.imageUrl, - maxWidth: 144, - maxHeight: 144, - ), - ) - : CircleAvatar( - radius: 22, - backgroundColor: cs.primaryContainer, - child: Text( - widget.name.isNotEmpty - ? widget.name[0].toUpperCase() - : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 16, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - widget.name, - style: TextStyle( - color: cs.onSurface, - fontSize: 17, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (chat?.isOfficial ?? false) ...[ - const SizedBox(width: 4), - Icon( - Symbols.verified, - color: cs.primary, - size: 16, - weight: 600, - fill: 1, - ), - ], - ], - ), - ValueListenableBuilder( - valueListenable: _headerStatusNotifier, - builder: (context, status, _) => Text( - status, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - fontWeight: FontWeight.w400, - ), - ), - ), - ], - ), - ), - ], - ), - ), - ), - const SizedBox(width: 8), - GlossyPill( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: SizedBox( - height: 56, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ValueListenableBuilder( - valueListenable: _scheduledCount, - builder: (_, count, _) => count > 0 - ? IconButton( - icon: Icon( - Symbols.schedule, - weight: 500, - color: cs.onSurface, - ), - onPressed: _openScheduledMessages, - ) - : const SizedBox.shrink(), - ), - IconButton( - icon: Icon(Symbols.call, weight: 500, color: cs.onSurface), - onPressed: _startCall, - ), - Builder( - builder: (btnContext) => IconButton( - icon: Icon( - Symbols.more_vert, - weight: 500, - color: cs.onSurface, - ), - onPressed: () => _openChatMenu(btnContext), - ), - ), - ], - ), - ), - ), - ], - ), - ); - } - - Widget _materialHeaderRow(ColorScheme cs) { - return Row( - children: [ - _backWithBadge( - cs, - IconButton( - icon: Icon( - widget.embedded ? Symbols.close : Symbols.arrow_back, - weight: 400, - color: cs.onSurface, - ), - onPressed: () { - if (widget.embedded) { - widget.onClose?.call(); - } else { - Navigator.pop(context); - } - }, - ), - ), - Expanded( - child: InkWell( - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChatInfoScreen( - chatId: widget.chatId, - name: widget.name, - imageUrl: widget.imageUrl, - chatType: widget.chatType, - ), - ), - ), - child: Row( - children: [ - _withOnlineDot( - cs, - AvatarHero( - tag: 'chatAvatar_${widget.chatId}', - name: widget.name, - imageUrl: widget.imageUrl.isNotEmpty - ? widget.imageUrl - : null, - child: widget.imageUrl.isNotEmpty - ? CircleAvatar( - radius: 18, - backgroundImage: CachedNetworkImageProvider( - widget.imageUrl, - maxWidth: 144, - maxHeight: 144, - ), - ) - : CircleAvatar( - radius: 18, - backgroundColor: cs.primaryContainer, - child: Text( - widget.name.isNotEmpty - ? widget.name[0].toUpperCase() - : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 12, - ), - ), - ), - ), - dotSize: 11, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - widget.name, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (chat?.isOfficial ?? false) ...[ - const SizedBox(width: 4), - Icon( - Symbols.verified, - color: cs.primary, - size: 16, - weight: 600, - fill: 1, - ), - ], - ], - ), - ValueListenableBuilder( - valueListenable: _headerStatusNotifier, - builder: (context, status, _) => Text( - status, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 12, - fontWeight: FontWeight.w400, - ), - ), - ), - ], - ), - ), - ], - ), - ), - ), - ValueListenableBuilder( - valueListenable: _scheduledCount, - builder: (_, count, _) => count > 0 - ? IconButton( - icon: Icon(Symbols.schedule, weight: 400, color: cs.onSurface), - onPressed: _openScheduledMessages, - ) - : const SizedBox.shrink(), - ), - IconButton( - icon: Icon(Symbols.call, weight: 400, color: cs.onSurface), - onPressed: _startCall, - ), - Builder( - builder: (btnContext) => IconButton( - icon: Icon(Symbols.more_vert, weight: 400, color: cs.onSurface), - onPressed: () => _openChatMenu(btnContext), - ), - ), - ], - ); - } - - Widget _selectionTopBar(ColorScheme cs, Set selected, bool glossy) { - final count = selected.length; - final copyMsg = _singleCopyableText(selected); - final editMsg = _singleEditable(selected); - final label = 'Выбрано $count'; - - if (!glossy) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Row( - children: [ - IconButton( - icon: Icon(Symbols.close, color: cs.onSurface), - onPressed: _clearSelection, - ), - const SizedBox(width: 4), - Expanded( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: cs.onSurface, - fontSize: 18, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - ), - if (copyMsg != null) - IconButton( - icon: Icon(Symbols.content_copy, color: cs.onSurface), - onPressed: () => _copySelected(copyMsg), - ), - if (editMsg != null) - IconButton( - icon: Icon(Symbols.edit, color: cs.onSurface), - onPressed: () => _editSelected(editMsg), - ), - IconButton( - icon: Icon(Symbols.delete, color: cs.onSurface), - onPressed: _deleteSelected, - ), - ], - ), - ); - } - - Widget actionBtn(IconData icon, VoidCallback onTap) => IconButton( - icon: Icon(icon, weight: 500, color: cs.onSurface), - onPressed: onTap, - ); - - return Padding( - padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), - child: Row( - children: [ - SizedBox( - width: 56, - height: 56, - child: GlossyPill( - onTap: _clearSelection, - child: Center( - child: Icon( - Symbols.close, - color: cs.onSurface, - weight: 500, - size: 24, - ), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: GlossyPill( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: SizedBox( - height: 56, - child: Align( - alignment: Alignment.centerLeft, - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: cs.onSurface, - fontSize: 18, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - ), - ), - ), - ), - const SizedBox(width: 8), - GlossyPill( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: SizedBox( - height: 56, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (copyMsg != null) - actionBtn(Symbols.content_copy, () => _copySelected(copyMsg)), - if (editMsg != null) - actionBtn(Symbols.edit, () => _editSelected(editMsg)), - actionBtn(Symbols.delete, _deleteSelected), - ], - ), - ), - ), - ], - ), - ); - } - void _openChatMenu(BuildContext btnContext) { final box = btnContext.findRenderObject() as RenderBox?; if (box == null || !box.hasSize) return; @@ -2873,16 +1816,14 @@ class _ChatScreenState extends State anchorRect: anchorRect, items: [ ChatMenuItem( - icon: Symbols.volume_up, - label: 'Уведомления', - showChevron: true, + icon: (chat?.isMuted ?? false) + ? Symbols.volume_off + : Symbols.volume_up, + label: (chat?.isMuted ?? false) + ? 'Включить уведомления' + : 'Отключить уведомления', dividerAfter: true, - onTap: () {}, - ), - ChatMenuItem( - icon: Symbols.videocam, - label: 'Видеозвонок', - onTap: () {}, + onTap: _toggleChatMute, ), ChatMenuItem(icon: Symbols.search, label: 'Поиск', onTap: _openSearch), ChatMenuItem( @@ -2904,6 +1845,28 @@ class _ChatScreenState extends State ); } + Future _toggleChatMute() async { + final current = chat; + if (current == null) return; + final muted = current.isMuted; + final target = muted ? ChatsModule.muteOff : ChatsModule.muteForever; + final error = await chats.setChatMute( + api, + chatId: widget.chatId, + dontDisturbUntil: target, + ); + if (!mounted) return; + if (error != null) { + showCustomNotification(context, error); + return; + } + setState(() => chat = current.copyWith(dontDisturbUntil: target)); + showCustomNotification( + context, + muted ? 'Уведомления включены' : 'Уведомления отключены', + ); + } + Future _loadWallpaper() async { await ChatWallpaperStore.instance.load(); if (!mounted) return; @@ -2965,44 +1928,18 @@ class _ChatScreenState extends State setState(() => _wallpaper = wp); } - Future _showConfirmDialog({ - required String title, - required String body, - required String confirmLabel, - }) { - final cs = Theme.of(context).colorScheme; - return showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: cs.surfaceContainerHigh, - title: Text(title), - content: Text( - body, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Отмена'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text(confirmLabel, style: TextStyle(color: cs.error)), - ), - ], - ), - ); - } - Future _clearHistory() async { - final confirmed = await _showConfirmDialog( + final confirmed = await showConfirmDialog( + context, title: 'Очистить историю', - body: 'Все сообщения в этом чате будут удалены без возможности ' + message: + 'Все сообщения в этом чате будут удалены без возможности ' 'восстановления.', confirmLabel: 'Очистить', + destructive: true, ); - if (!mounted || confirmed != true) return; - final err = await ChatsModule.clearHistory( + if (!mounted || !confirmed) return; + final err = await chats.clearHistory( api, chatId: widget.chatId, lastEventTime: chat?.lastEventTime ?? 0, @@ -3021,13 +1958,15 @@ class _ChatScreenState extends State } Future _deleteChat() async { - final confirmed = await _showConfirmDialog( + final confirmed = await showConfirmDialog( + context, title: 'Удалить чат', - body: 'Чат будет удалён вместе со всей перепиской.', + message: 'Чат будет удалён вместе со всей перепиской.', confirmLabel: 'Удалить', + destructive: true, ); - if (!mounted || confirmed != true) return; - final err = await ChatsModule.deleteChat( + if (!mounted || !confirmed) return; + final err = await chats.deleteChat( api, chatId: widget.chatId, lastEventTime: chat?.lastEventTime ?? 0, @@ -3093,10 +2032,12 @@ class _ChatScreenState extends State await Future.delayed(const Duration(milliseconds: 700)); if (!mounted || _myId == 0) return; try { - final serverMessages = - await messagesModule.fetchHistory(_myId, widget.chatId); + final serverMessages = await messagesModule.fetchHistory( + _myId, + widget.chatId, + ); if (KometSettings.viewDeleted.value) { - await ChatsModule.reconcileDeletedFromFetch( + await chats.reconcileDeletedFromFetch( _myId, widget.chatId, serverMessages, @@ -3110,14 +2051,15 @@ class _ChatScreenState extends State ); final decoded = await CachedMessage.fromDbRowsAsync(rows); if (mounted) _applyMergedMessages(decoded); - } catch (_) {} + } catch (e) { + logger.w('Обновление после звонка не удалось: $e'); + } } void _seedPresenceFromChat() { - if (widget.chatType != 'DIALOG' || _myId == 0) return; if (_otherStatus != 0 || _otherSeenTime != null) return; - final otherId = widget.chatId ^ _myId; - if (otherId <= 0) return; + final otherId = _resolveOtherId(); + if (otherId == null) return; final p = PresenceFetch.live(otherId); if (p == null) return; _otherStatus = (p['status'] as int?) ?? 0; @@ -3234,10 +2176,11 @@ class _ChatScreenState extends State ) { if (a.length != b.length) return false; String canon(List> els) { - final copy = [...els]..sort((x, y) { - final t = (x['type'] as String).compareTo(y['type'] as String); - return t != 0 ? t : (x['from'] as int).compareTo(y['from'] as int); - }); + final copy = [...els] + ..sort((x, y) { + final t = (x['type'] as String).compareTo(y['type'] as String); + return t != 0 ? t : (x['from'] as int).compareTo(y['from'] as int); + }); return copy .map((e) => '${e['type']}:${e['from']}:${e['length']}') .join(','); @@ -3320,10 +2263,7 @@ class _ChatScreenState extends State final Map? composedPayload = (replyPayload == null && elements.isEmpty) ? null - : { - ...?replyPayload, - if (elements.isNotEmpty) 'elements': elements, - }; + : {...?replyPayload, if (elements.isNotEmpty) 'elements': elements}; final composed = CachedMessage( id: tempId, @@ -3345,22 +2285,24 @@ class _ChatScreenState extends State } _bumpMessages(); unawaited(_persistOutgoing(composed)); - unawaited(ChatsModule.applyOutgoing( - _myId, - widget.chatId, - messageId: tempId, - time: now, - text: text, - status: composed.status ?? 'sending', - elements: elements, - )); + unawaited( + chats.applyOutgoing( + _myId, + widget.chatId, + messageId: tempId, + time: now, + text: text, + status: composed.status ?? 'sending', + elements: elements, + ), + ); // Instant tactile "whoosh" the moment the message leaves the composer, // not after the network round-trip — feedback must feel immediate. Haptics.send(); _scrollToBottom(); - _checkPrankTrigger(composed); + _prank.checkTrigger(composed); if (!online) return; @@ -3388,22 +2330,25 @@ class _ChatScreenState extends State _messages[index] = sent; _bumpMessages(); unawaited(_persistOutgoing(sent, removeId: tempId)); - unawaited(ChatsModule.applyOutgoing( - _myId, - widget.chatId, - messageId: sent.id, - time: now, - text: text, - status: 'sent', - elements: elements, - )); + unawaited( + chats.applyOutgoing( + _myId, + widget.chatId, + messageId: sent.id, + time: now, + text: text, + status: 'sent', + elements: elements, + ), + ); } if (chat == null) { unawaited( - ChatsModule.refreshChats(api, [widget.chatId]).then((list) { + chats.refreshChats(api, [widget.chatId]).then((list) { if (!mounted || list.isEmpty) return; setState(() => chat = list.first); + _bumpMessages(); _syncOtherReadTime(); }), ); @@ -3424,15 +2369,17 @@ class _ChatScreenState extends State _messages[index] = queued; _bumpMessages(); unawaited(_persistOutgoing(queued)); - unawaited(ChatsModule.applyOutgoing( - _myId, - widget.chatId, - messageId: tempId, - time: now, - text: text, - status: 'pending', - elements: elements, - )); + unawaited( + chats.applyOutgoing( + _myId, + widget.chatId, + messageId: tempId, + time: now, + text: text, + status: 'pending', + elements: elements, + ), + ); } } } @@ -3529,30 +2476,38 @@ class _ChatScreenState extends State _bumpMessages(); _scrollToBottom(); unawaited(_persistOutgoing(composed)); - unawaited(ChatsModule.applyOutgoing( - _myId, - widget.chatId, - messageId: tempId, - time: now, - text: text, - status: composed.status ?? 'sending', - )); + unawaited( + chats.applyOutgoing( + _myId, + widget.chatId, + messageId: tempId, + time: now, + text: text, + status: composed.status ?? 'sending', + ), + ); if (!online) return tempId; try { - final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text); + final actualId = await messagesModule.sendMessage( + _myId, + widget.chatId, + text, + ); final realId = actualId.isNotEmpty ? actualId : tempId; final i = _messages.indexWhere((m) => m.id == tempId); if (i != -1) { final sent = _replaceMessage(i, id: realId, status: 'sent'); unawaited(_persistOutgoing(sent, removeId: tempId)); - unawaited(ChatsModule.applyOutgoing( - _myId, - widget.chatId, - messageId: realId, - time: now, - text: text, - status: 'sent', - )); + unawaited( + chats.applyOutgoing( + _myId, + widget.chatId, + messageId: realId, + time: now, + text: text, + status: 'sent', + ), + ); } return realId; } catch (_) { @@ -3715,17 +2670,7 @@ class _ChatScreenState extends State if (!msgChanged) continue; anyChanged = true; - _messages[i] = CachedMessage( - id: msg.id, - accountId: msg.accountId, - chatId: msg.chatId, - senderId: msg.senderId, - text: msg.text, - time: msg.time, - status: msg.status, - payload: msg.payload, - attachments: newAttaches, - ); + _messages[i] = msg.copyWith(attachments: newAttaches); } if (anyChanged) { @@ -3799,8 +2744,10 @@ class _ChatScreenState extends State ); } + _highlightTimer?.cancel(); _highlightMessageId.value = messageId; - Future.delayed(const Duration(milliseconds: 1400), () { + _highlightTimer = Timer(const Duration(milliseconds: 1400), () { + if (!mounted) return; if (_highlightMessageId.value == messageId) { _highlightMessageId.value = null; } @@ -3813,65 +2760,22 @@ class _ChatScreenState extends State _messageKeys.putIfAbsent(messageId, () => GlobalKey()); void _openSearch() { - if (_searchMode.value || _selectionMode) return; - _searchMode.value = true; + if (_search.searchMode.value || _selectionMode) return; + _search.searchMode.value = true; _searchAnim.forward(); WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _searchMode.value) _searchFocusNode.requestFocus(); + if (mounted && _search.searchMode.value) _searchFocusNode.requestFocus(); }); } void _closeSearch() { - if (!_searchMode.value) return; - _searchDebounce?.cancel(); - _searchSeq++; + if (!_search.searchMode.value) return; _searchFocusNode.unfocus(); - _searchMode.value = false; _searchAnim.reverse(); - _searchController.clear(); - _searchResults.value = const []; - _searchLoading.value = false; - _searchPerformed.value = false; + _search.reset(); } - void _onSearchTextChanged() { - final query = _searchController.text.trim(); - _searchDebounce?.cancel(); - if (query.isEmpty) { - _searchSeq++; - _searchResults.value = const []; - _searchLoading.value = false; - _searchPerformed.value = false; - return; - } - _searchDebounce = Timer(const Duration(milliseconds: 300), () { - _runSearch(query); - }); - } - - Future _runSearch(String query) async { - final trimmed = query.trim(); - if (trimmed.isEmpty) return; - final seq = ++_searchSeq; - _searchLoading.value = true; - List> raw; - try { - raw = await messagesModule.searchMessages(widget.chatId, trimmed); - } catch (e) { - logger.e('Search error: $e'); - raw = const []; - } - if (!mounted || seq != _searchSeq) return; - final results = raw - .map(_MessageSearchResult.fromRaw) - .whereType<_MessageSearchResult>() - .toList(); - _searchResults.value = results; - _searchLoading.value = false; - _searchPerformed.value = true; - } - - Future _openSearchResult(_MessageSearchResult result) async { + Future _openSearchResult(MessageSearchResult result) async { _closeSearch(); await WidgetsBinding.instance.endOfFrame; if (!mounted) return; @@ -3917,8 +2821,10 @@ class _ChatScreenState extends State final estimate = below.clamp(0.0, maxExtent).toDouble(); _scrollController.jumpTo(estimate); + _highlightTimer?.cancel(); _highlightMessageId.value = messageId; - Future.delayed(const Duration(milliseconds: 1600), () { + _highlightTimer = Timer(const Duration(milliseconds: 1600), () { + if (!mounted) return; if (_highlightMessageId.value == messageId) { _highlightMessageId.value = null; } @@ -3965,222 +2871,6 @@ class _ChatScreenState extends State return null; } - Widget _buildHighlightedText( - String text, - List highlights, - ColorScheme cs, - ) { - final baseStyle = TextStyle(color: cs.onSurface, fontSize: 15); - final terms = highlights - .where((h) => h.trim().isNotEmpty) - .map((h) => h.toLowerCase()) - .toSet(); - if (text.isEmpty || terms.isEmpty) { - return Text( - text, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: baseStyle, - ); - } - - final lower = text.toLowerCase(); - final ranges = >[]; - for (final term in terms) { - var start = 0; - while (true) { - final idx = lower.indexOf(term, start); - if (idx < 0) break; - ranges.add([idx, idx + term.length]); - start = idx + term.length; - } - } - if (ranges.isEmpty) { - return Text( - text, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: baseStyle, - ); - } - - ranges.sort((a, b) => a[0].compareTo(b[0])); - final merged = >[]; - for (final r in ranges) { - if (merged.isNotEmpty && r[0] <= merged.last[1]) { - merged.last[1] = math.max(merged.last[1], r[1]); - } else { - merged.add([r[0], r[1]]); - } - } - - final highlightStyle = baseStyle.copyWith( - color: cs.primary, - fontWeight: FontWeight.w600, - backgroundColor: cs.primary.withValues(alpha: 0.18), - ); - final spans = []; - var cursor = 0; - for (final r in merged) { - if (r[0] > cursor) { - spans.add(TextSpan(text: text.substring(cursor, r[0]))); - } - spans.add( - TextSpan(text: text.substring(r[0], r[1]), style: highlightStyle), - ); - cursor = r[1]; - } - if (cursor < text.length) { - spans.add(TextSpan(text: text.substring(cursor))); - } - - return Text.rich( - TextSpan(style: baseStyle, children: spans), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ); - } - - Widget _buildSearchResultTile(_MessageSearchResult r, ColorScheme cs) { - final name = _searchSenderName(r.senderId); - final date = formatDateWords(DateTime.fromMillisecondsSinceEpoch(r.time)); - return InkWell( - onTap: () => _openSearchResult(r), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - KometAvatar( - name: name, - imageUrl: _searchSenderAvatar(r.senderId), - size: 44, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Expanded( - child: Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: cs.primary, - fontSize: 15, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - ), - const SizedBox(width: 8), - Text( - date, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 12, - ), - ), - ], - ), - const SizedBox(height: 2), - _buildHighlightedText(r.text, r.highlights, cs), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildSearchOverlay(ColorScheme cs) { - return AnimatedBuilder( - animation: _searchAnim, - builder: (context, _) { - final s = Curves.easeOut.transform(_searchAnim.value.clamp(0.0, 1.0)); - if (s == 0) return const SizedBox.shrink(); - final chrome = AppChatChrome.current.value; - final topPad = chrome == ChatChromeStyle.color - ? 0.0 - : MediaQuery.paddingOf(context).top; - return Positioned.fill( - child: IgnorePointer( - ignoring: s < 0.5, - child: Opacity( - opacity: s, - child: Container( - color: cs.surface, - padding: EdgeInsets.only(top: topPad), - child: _buildSearchResultsContent(cs), - ), - ), - ), - ); - }, - ); - } - - Widget _buildSearchResultsContent(ColorScheme cs) { - return ValueListenableBuilder( - valueListenable: _searchLoading, - builder: (context, loading, _) => - ValueListenableBuilder>( - valueListenable: _searchResults, - builder: (context, results, _) { - if (results.isNotEmpty) { - return ListView.builder( - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - padding: EdgeInsets.only( - top: 4, - bottom: MediaQuery.paddingOf(context).bottom + 16, - ), - itemCount: results.length, - itemBuilder: (context, index) => - _buildSearchResultTile(results[index], cs), - ); - } - if (loading) { - return Center( - child: SizedBox( - width: 26, - height: 26, - child: CircularProgressIndicator( - strokeWidth: 2.4, - color: cs.onSurfaceVariant, - ), - ), - ); - } - return ValueListenableBuilder( - valueListenable: _searchPerformed, - builder: (context, performed, _) { - if (!performed) return const SizedBox.shrink(); - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text( - 'Поиск ничего не вернул...', - textAlign: TextAlign.center, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 16, - ), - ), - ), - ); - }, - ); - }, - ), - ); - } - List _buildCombinedItems() { final key = Object.hash(_messagesRev.value, _messages.length); final cached = _combinedItemsCache; @@ -4341,8 +3031,8 @@ class _ChatScreenState extends State @override Widget build(BuildContext context) { - final theme = _prankActive - ? _prankPinkTheme(Theme.of(context)) + final theme = _prank.active + ? _prank.pinkTheme(Theme.of(context)) : Theme.of(context); final cs = theme.colorScheme; final underlap = AppChatChrome.current.value != ChatChromeStyle.color; @@ -4354,12 +3044,12 @@ class _ChatScreenState extends State ? math.max(mq.viewInsets.bottom, _keyboardReserve) : mq.viewInsets.bottom; return ListenableBuilder( - listenable: Listenable.merge([_selectedIds, _searchMode]), + listenable: Listenable.merge([_selectedIds, _search.searchMode]), builder: (context, child) => PopScope( - canPop: _selectedIds.value.isEmpty && !_searchMode.value, + canPop: _selectedIds.value.isEmpty && !_search.searchMode.value, onPopInvokedWithResult: (didPop, _) { if (didPop) return; - if (_searchMode.value) { + if (_search.searchMode.value) { _closeSearch(); } else { _clearSelection(); @@ -4368,34 +3058,34 @@ class _ChatScreenState extends State child: child!, ), child: MediaQuery( - data: mq.copyWith( - viewInsets: mq.viewInsets.copyWith(bottom: bottomInset), - ), - child: Theme( - data: theme, - child: RepaintBoundary( - key: _prankCaptureKey, - child: ValueListenableBuilder( - valueListenable: AppSwipeBackDesktop.current, - builder: (context, desktopSwipe, child) => SwipeToPop( - enabled: widget.embedded && desktopSwipe, - onPop: widget.onClose, - child: child!, - ), - child: AnimatedBuilder( - animation: _searchAnim, - child: underlap ? _buildUnderlapBody() : _buildColorBody(), - builder: (context, body) => Scaffold( - backgroundColor: cs.surface, - extendBodyBehindAppBar: underlap, - appBar: _buildAppBar(cs), - body: body, + data: mq.copyWith( + viewInsets: mq.viewInsets.copyWith(bottom: bottomInset), + ), + child: Theme( + data: theme, + child: RepaintBoundary( + key: _prank.captureKey, + child: ValueListenableBuilder( + valueListenable: AppSwipeBackDesktop.current, + builder: (context, desktopSwipe, child) => SwipeToPop( + enabled: widget.embedded && desktopSwipe, + onPop: widget.onClose, + child: child!, + ), + child: AnimatedBuilder( + animation: _searchAnim, + child: underlap ? _buildUnderlapBody() : _buildColorBody(), + builder: (context, body) => Scaffold( + backgroundColor: cs.surface, + extendBodyBehindAppBar: underlap, + appBar: _buildAppBar(cs), + body: body, + ), ), ), ), ), ), - ), ); } @@ -4413,16 +3103,23 @@ class _ChatScreenState extends State ), Positioned.fill( child: _isLoading && _messages.isEmpty - ? _buildShimmerLoading() + ? ShimmerLoading(shimmer: _shimmerController) : _buildMessagesList(), ), Positioned( left: 0, right: 0, bottom: 0, - child: _buildCommandPanel(), + child: CommandPanelView(commandPanel: _commandPanel), + ), + SearchOverlay( + cs: cs, + searchAnim: _searchAnim, + search: _search, + onOpenResult: _openSearchResult, + senderName: _searchSenderName, + senderAvatar: _searchSenderAvatar, ), - _buildSearchOverlay(cs), ], ), ), @@ -4438,15 +3135,20 @@ class _ChatScreenState extends State fit: StackFit.expand, children: [ if (_wallpaper != null) - Positioned.fill( - child: ChatWallpaperView(wallpaper: _wallpaper!), - ), + Positioned.fill(child: ChatWallpaperView(wallpaper: _wallpaper!)), Positioned.fill( child: _isLoading && _messages.isEmpty - ? _buildShimmerLoading() + ? ShimmerLoading(shimmer: _shimmerController) : _buildMessagesList(), ), - _buildSearchOverlay(cs), + SearchOverlay( + cs: cs, + searchAnim: _searchAnim, + search: _search, + onOpenResult: _openSearchResult, + senderName: _searchSenderName, + senderAvatar: _searchSenderAvatar, + ), if (vignette) ...[ Positioned( top: 0, @@ -4470,7 +3172,7 @@ class _ChatScreenState extends State left: 0, right: 0, bottom: height, - child: _buildCommandPanel(), + child: CommandPanelView(commandPanel: _commandPanel), ), ), Positioned( @@ -4492,13 +3194,18 @@ class _ChatScreenState extends State ); } - Widget _buildEdgeVignette(ColorScheme cs, {required bool top, double? height}) { + Widget _buildEdgeVignette( + ColorScheme cs, { + required bool top, + double? height, + }) { final double resolved; if (height != null) { resolved = height; } else { final glossy = AppVisualStyle.current.value == VisualStyle.glossy; - resolved = MediaQuery.paddingOf(context).top + + resolved = + MediaQuery.paddingOf(context).top + (glossy ? _glossyHeaderHeight : kToolbarHeight); } return IgnorePointer( @@ -4515,22 +3222,15 @@ class _ChatScreenState extends State ); } - Widget _buildMessagesList() { - return ValueListenableBuilder( - valueListenable: _messagesRev, - builder: (context, _, _) => _buildMessagesListContent(), - ); - } + Widget _buildMessagesList() => + _messageListWidget ??= _ChatMessageList(this, key: _messageListKey); EdgeInsets _messagesListPadding(BuildContext context) { if (AppChatChrome.current.value == ChatChromeStyle.color) { return const EdgeInsets.symmetric(vertical: 8); } final topInset = MediaQuery.paddingOf(context).top; - return EdgeInsets.only( - top: topInset + 8, - bottom: _composerHeight.value + 8, - ); + return EdgeInsets.only(top: topInset + 8, bottom: 8); } double _floatingDateTop(BuildContext context) { @@ -4577,150 +3277,153 @@ class _ChatScreenState extends State return Stack( key: _listKey, children: [ - ListenableBuilder( - listenable: Listenable.merge([_otherReadTime, _composerHeight]), - builder: (context, _) => ValueListenableBuilder( - valueListenable: AppCacheExtent.current, - builder: (context, cacheExtent, _) => ListView.builder( - controller: _scrollController, - reverse: true, - padding: _messagesListPadding(context), - cacheExtent: cacheExtent, - itemCount: items.length + (_isLoadingMore ? 1 : 0), - itemBuilder: (context, index) { - if (index >= items.length) { - return _buildLoadMoreIndicator(); - } - final item = items[items.length - 1 - index]; - - if (item is _DateSeparatorItem) { - return _buildDateSeparatorWidget( - context, - item.date, - key: item.key, - ); - } - - final msgItem = item as _MessageItem; - final message = msgItem.message; - final msgIndex = msgItem.index; - final isMe = message.senderId == _myId; - final prevMessage = msgIndex > 0 - ? _messages[msgIndex - 1] - : null; - final nextMessage = msgIndex < _messages.length - 1 - ? _messages[msgIndex + 1] - : null; - - final bubble = MessageBubble( - message: message, - isMe: isMe, - myId: _myId, - prevMessage: prevMessage, - nextMessage: nextMessage, - chatType: chat?.type ?? 'CHAT', - overrideStatus: _effectiveStatus(message), - reactionsListenable: _reactionNotifierFor(message), - uploadProgress: _photoProgressFor(message), - onReplyTap: _jumpToMessage, - onAvatarTap: _openSenderProfile, - onStickerTap: _openStickerPack, - ); - - final canReport = !isMe && !message.isControl; - final reportTypeId = _complaintTypeId( - chat?.type ?? widget.chatType, - ); - - final pressable = _SelectableMessageRow( - message: message, - isMe: isMe, - selectedIds: _selectedIds, - selectionAnim: _selectionAnim, - isSelectionActive: () => _selectionMode, - onToggleSelection: () => _toggleSelection(message), - onEnterSelection: () => _enterSelection(message), - onDelete: () => _confirmDeleteMessage(message, isMe), - onEdit: _canEditMessage(message) - ? () => _startEditMessage(message) - : null, - onReply: message.isControl - ? null - : () => _startReply(message), - onForward: message.isControl - ? null - : () => _forwardMessages([message]), - onMarkUnread: message.isControl - ? null - : () => _markMessageUnread(message), - loadReportReasons: canReport - ? () => _loadReportReasons(reportTypeId) - : null, - onReport: canReport - ? (reasonId) => - _reportMessage(message, reportTypeId, reasonId) - : null, - child: bubble, - ); - - final isChannel = - (chat?.type ?? widget.chatType) == 'CHANNEL'; - final swipeable = (message.isControl || isChannel) - ? pressable - : _SwipeToReply( - isMe: isMe, - onReply: () => _startReply(message), - child: pressable, - ); - - final Widget child; - if (_deletingIds.contains(message.id)) { - child = _DeletingMessageAnimation( - key: ValueKey('del_${message.id}'), - onComplete: () => _finalizeDelete(message.id), - child: IgnorePointer(child: swipeable), - ); - } else if (message.id == _lastSentId) { - child = _SentMessageAnimation( - key: ValueKey('anim_${message.id}'), - onComplete: () { - if (mounted) { - _lastSentId = null; - _bumpMessages(); - } - }, - child: swipeable, - ); - } else { - child = swipeable; - } - - final highlightable = ValueListenableBuilder( - valueListenable: _highlightMessageId, - builder: (context, hl, c) => AnimatedContainer( - duration: const Duration(milliseconds: 250), - color: hl == message.id - ? Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.12) - : Colors.transparent, - child: c, - ), - child: child, - ); - - final builtItem = RepaintBoundary( - key: ValueKey('msg_${message.id}'), - child: KeyedSubtree( - key: _keyForMessage(message.id), - child: highlightable, + ValueListenableBuilder( + valueListenable: AppCacheExtent.current, + builder: (context, cacheExtent, _) => ListView.builder( + controller: _scrollController, + reverse: true, + padding: _messagesListPadding(context), + cacheExtent: cacheExtent, + itemCount: items.length + 1 + (_isLoadingMore ? 1 : 0), + itemBuilder: (context, index) { + if (index == 0) { + return ValueListenableBuilder( + valueListenable: _composerHeight, + builder: (context, height, _) => SizedBox( + height: AppChatChrome.current.value == ChatChromeStyle.color + ? 0 + : height, ), ); - return message.id == _prankBubbleId - ? KeyedSubtree(key: _prankBubbleKey, child: builtItem) - : builtItem; - }, - ), + } + if (index > items.length) { + return _buildLoadMoreIndicator(); + } + final item = items[items.length - index]; + + if (item is _DateSeparatorItem) { + return _buildDateSeparatorWidget( + context, + item.date, + key: item.key, + ); + } + + final msgItem = item as _MessageItem; + final message = msgItem.message; + final msgIndex = msgItem.index; + final isMe = message.senderId == _myId; + final prevMessage = msgIndex > 0 ? _messages[msgIndex - 1] : null; + final nextMessage = msgIndex < _messages.length - 1 + ? _messages[msgIndex + 1] + : null; + + final bubble = MessageBubble( + message: message, + isMe: isMe, + myId: _myId, + prevMessage: prevMessage, + nextMessage: nextMessage, + chatType: chat?.type ?? 'CHAT', + overrideStatus: _effectiveStatus(message), + otherReadTime: _otherReadTime, + reactionsListenable: _reactionNotifierFor(message), + uploadProgress: _photoProgressFor(message), + onReplyTap: _jumpToMessage, + onAvatarTap: _openSenderProfile, + onStickerTap: _openStickerPack, + ); + + final canReport = !isMe && !message.isControl; + final reportTypeId = _complaintTypeId( + chat?.type ?? widget.chatType, + ); + + final pressable = _SelectableMessageRow( + message: message, + isMe: isMe, + selectedIds: _selectedIds, + selectionAnim: _selectionAnim, + isSelectionActive: () => _selectionMode, + onToggleSelection: () => _toggleSelection(message), + onEnterSelection: () => _enterSelection(message), + onDelete: () => _confirmDeleteMessage(message, isMe), + onEdit: _canEditMessage(message) + ? () => _startEditMessage(message) + : null, + onReply: message.isControl ? null : () => _startReply(message), + onForward: message.isControl + ? null + : () => _forwardMessages([message]), + onMarkUnread: message.isControl + ? null + : () => _markMessageUnread(message), + loadReportReasons: canReport + ? () => _loadReportReasons(reportTypeId) + : null, + onReport: canReport + ? (reasonId) => + _reportMessage(message, reportTypeId, reasonId) + : null, + child: bubble, + ); + + final isChannel = (chat?.type ?? widget.chatType) == 'CHANNEL'; + final swipeable = (message.isControl || isChannel) + ? pressable + : _SwipeToReply( + isMe: isMe, + onReply: () => _startReply(message), + child: pressable, + ); + + final Widget child; + if (_deletingIds.contains(message.id)) { + child = _DeletingMessageAnimation( + key: ValueKey('del_${message.id}'), + onComplete: () => _finalizeDelete(message.id), + child: IgnorePointer(child: swipeable), + ); + } else if (message.id == _lastSentId) { + child = _SentMessageAnimation( + key: ValueKey('anim_${message.id}'), + onComplete: () { + if (mounted) { + _lastSentId = null; + _bumpMessages(); + } + }, + child: swipeable, + ); + } else { + child = swipeable; + } + + final highlightable = ValueListenableBuilder( + valueListenable: _highlightMessageId, + builder: (context, hl, c) => AnimatedContainer( + duration: const Duration(milliseconds: 250), + color: hl == message.id + ? Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.12) + : Colors.transparent, + child: c, + ), + child: child, + ); + + final builtItem = RepaintBoundary( + key: ValueKey('msg_${message.id}'), + child: KeyedSubtree( + key: _keyForMessage(message.id), + child: highlightable, + ), + ); + return message.id == _prank.bubbleId + ? KeyedSubtree(key: _prank.bubbleKey, child: builtItem) + : builtItem; + }, ), ), Positioned( @@ -4758,287 +3461,6 @@ class _ChatScreenState extends State ); } - Widget _buildShimmerLoading() { - return AnimatedBuilder( - animation: _shimmerController, - builder: (context, child) { - final cs = Theme.of(context).colorScheme; - final placeholder = cs.surfaceContainerHighest; - final opacity = 0.3 + (0.4 * _shimmerController.value); - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: 8, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) { - final hasImage = index % 3 == 0; - final hasReactions = index % 2 == 0; - final width1 = 60.0 + (index * 15 % 50); - final width2 = 120.0 + (index * 25 % 80); - - return Opacity( - opacity: opacity, - child: Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: placeholder, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: width1, - height: 10, - decoration: BoxDecoration( - color: placeholder, - borderRadius: BorderRadius.circular(5), - ), - ), - const SizedBox(height: 6), - Container( - width: width2, - height: 32, - decoration: BoxDecoration( - color: placeholder, - borderRadius: BorderRadius.circular(10), - ), - ), - if (hasImage) ...[ - const SizedBox(height: 8), - Container( - width: double.infinity, - height: 120, - decoration: BoxDecoration( - color: placeholder, - borderRadius: BorderRadius.circular(12), - ), - ), - ], - if (hasReactions) ...[ - const SizedBox(height: 8), - Row( - children: List.generate( - 3, - (i) => Container( - width: 32, - height: 16, - margin: const EdgeInsets.only(right: 6), - decoration: BoxDecoration( - color: placeholder, - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - ], - ], - ), - ), - ], - ), - ), - ); - }, - ); - }, - ); - } - - static const int _voiceMinMs = 800; - static const double _voiceCancelThreshold = 110; - static const double _voiceLockThreshold = 90; - - Future _startVoiceRecording() async { - if (_isRecordingVoice.value || _myId == 0) return; - _voiceStopRequested = false; - final rec = _voiceRecorder ??= AudioRecorder(); - try { - final AudioEncoder encoder; - final String ext; - // Предпочитаем собственный кодер (libopus → Ogg/Opus): его формат сервер - // гарантированно принимает. Нативный Opus от record (напр. на Android) - // CDN не дообрабатывает — остаётся attachment.not.ready. - if (await OpusOggEncoder.ensureAvailable() && - await rec.isEncoderSupported(AudioEncoder.wav)) { - encoder = AudioEncoder.wav; - ext = 'wav'; - _voiceTranscode = true; - } else if (await rec.isEncoderSupported(AudioEncoder.opus)) { - encoder = AudioEncoder.opus; - ext = 'ogg'; - _voiceTranscode = false; - } else { - if (mounted) { - showCustomNotification( - context, - 'Голосовые сообщения недоступны на этой платформе', - ); - } - return; - } - if (!await rec.hasPermission()) { - if (mounted) showCustomNotification(context, 'Нет доступа к микрофону'); - return; - } - final dir = await getTemporaryDirectory(); - final path = - '${dir.path}/voice_${DateTime.now().millisecondsSinceEpoch}.$ext'; - _voiceAmps.clear(); - _voiceCancelled = false; - _voicePath = path; - await rec.start( - RecordConfig( - encoder: encoder, - numChannels: 1, - sampleRate: 48000, - ), - path: path, - ); - if (!mounted) { - try { - await rec.stop(); - } catch (_) {} - return; - } - _voiceStopwatch - ..reset() - ..start(); - _voiceElapsedMs.value = 0; - _voiceCancelDrag.value = 0; - _voiceLocked.value = false; - _voiceLockDrag.value = 0; - _isRecordingVoice.value = true; - FocusManager.instance.primaryFocus?.unfocus(); - Haptics.send(); - _voiceTimer = Timer.periodic(const Duration(milliseconds: 100), (_) { - _voiceElapsedMs.value = _voiceStopwatch.elapsedMilliseconds; - }); - _voiceAmpSub = rec - .onAmplitudeChanged(const Duration(milliseconds: 70)) - .listen((amp) { - final norm = ((amp.current + 45) / 45).clamp(0.0, 1.0); - _voiceAmps.add(norm); - _voiceAmplitude.value = norm; - _voiceWaveRev.value++; - }); - if (_voiceStopRequested) { - _voiceStopRequested = false; - await _stopVoiceRecording(cancel: false); - } - } catch (_) { - _isRecordingVoice.value = false; - if (mounted) showCustomNotification(context, 'Не удалось начать запись'); - } - } - - void _handleVoiceDrag(Offset offsetFromOrigin) { - if (!_isRecordingVoice.value || _voiceLocked.value) return; - - final lock = (-offsetFromOrigin.dy / _voiceLockThreshold).clamp(0.0, 1.0); - _voiceLockDrag.value = lock; - if (lock >= 1.0) { - _voiceLocked.value = true; - _voiceLockDrag.value = 0; - _voiceCancelDrag.value = 0; - Haptics.send(); - return; - } - - final drag = (-offsetFromOrigin.dx / _voiceCancelThreshold).clamp(0.0, 1.0); - _voiceCancelDrag.value = drag; - if (drag >= 1.0 && !_voiceCancelled) { - _voiceCancelled = true; - Haptics.error(); - _stopVoiceRecording(cancel: true); - } - } - - void _handleVoiceEnd() { - if (_voiceLocked.value) return; - _stopVoiceRecording(cancel: false); - } - - Future _stopVoiceRecording({required bool cancel}) async { - if (!_isRecordingVoice.value) { - _voiceStopRequested = true; - return; - } - final rec = _voiceRecorder; - if (rec == null) { - _isRecordingVoice.value = false; - return; - } - - _voiceTimer?.cancel(); - _voiceTimer = null; - await _voiceAmpSub?.cancel(); - _voiceAmpSub = null; - _voiceStopwatch.stop(); - final elapsed = _voiceStopwatch.elapsedMilliseconds; - _isRecordingVoice.value = false; - _voiceCancelDrag.value = 0; - _voiceAmplitude.value = 0; - _voiceLocked.value = false; - _voiceLockDrag.value = 0; - - String? path; - try { - path = await rec.stop(); - } catch (_) {} - path ??= _voicePath; - final amps = List.from(_voiceAmps); - _voiceAmps.clear(); - - final shouldCancel = cancel || _voiceCancelled || elapsed < _voiceMinMs; - if (shouldCancel || path == null) { - if (path != null) { - try { - await File(path).delete(); - } catch (_) {} - } - return; - } - - var file = File(path); - if (_voiceTranscode) { - final ogg = await _transcodeWavToOgg(file); - if (ogg == null) { - if (mounted) { - showCustomNotification(context, 'Не удалось закодировать запись'); - } - return; - } - file = ogg; - } - await _sendVoice(file, elapsed, amps); - } - - Future _transcodeWavToOgg(File wav) async { - try { - final bytes = await wav.readAsBytes(); - final ogg = await OpusOggEncoder.wavToOggOpus(bytes); - try { - await wav.delete(); - } catch (_) {} - if (ogg == null) return null; - final oggPath = '${wav.path.substring(0, wav.path.length - 3)}ogg'; - final out = File(oggPath); - await out.writeAsBytes(ogg, flush: true); - return out; - } catch (_) { - return null; - } - } - Uint8List _buildWave(List amps, {int bars = 80}) { final out = Uint8List(bars); if (amps.isEmpty) return out; @@ -5085,17 +3507,6 @@ class _ChatScreenState extends State _scrollToBottom(); try { - try { - final len = await file.length(); - final head = await file - .openRead(0, 80) - .fold>([], (a, b) => a..addAll(b)); - final hex = head.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); - final ascii = String.fromCharCodes( - head.map((b) => (b >= 32 && b < 127) ? b : 46), - ); - logger.w('VOICE size=$len hex=$hex ascii=$ascii'); - } catch (_) {} final info = await messagesModule.requestAudioUploadUrl(); if (info == null || info.url.isEmpty) throw Exception('no_url'); @@ -5124,7 +3535,11 @@ class _ChatScreenState extends State } if (serverMsg == null) throw Exception('send_failed'); - final real = CachedMessage.fromPushPayload(_myId, widget.chatId, serverMsg); + final real = CachedMessage.fromPushPayload( + _myId, + widget.chatId, + serverMsg, + ); final idx = _messages.indexWhere((m) => m.id == tempId); if (idx != -1) { _messages[idx] = real; @@ -5145,130 +3560,6 @@ class _ChatScreenState extends State } } - // ── Видеосообщения-кружки ────────────────────────────────────────── - Future _toggleComposerMode() async { - final toVideo = !_videoNoteMode.value; - _videoNoteMode.value = toVideo; - Haptics.tap(); - if (toVideo) { - await _initNoteCamera(); - } else { - await _disposeNoteCamera(); - } - } - - Future _initNoteCamera() async { - if (_noteRec.textureId != null) return; - if (!_noteRec.isAvailable) { - if (mounted) showCustomNotification(context, 'Камера недоступна'); - return; - } - try { - final ok = await _noteRec.init(); - if (!ok) { - if (mounted) showCustomNotification(context, 'Камера недоступна'); - return; - } - if (!mounted || !_videoNoteMode.value) { - await _disposeNoteCamera(); - return; - } - _noteTextureId.value = _noteRec.textureId; - _noteCamReady.value = true; - } catch (e) { - logger.w('initNoteCamera: $e'); - if (mounted) showCustomNotification(context, 'Камера недоступна'); - } - } - - Future _disposeNoteCamera() async { - _noteCamReady.value = false; - _noteTextureId.value = null; - await _noteRec.dispose(); - } - - Future _startNoteRecording() async { - if (_isRecordingNote.value) return; - _noteStopRequested = false; - if (_noteRec.textureId == null) { - await _initNoteCamera(); - return; - } - try { - final ok = await _noteRec.start(); - if (!ok) { - _isRecordingNote.value = false; - return; - } - if (!mounted) { - await _noteRec.stop(); - return; - } - _noteStopwatch - ..reset() - ..start(); - _noteElapsedMs.value = 0; - _noteCancelDrag.value = 0; - _noteCancelled = false; - _isRecordingNote.value = true; - FocusManager.instance.primaryFocus?.unfocus(); - Haptics.send(); - _noteTimer = Timer.periodic(const Duration(milliseconds: 100), (_) { - _noteElapsedMs.value = _noteStopwatch.elapsedMilliseconds; - }); - _showNoteOverlay(); - if (_noteStopRequested) { - _noteStopRequested = false; - await _stopNoteRecording(cancel: false); - } - } catch (e) { - logger.w('startNoteRecording: $e'); - _isRecordingNote.value = false; - } - } - - void _handleNoteDrag(Offset offsetFromOrigin) { - if (!_isRecordingNote.value) return; - final drag = (-offsetFromOrigin.dx / _voiceCancelThreshold).clamp(0.0, 1.0); - _noteCancelDrag.value = drag; - if (drag >= 1.0 && !_noteCancelled) { - _noteCancelled = true; - Haptics.error(); - _stopNoteRecording(cancel: true); - } - } - - void _handleNoteEnd() => _stopNoteRecording(cancel: false); - - Future _stopNoteRecording({required bool cancel}) async { - if (!_isRecordingNote.value) { - _noteStopRequested = true; - return; - } - _noteTimer?.cancel(); - _noteTimer = null; - _noteStopwatch.stop(); - final elapsed = _noteStopwatch.elapsedMilliseconds; - _isRecordingNote.value = false; - _noteCancelDrag.value = 0; - _hideNoteOverlay(); - - final path = await _noteRec.stop(); - - final shouldCancel = cancel || _noteCancelled || elapsed < _voiceMinMs; - if (shouldCancel || path == null) { - if (path != null) { - try { - await File(path).delete(); - } catch (_) {} - } - return; - } - - // Файл уже квадратный 480×480 (нативная запись) — шлём как есть. - await _sendVideoNote(File(path), elapsed); - } - Future _sendVideoNote(File file, int durationMs) async { if (_myId == 0) { try { @@ -5320,7 +3611,11 @@ class _ChatScreenState extends State return; } if (serverMsg == null) throw Exception('send_failed'); - final real = CachedMessage.fromPushPayload(_myId, widget.chatId, serverMsg); + final real = CachedMessage.fromPushPayload( + _myId, + widget.chatId, + serverMsg, + ); final idx = _messages.indexWhere((m) => m.id == tempId); if (idx != -1) { _messages[idx] = real; @@ -5341,675 +3636,6 @@ class _ChatScreenState extends State } } - OverlayEntry? _noteOverlay; - - void _showNoteOverlay() { - _noteOverlay?.remove(); - _noteOverlay = OverlayEntry( - builder: (context) { - final texId = _noteTextureId.value; - return Positioned.fill( - child: IgnorePointer( - child: Container( - color: Colors.black.withValues(alpha: 0.55), - alignment: Alignment.center, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ClipOval( - child: SizedBox( - width: 260, - height: 260, - child: texId != null - ? Texture(textureId: texId) - : Container(color: Colors.black), - ), - ), - const SizedBox(height: 20), - ValueListenableBuilder( - valueListenable: _noteElapsedMs, - builder: (context, ms, _) => Text( - _formatVoiceElapsed(ms), - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontFeatures: [ui.FontFeature.tabularFigures()], - ), - ), - ), - const SizedBox(height: 8), - ValueListenableBuilder( - valueListenable: _noteCancelDrag, - builder: (context, drag, _) => Opacity( - opacity: (0.5 + drag * 0.5).clamp(0.0, 1.0), - child: const Text( - '‹ влево — отмена', - style: TextStyle(color: Colors.white70, fontSize: 13), - ), - ), - ), - ], - ), - ), - ), - ); - }, - ); - final overlay = Overlay.of(context, rootOverlay: true); - overlay.insert(_noteOverlay!); - } - - void _hideNoteOverlay() { - _noteOverlay?.remove(); - _noteOverlay = null; - } - - String _formatVoiceElapsed(int ms) { - final totalSec = ms ~/ 1000; - final m = (totalSec ~/ 60).toString(); - final s = (totalSec % 60).toString().padLeft(2, '0'); - final ds = ((ms % 1000) ~/ 100).toString(); - return '$m:$s,$ds'; - } - - Widget _recordingButtonVisual({ - required Widget pill, - required ColorScheme cs, - required bool active, - }) { - return TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: active ? 1.0 : 0.0), - duration: const Duration(milliseconds: 220), - curve: Curves.easeOut, - builder: (context, a, _) { - if (a <= 0.001) return pill; - return ValueListenableBuilder( - valueListenable: _voiceAmplitude, - builder: (context, amp, _) => TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: amp), - duration: const Duration(milliseconds: 110), - builder: (context, v, _) { - final glow = a * (88.0 + v * 76.0); - return Stack( - clipBehavior: Clip.none, - alignment: Alignment.center, - children: [ - Positioned( - left: 27 - glow / 2, - top: 27 - glow / 2, - child: Container( - width: glow, - height: glow, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: cs.error.withValues(alpha: a * (0.16 + v * 0.12)), - ), - ), - ), - _voiceLockChip(cs), - Transform.scale( - scale: 1.0 + a * 0.14 + a * v * 0.24, - child: pill, - ), - ], - ); - }, - ), - ); - }, - ); - } - - Widget _voiceLockChip(ColorScheme cs) { - return Positioned( - bottom: 62, - child: ValueListenableBuilder( - valueListenable: _voiceLockDrag, - builder: (context, lock, _) => Opacity( - opacity: (0.5 + lock * 0.5).clamp(0.0, 1.0), - child: Transform.translate( - offset: Offset(0, lock * 12), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 7, horizontal: 6), - decoration: BoxDecoration( - color: Color.alphaBlend( - cs.surfaceContainerHighest.withValues(alpha: 0.96), - cs.surface, - ), - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.15), - blurRadius: 6, - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Symbols.lock, - size: 16, - color: lock > 0.6 ? cs.primary : cs.onSurfaceVariant, - ), - Icon( - Symbols.keyboard_arrow_up, - size: 14, - color: cs.onSurfaceVariant, - ), - ], - ), - ), - ), - ), - ), - ); - } - - Widget _buildVoiceRecordingIndicator(ColorScheme cs) { - return Container( - color: Color.alphaBlend( - cs.surfaceContainerHighest.withValues(alpha: 0.92), - cs.surface, - ), - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - ValueListenableBuilder( - valueListenable: _voiceAmplitude, - builder: (context, amp, child) => TweenAnimationBuilder( - tween: Tween(begin: 0, end: amp), - duration: const Duration(milliseconds: 120), - builder: (context, v, child) => Transform.scale( - scale: 1.0 + v * 0.7, - child: child, - ), - child: child, - ), - child: _RecordingDot(color: cs.error), - ), - const SizedBox(width: 12), - ValueListenableBuilder( - valueListenable: _voiceElapsedMs, - builder: (context, ms, _) => Text( - _formatVoiceElapsed(ms), - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontFeatures: const [ui.FontFeature.tabularFigures()], - ), - ), - ), - const SizedBox(width: 14), - Expanded( - child: ValueListenableBuilder( - valueListenable: _voiceCancelDrag, - builder: (context, drag, _) { - if (drag > 0.01) { - return Opacity( - opacity: (0.45 + drag * 0.55).clamp(0.0, 1.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Icon( - Symbols.arrow_back, - size: 16, - color: cs.onSurfaceVariant, - ), - const SizedBox(width: 6), - Text( - 'Отмена', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 14, - ), - ), - ], - ), - ); - } - return SizedBox( - height: 26, - child: ValueListenableBuilder( - valueListenable: _voiceWaveRev, - builder: (context, _, _) => CustomPaint( - size: Size.infinite, - painter: _LiveWavePainter( - amps: _voiceAmps, - color: cs.primary.withValues(alpha: 0.85), - ), - ), - ), - ); - }, - ), - ), - const SizedBox(width: 8), - ValueListenableBuilder( - valueListenable: _voiceLocked, - builder: (context, locked, _) => locked - ? GestureDetector( - onTap: () => _stopVoiceRecording(cancel: true), - behavior: HitTestBehavior.opaque, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Icon(Symbols.delete, size: 22, color: cs.error), - ), - ) - : Text( - '‹ влево — отмена', - style: TextStyle( - color: cs.onSurfaceVariant.withValues(alpha: 0.6), - fontSize: 11, - ), - ), - ), - ], - ), - ); - } - - Widget _buildInputArea(BuildContext context) { - final cs = Theme.of(context).colorScheme; - final mutedIcon = cs.onSurfaceVariant.withValues(alpha: 0.85); - - if (widget.chatType == "CHANNEL") { - return SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), - child: GlossyPill( - onTap: () {}, - color: Color.alphaBlend( - cs.surfaceContainerHighest.withValues(alpha: 0.92), - cs.surface, - ), - borderRadius: BorderRadius.circular(28), - padding: const EdgeInsets.symmetric(vertical: 16), - depth: 8, - borderSide: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.5), - width: 0.5, - ), - child: SizedBox( - width: double.infinity, - child: Center( - child: Text( - 'Отключить уведомления', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ), - ), - ); - } - - return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildReplyPreview(cs), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - vertical: 8.0, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - constraints: const BoxConstraints( - minHeight: 54, - maxHeight: 180, - ), - child: GlossyPill( - color: Color.alphaBlend( - cs.surfaceContainerHighest.withValues(alpha: 0.92), - cs.surface, - ), - borderRadius: BorderRadius.circular(28), - depth: 8, - borderSide: BorderSide( - color: cs.outlineVariant.withValues(alpha: 0.5), - width: 0.5, - ), - child: Stack( - alignment: Alignment.center, - children: [ - AnimatedBuilder( - animation: _attachAnim, - builder: (context, child) { - final t = _attachAnim.value; - return IgnorePointer( - ignoring: t > 0.5, - child: Opacity( - opacity: (1 - t).clamp(0.0, 1.0), - child: child, - ), - ); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: _toggleStickerPanel, - child: Icon( - Symbols.face, - color: mutedIcon, - size: 24, - weight: 400, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Focus( - onKeyEvent: (node, event) { - if (event is KeyDownEvent && - event.logicalKey == - LogicalKeyboardKey.enter && - !HardwareKeyboard - .instance - .isShiftPressed) { - if (_hasText.value) _sendMessage(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: TextField( - controller: _messageController, - focusNode: _messageFocusNode, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - ), - maxLines: null, - keyboardType: TextInputType.multiline, - textAlignVertical: TextAlignVertical.center, - contextMenuBuilder: (ctx, state) => - _formatContextMenu( - _messageController, - ctx, - state, - ), - decoration: InputDecoration( - hintText: 'Message', - hintStyle: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 16, - ), - border: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.symmetric( - vertical: 14, - ), - ), - ), - ), - ), - _AttachButton( - hasText: _hasText, - onOpen: _openAttachmentSheet, - onLongOpen: _openAttachmentSheetScheduled, - uploadStatus: _uploadStatus, - mutedIcon: mutedIcon, - cs: cs, - ), - ], - ), - ), - ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: SizedBox( - height: 54, - child: AnimatedBuilder( - animation: _attachAnim, - builder: (context, child) { - final t = _attachAnim.value; - return IgnorePointer( - ignoring: t < 0.5, - child: Opacity( - opacity: t.clamp(0.0, 1.0), - child: child, - ), - ); - }, - child: _HistoryStrip( - anim: _attachAnim, - cs: cs, - onTapEntry: _sendHistoryFile, - ), - ), - ), - ), - Positioned.fill( - child: ValueListenableBuilder( - valueListenable: _isRecordingVoice, - builder: (context, recording, _) => IgnorePointer( - ignoring: !recording, - child: AnimatedSlide( - offset: recording - ? Offset.zero - : const Offset(0.06, 0), - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - child: AnimatedOpacity( - opacity: recording ? 1 : 0, - duration: const Duration(milliseconds: 180), - curve: Curves.easeOut, - child: _buildVoiceRecordingIndicator(cs), - ), - ), - ), - ), - ), - ], - ), - ), - ), - ), - AnimatedBuilder( - animation: _attachAnim, - builder: (context, child) { - final t = _attachAnim.value; - return ClipRect( - clipper: _ButtonClipper(t), - child: Align( - alignment: Alignment.centerLeft, - widthFactor: (1 - t).clamp(0.0, 1.0), - child: child, - ), - ); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 8), - AnimatedBuilder( - animation: _attachAnim, - builder: (context, child) { - final t = _attachAnim.value; - return Transform.translate( - offset: Offset(t * 80, 0), - child: Opacity( - opacity: (1 - t * 1.5).clamp(0.0, 1.0), - child: child, - ), - ); - }, - child: ValueListenableBuilder( - valueListenable: _hasText, - builder: (context, hasText, _) => - ValueListenableBuilder( - valueListenable: _voiceLocked, - builder: (context, locked, _) => - ValueListenableBuilder( - valueListenable: _isRecordingVoice, - builder: (context, recording, _) => - ValueListenableBuilder( - valueListenable: _videoNoteMode, - builder: (context, videoMode, _) { - final sendMode = hasText || locked; - final pill = GlossyPill( - color: sendMode - ? cs.primary - : recording - ? cs.error - : cs.surfaceContainerHighest, - borderRadius: - BorderRadius.circular(27), - onTap: hasText - ? _sendMessage - : locked - ? () => _stopVoiceRecording( - cancel: false, - ) - : null, - onLongPress: hasText - ? _scheduleMessage - : null, - depth: 8, - child: SizedBox( - width: 54, - height: 54, - child: Center( - child: Icon( - sendMode - ? Symbols.send - : videoMode - ? Symbols.videocam - : Symbols.mic, - color: sendMode - ? cs.onPrimary - : recording - ? cs.onError - : cs.onSurface, - size: 24, - weight: 400, - ), - ), - ), - ); - final visual = _recordingButtonVisual( - pill: pill, - cs: cs, - active: recording && !locked, - ); - return GestureDetector( - onTap: sendMode - ? null - : _toggleComposerMode, - onLongPressStart: sendMode - ? null - : (_) => videoMode - ? _startNoteRecording() - : _startVoiceRecording(), - onLongPressMoveUpdate: sendMode - ? null - : (d) => videoMode - ? _handleNoteDrag( - d.offsetFromOrigin, - ) - : _handleVoiceDrag( - d.offsetFromOrigin, - ), - onLongPressEnd: sendMode - ? null - : (_) => videoMode - ? _handleNoteEnd() - : _handleVoiceEnd(), - child: visual, - ); - }, - ), - ), - ), - ), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildReplyPreview(ColorScheme cs) { - return ValueListenableBuilder( - valueListenable: _replyTo, - builder: (context, reply, _) { - if (reply == null) return const SizedBox.shrink(); - final name = reply.senderId == _myId - ? 'Вы' - : (ContactCache.get(reply.senderId) ?? 'Сообщение'); - final info = ReplyInfo( - senderId: reply.senderId, - text: reply.text, - attachments: reply.attachments, - ); - final preview = info.previewText(); - return Padding( - padding: const EdgeInsets.fromLTRB(16, 6, 8, 2), - child: Row( - children: [ - Icon(Symbols.reply, size: 20, color: cs.primary), - const SizedBox(width: 10), - Container(width: 2, height: 34, color: cs.primary), - const SizedBox(width: 10), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Ответ $name', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: cs.primary, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - if (preview.isNotEmpty) - Text( - preview, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ), - IconButton( - icon: const Icon(Symbols.close, size: 20), - color: cs.onSurfaceVariant, - onPressed: _cancelReply, - ), - ], - ), - ); - }, - ); - } - String _addOptimisticFileMessage(FileAttachment attachment) { final now = DateTime.now().millisecondsSinceEpoch; final tempId = _nextTempId(); @@ -6450,7 +4076,11 @@ class _ChatScreenState extends State showCustomNotification(context, 'Ошибка отправки'); return; } - final real = CachedMessage.fromPushPayload(_myId, widget.chatId, serverMsg); + final real = CachedMessage.fromPushPayload( + _myId, + widget.chatId, + serverMsg, + ); _messages[idx] = real; _bumpMessages(); unawaited(_persistOutgoing(real, removeId: tempId)); @@ -6462,81 +4092,34 @@ class _ChatScreenState extends State } void _toggleStickerPanel() { - if (_showStickerPanel.value) { - _showStickerPanel.value = false; + if (_stickers.showPanel.value) { + _stickers.hide(); _messageFocusNode.requestFocus(); return; } final keyboard = MediaQuery.viewInsetsOf(context).bottom; - if (keyboard > 120) _stickerPanelHeight = keyboard; + if (keyboard > 120) _stickers.panelHeight = keyboard; FocusManager.instance.primaryFocus?.unfocus(); - _showStickerPanel.value = true; + _stickers.showPanel.value = true; } void _onComposerFocusChanged() { - if (_messageFocusNode.hasFocus && _showStickerPanel.value) { - _showStickerPanel.value = false; + if (_messageFocusNode.hasFocus && _stickers.showPanel.value) { + _stickers.hide(); } } - void _onStickerPanelToggle() { - if (_showStickerPanel.value) { - _stickerAnim.forward(); - _sendStickerTyping(); - _stickerTypingTimer?.cancel(); - _stickerTypingTimer = Timer.periodic( - const Duration(seconds: 4), - (_) => _sendStickerTyping(), - ); - } else { - _stickerAnim.reverse(); - _stickerTypingTimer?.cancel(); - _stickerTypingTimer = null; - } - } - - void _sendStickerTyping() { - if (KometSettings.ghostMode.value) return; - messagesModule.sendTyping(widget.chatId, 'STICKER'); - } - Future _sendSticker(StickerItem sticker) async { - _showStickerPanel.value = false; - await _sendAttachMessage( - [ - StickerAttachment( - stickerId: sticker.id.toString(), - baseUrl: sticker.url, - lottieUrl: sticker.lottieUrl, - width: sticker.width, - height: sticker.height, - ), - ], - () => messagesModule.sendStickerMessage(widget.chatId, sticker.id), - ); - } - - Widget _buildStickerPanel(BuildContext context) { - return AnimatedBuilder( - animation: _stickerAnim, - child: StickerPanel( - height: _stickerPanelHeight, - onStickerTap: _sendSticker, + _stickers.hide(); + await _sendAttachMessage([ + StickerAttachment( + stickerId: sticker.id.toString(), + baseUrl: sticker.url, + lottieUrl: sticker.lottieUrl, + width: sticker.width, + height: sticker.height, ), - builder: (context, child) { - final t = Curves.easeOutCubic.transform( - _stickerAnim.value.clamp(0.0, 1.0), - ); - if (t == 0) return const SizedBox.shrink(); - return ClipRect( - child: Align( - alignment: Alignment.topCenter, - heightFactor: t, - child: child, - ), - ); - }, - ); + ], () => messagesModule.sendStickerMessage(widget.chatId, sticker.id)); } Future _shareLocation() async { @@ -6544,10 +4127,9 @@ class _ChatScreenState extends State if (position == null || !mounted) return; final lat = position.latitude; final lon = position.longitude; - await _sendAttachMessage( - [LocationAttachment(latitude: lat, longitude: lon, zoom: 15)], - () => messagesModule.sendLocationMessage(widget.chatId, lat, lon), - ); + await _sendAttachMessage([ + LocationAttachment(latitude: lat, longitude: lon, zoom: 15), + ], () => messagesModule.sendLocationMessage(widget.chatId, lat, lon)); } Future _resolveCurrentPosition() async { @@ -6562,7 +4144,8 @@ class _ChatScreenState extends State } if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) { - if (mounted) showCustomNotification(context, 'Нет доступа к геолокации'); + if (mounted) + showCustomNotification(context, 'Нет доступа к геолокации'); return null; } return await Geolocator.getCurrentPosition( @@ -6571,7 +4154,8 @@ class _ChatScreenState extends State ), ); } catch (e) { - if (mounted) showCustomNotification(context, 'Не удалось получить геопозицию'); + if (mounted) + showCustomNotification(context, 'Не удалось получить геопозицию'); return null; } } @@ -6650,7 +4234,7 @@ class _ChatScreenState extends State if (file.path == null) return; _showAttachmentPanel.value = false; - _uploadStatus.value = _UploadStatus(active: true, total: file.size); + _uploadStatus.value = UploadStatus(active: true, total: file.size); final scheduled = scheduledTime != null; final tempId = scheduled @@ -6682,7 +4266,7 @@ class _ChatScreenState extends State if (!mounted) return; switch (event) { case UploadProgress(:final sent, :final total): - _uploadStatus.value = _UploadStatus( + _uploadStatus.value = UploadStatus( active: true, sent: sent, total: total, @@ -6759,7 +4343,7 @@ class _ChatScreenState extends State _updateFileMessageStatus(tempId, 'error'); } } - _uploadStatus.value = const _UploadStatus(); + _uploadStatus.value = const UploadStatus(); _uploadSub = null; }, onError: (Object e) { @@ -6767,309 +4351,13 @@ class _ChatScreenState extends State stopNotif(); showCustomNotification(context, 'Ошибка: $e'); if (tempId != null) _updateFileMessageStatus(tempId, 'error'); - _uploadStatus.value = const _UploadStatus(); + _uploadStatus.value = const UploadStatus(); _uploadSub = null; }, ); } } -class _AttachButton extends StatelessWidget { - final ValueNotifier hasText; - final VoidCallback onOpen; - final VoidCallback onLongOpen; - final ValueNotifier<_UploadStatus> uploadStatus; - final Color mutedIcon; - final ColorScheme cs; - - const _AttachButton({ - required this.hasText, - required this.onOpen, - required this.onLongOpen, - required this.uploadStatus, - required this.mutedIcon, - required this.cs, - }); - - @override - Widget build(BuildContext context) { - return ListenableBuilder( - listenable: Listenable.merge([hasText, uploadStatus]), - builder: (context, _) { - final isText = hasText.value; - final status = uploadStatus.value; - final iconColor = status.awaitingResponse - ? cs.primary - : (status.active - ? cs.onSurfaceVariant.withValues(alpha: 0.5) - : mutedIcon); - final disabled = isText || status.active; - final onTap = disabled ? null : onOpen; - final onLongPress = disabled ? null : onLongOpen; - return AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: isText ? 0 : 36, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: isText ? 0 : 1, - child: isText - ? const SizedBox.shrink() - : GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onTap, - onLongPress: onLongPress, - child: Padding( - padding: const EdgeInsets.only(left: 12), - child: Stack( - alignment: Alignment.center, - children: [ - if (status.active) - SizedBox( - width: 30, - height: 30, - child: CircularProgressIndicator( - strokeWidth: 2, - value: status.progressValue, - color: cs.primary, - ), - ), - Icon( - Symbols.attachment, - color: iconColor, - size: 22, - weight: 400, - ), - ], - ), - ), - ), - ), - ); - }, - ); - } -} - -class _HistoryStrip extends StatelessWidget { - final Animation anim; - final ColorScheme cs; - final Future Function(FileHistoryEntry entry) onTapEntry; - - const _HistoryStrip({ - required this.anim, - required this.cs, - required this.onTapEntry, - }); - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder>( - valueListenable: FileHistoryCache.notifier, - builder: (context, history, _) { - if (history.isEmpty) { - return Center( - child: AnimatedBuilder( - animation: anim, - builder: (context, _) { - final v = anim.value.clamp(0.0, 1.0); - return Opacity( - opacity: v, - child: Text( - 'история пуста...', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), - ), - ); - }, - ), - ); - } - return ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - itemCount: history.length, - itemBuilder: (ctx, idx) { - final e = history[idx]; - final startInterval = (idx * 0.05).clamp(0.0, 0.45); - return AnimatedBuilder( - animation: anim, - builder: (context, child) { - final raw = ((anim.value - startInterval) / 0.45).clamp( - 0.0, - 1.0, - ); - final v = Curves.easeOutCubic.transform(raw); - return Opacity( - opacity: v, - child: Transform.translate( - offset: Offset(-14 * (1 - v), 0), - child: child, - ), - ); - }, - child: Container( - width: 54, - margin: const EdgeInsets.symmetric(horizontal: 3), - decoration: BoxDecoration( - color: cs.surfaceContainerLow, - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: cs.outlineVariant.withValues(alpha: 0.3), - ), - ), - child: Stack( - children: [ - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => onTapEntry(e), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _iconForFilename(e.filename), - color: cs.onSurfaceVariant, - size: 22, - ), - const SizedBox(height: 2), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 3, - ), - child: Text( - _labelForEntry(e), - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 9, - ), - overflow: TextOverflow.ellipsis, - maxLines: 1, - textAlign: TextAlign.center, - ), - ), - ], - ), - ), - ), - Positioned( - top: -2, - right: -2, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => FileHistoryCache.remove(e.fileId), - child: Container( - width: 18, - height: 18, - alignment: Alignment.center, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - shape: BoxShape.circle, - border: Border.all( - color: cs.outlineVariant.withValues(alpha: 0.5), - width: 0.5, - ), - ), - child: Icon( - Symbols.close, - size: 12, - color: cs.onSurfaceVariant, - ), - ), - ), - ), - ], - ), - ), - ); - }, - ); - }, - ); - } -} - -String _labelForEntry(FileHistoryEntry e) { - final n = e.filename; - if (n == null || n.isEmpty) return e.fileId.toString(); - final lastDot = n.lastIndexOf('.'); - return lastDot > 0 ? n.substring(0, lastDot) : n; -} - -IconData _iconForFilename(String? name) { - if (name == null || !name.contains('.')) return Symbols.description; - final ext = name.split('.').last.toLowerCase(); - switch (ext) { - case 'jpg': - case 'jpeg': - case 'png': - case 'gif': - case 'webp': - case 'bmp': - case 'heic': - case 'heif': - return Symbols.image; - case 'mp4': - case 'mov': - case 'avi': - case 'mkv': - case 'webm': - case '3gp': - return Symbols.movie; - case 'mp3': - case 'wav': - case 'ogg': - case 'flac': - case 'm4a': - case 'aac': - return Symbols.audio_file; - case 'pdf': - return Symbols.picture_as_pdf; - case 'zip': - case 'rar': - case '7z': - case 'tar': - case 'gz': - return Symbols.folder_zip; - case 'doc': - case 'docx': - case 'txt': - case 'rtf': - case 'odt': - case 'md': - return Symbols.article; - case 'xls': - case 'xlsx': - case 'csv': - return Symbols.table_chart; - case 'ppt': - case 'pptx': - return Symbols.slideshow; - case 'dart': - case 'js': - case 'ts': - case 'py': - case 'java': - case 'kt': - case 'swift': - case 'cpp': - case 'c': - case 'h': - case 'rs': - case 'go': - case 'rb': - case 'php': - case 'html': - case 'css': - case 'json': - case 'xml': - case 'yaml': - case 'yml': - return Symbols.code; - default: - return Symbols.description; - } -} - class _SwipeToReply extends StatefulWidget { final Widget child; final bool isMe; @@ -7098,13 +4386,14 @@ class _SwipeToReplyState extends State<_SwipeToReply> @override void initState() { super.initState(); - _springBack = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 200), - )..addListener(() { - final t = Curves.easeOut.transform(_springBack.value); - setState(() => _dragX = _springFrom * (1 - t)); - }); + _springBack = + AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + )..addListener(() { + final t = Curves.easeOut.transform(_springBack.value); + setState(() => _dragX = _springFrom * (1 - t)); + }); } @override @@ -7159,10 +4448,7 @@ class _SwipeToReplyState extends State<_SwipeToReply> ), ), ), - Transform.translate( - offset: Offset(_dragX, 0), - child: widget.child, - ), + Transform.translate(offset: Offset(_dragX, 0), child: widget.child), ], ), ); @@ -7314,9 +4600,7 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { shape: BoxShape.circle, color: selected ? cs.primary : Colors.transparent, border: Border.all( - color: selected - ? cs.primary - : cs.onSurfaceVariant.withValues(alpha: 0.6), + color: selected ? cs.primary : cs.mutedText, width: 2, ), ), @@ -7415,14 +4699,19 @@ class _DeletingMessageAnimationState extends State<_DeletingMessageAnimation> vsync: this, duration: const Duration(milliseconds: 280), ); - _opacity = Tween(begin: 1, end: 0).animate( - CurvedAnimation(parent: _ctrl, curve: const Interval(0.0, 0.6)), - ); - _scale = Tween(begin: 1, end: 0.82).animate( - CurvedAnimation(parent: _ctrl, curve: const Interval(0.0, 0.6)), - ); + _opacity = Tween( + begin: 1, + end: 0, + ).animate(CurvedAnimation(parent: _ctrl, curve: const Interval(0.0, 0.6))); + _scale = Tween( + begin: 1, + end: 0.82, + ).animate(CurvedAnimation(parent: _ctrl, curve: const Interval(0.0, 0.6))); _collapse = Tween(begin: 1, end: 0).animate( - CurvedAnimation(parent: _ctrl, curve: const Interval(0.35, 1.0, curve: Curves.easeInOut)), + CurvedAnimation( + parent: _ctrl, + curve: const Interval(0.35, 1.0, curve: Curves.easeInOut), + ), ); _ctrl.forward().whenComplete(() { if (_fired) return; @@ -7511,58 +4800,20 @@ class _SentMessageAnimationState extends State<_SentMessageAnimation> } } -class _RollingCount extends StatefulWidget { - final int count; - final TextStyle style; - - const _RollingCount({required this.count, required this.style}); +class _ChatMessageList extends StatefulWidget { + final _ChatScreenState host; + const _ChatMessageList(this.host, {super.key}); @override - State<_RollingCount> createState() => _RollingCountState(); + State<_ChatMessageList> createState() => _ChatMessageListState(); } -class _RollingCountState extends State<_RollingCount> { - late int _count = widget.count; - bool _increasing = true; - - @override - void didUpdateWidget(_RollingCount old) { - super.didUpdateWidget(old); - if (widget.count != _count) { - _increasing = widget.count > _count; - _count = widget.count; - } - } - +class _ChatMessageListState extends State<_ChatMessageList> { @override Widget build(BuildContext context) { - return AnimatedSwitcher( - duration: const Duration(milliseconds: 260), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - transitionBuilder: (child, anim) { - final incoming = (child.key as ValueKey).value == _count; - final Offset begin; - if (incoming) { - begin = _increasing ? const Offset(0, -1) : const Offset(0, 1); - } else { - begin = _increasing ? const Offset(0, 1) : const Offset(0, -1); - } - return ClipRect( - child: FadeTransition( - opacity: anim, - child: SlideTransition( - position: Tween(begin: begin, end: Offset.zero).animate(anim), - child: child, - ), - ), - ); - }, - child: Text( - '${widget.count}', - key: ValueKey(widget.count), - style: widget.style, - ), + return ValueListenableBuilder( + valueListenable: widget.host._messagesRev, + builder: (context, _, _) => widget.host._buildMessagesListContent(), ); } } diff --git a/lib/frontend/screens/chats/create_group_flow.dart b/lib/frontend/screens/chats/create_group_flow.dart index 782ba18..b7566fd 100644 --- a/lib/frontend/screens/chats/create_group_flow.dart +++ b/lib/frontend/screens/chats/create_group_flow.dart @@ -1,6 +1,5 @@ import 'dart:io'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -9,8 +8,10 @@ import '../../../backend/modules/chats.dart'; import '../../../backend/modules/contacts.dart'; import '../../../core/storage/token_storage.dart'; import '../../../core/utils/image_utils.dart'; +import '../../../core/utils/names.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/komet_avatar.dart'; import '../../widgets/sheet_helpers.dart'; import '../../widgets/swipe_route.dart'; import 'chat_screen.dart'; @@ -68,9 +69,9 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { final list = await ContactsModule.getContacts(myId); list.removeWhere((c) => c.id == myId); list.sort( - (a, b) => _displayName( - a, - ).toLowerCase().compareTo(_displayName(b).toLowerCase()), + (a, b) => displayName(a.firstName, a.lastName).toLowerCase().compareTo( + displayName(b.firstName, b.lastName).toLowerCase(), + ), ); if (!mounted) return; setState(() { @@ -118,7 +119,7 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { setState(() => _creating = true); final navigator = Navigator.of(context, rootNavigator: true); try { - final chat = await ChatsModule.createGroupChat( + final chat = await chats.createGroupChat( api, title: title, userIds: _selected.map((c) => c.id).toList(), @@ -131,7 +132,7 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { } if (_avatar != null) { - final url = await ChatsModule.requestChatPhotoUploadUrl(api); + final url = await chats.requestChatPhotoUploadUrl(api); if (url != null) { final bytes = await compressAvatar(await _avatar!.readAsBytes()); if (bytes == null) { @@ -145,11 +146,7 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { filename: 'avatar.jpg', ); if (token != null) { - await ChatsModule.setChatPhoto( - api, - chatId: chat.id, - photoToken: token, - ); + await chats.setChatPhoto(api, chatId: chat.id, photoToken: token); } else if (mounted) { showCustomNotification(context, 'Не удалось загрузить аватарку'); } @@ -177,11 +174,6 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { } } - String _displayName(CachedContact c) { - final last = c.lastName ?? ''; - return last.isEmpty ? c.firstName : '${c.firstName} $last'; - } - String _statusText(CachedContact c) => c.isBot ? 'Бот' : 'Был(-а) недавно'; @override @@ -231,7 +223,12 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { final filtered = query.isEmpty ? _all : _all - .where((c) => _displayName(c).toLowerCase().contains(query)) + .where( + (c) => displayName( + c.firstName, + c.lastName, + ).toLowerCase().contains(query), + ) .toList(); return Column( @@ -268,7 +265,7 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { for (final c in _selected) _SelectedChip( contact: c, - label: _displayName(c), + label: displayName(c.firstName, c.lastName), onRemove: () => _toggle(c), cs: cs, ), @@ -316,14 +313,18 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { ), child: Row( children: [ - _Avatar(contact: c, size: 40, cs: cs), + KometAvatar( + name: c.firstName, + size: 40, + imageUrl: c.baseUrl, + ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - _displayName(c), + displayName(c.firstName, c.lastName), style: TextStyle( color: dim ? cs.onSurface.withValues(alpha: 0.5) @@ -504,54 +505,6 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { } } -class _Avatar extends StatelessWidget { - final CachedContact contact; - final double size; - final ColorScheme cs; - const _Avatar({required this.contact, required this.size, required this.cs}); - - @override - Widget build(BuildContext context) { - final url = contact.baseUrl; - if (url != null && url.isNotEmpty) { - return ClipOval( - child: CachedNetworkImage( - imageUrl: url, - width: size, - height: size, - fit: BoxFit.cover, - placeholder: (_, _) => _initials(cs, size), - errorWidget: (_, _, _) => _initials(cs, size), - ), - ); - } - return _initials(cs, size); - } - - Widget _initials(ColorScheme cs, double size) { - final initial = contact.firstName.isNotEmpty - ? contact.firstName[0].toUpperCase() - : '?'; - return Container( - width: size, - height: size, - decoration: BoxDecoration( - color: cs.primaryContainer, - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: Text( - initial, - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: size * 0.4, - fontWeight: FontWeight.w600, - ), - ), - ); - } -} - class _SelectedChip extends StatelessWidget { final CachedContact contact; final String label; @@ -577,7 +530,11 @@ class _SelectedChip extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - _Avatar(contact: contact, size: 24, cs: cs), + KometAvatar( + name: contact.firstName, + size: 24, + imageUrl: contact.baseUrl, + ), const SizedBox(width: 6), ConstrainedBox( constraints: const BoxConstraints(maxWidth: 140), diff --git a/lib/frontend/screens/chats/scheduled_messages_screen.dart b/lib/frontend/screens/chats/scheduled_messages_screen.dart index 87b369f..2e98c14 100644 --- a/lib/frontend/screens/chats/scheduled_messages_screen.dart +++ b/lib/frontend/screens/chats/scheduled_messages_screen.dart @@ -9,10 +9,12 @@ import '../../../models/attachment.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/utils/format.dart'; import '../../../core/utils/haptics.dart'; +import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; import '../../widgets/confirm_dialog.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/schedule_time_picker.dart'; +import '../../widgets/sheet_helpers.dart'; class ScheduledMessagesScreen extends StatefulWidget { final int chatId; @@ -70,10 +72,14 @@ class _ScheduledMessagesScreenState extends State { }); } - Future _pickTime(DateTime initial) => - showScheduleTimePicker(context, initial: initial, title: 'Когда отправить'); + Future _pickTime(DateTime initial) => showScheduleTimePicker( + context, + initial: initial, + title: AppLocalizations.of(context)!.scheduledPickTimeTitle, + ); Future _edit(CachedMessage msg) async { + final l10n = AppLocalizations.of(context)!; final controller = TextEditingController(text: msg.text ?? ''); var when = DateTime.fromMillisecondsSinceEpoch( msg.delayedTimeToFire ?? msg.time, @@ -84,9 +90,7 @@ class _ScheduledMessagesScreenState extends State { context: context, isScrollControlled: true, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (sheetContext) => StatefulBuilder( builder: (sheetContext, setSheet) => Padding( padding: EdgeInsets.only( @@ -100,7 +104,7 @@ class _ScheduledMessagesScreenState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - 'Изменить', + l10n.scheduledEditTitle, style: TextStyle( color: cs.onSurface, fontSize: 18, @@ -116,7 +120,7 @@ class _ScheduledMessagesScreenState extends State { maxLines: 5, style: TextStyle(color: cs.onSurface), decoration: InputDecoration( - hintText: 'Текст сообщения', + hintText: l10n.scheduledMessageTextHint, filled: true, fillColor: cs.surfaceContainerHighest, border: OutlineInputBorder( @@ -163,7 +167,7 @@ class _ScheduledMessagesScreenState extends State { const SizedBox(height: 20), FilledButton( onPressed: () => Navigator.of(sheetContext).pop(true), - child: const Text('Сохранить'), + child: Text(l10n.scheduledSave), ), ], ), @@ -188,16 +192,17 @@ class _ScheduledMessagesScreenState extends State { Haptics.send(); _load(); } else { - showCustomNotification(context, 'Не удалось изменить сообщение'); + showCustomNotification(context, l10n.scheduledEditFailed); } } Future _delete(CachedMessage msg) async { + final l10n = AppLocalizations.of(context)!; final confirmed = await showConfirmDialog( context, - title: 'Удалить запланированное сообщение?', - message: 'Сообщение не будет отправлено.', - confirmLabel: 'Удалить', + title: l10n.scheduledDeleteConfirmTitle, + message: l10n.scheduledDeleteConfirmMessage, + confirmLabel: l10n.scheduledDeleteConfirmLabel, destructive: true, ); if (!confirmed || !mounted) return; @@ -213,12 +218,13 @@ class _ScheduledMessagesScreenState extends State { Haptics.send(); setState(() => _messages.removeWhere((m) => m.id == msg.id)); } else { - showCustomNotification(context, 'Не удалось удалить сообщение'); + showCustomNotification(context, l10n.scheduledDeleteFailed); } } @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; final cs = Theme.of(context).colorScheme; return Scaffold( backgroundColor: cs.surface, @@ -230,8 +236,8 @@ class _ScheduledMessagesScreenState extends State { title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Отложенные', + Text( + l10n.scheduledAppBarTitle, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, @@ -274,7 +280,7 @@ class _ScheduledMessagesScreenState extends State { Icon(Symbols.schedule, size: 56, color: cs.onSurfaceVariant), const SizedBox(height: 12), Text( - 'Нет отложенных сообщений', + AppLocalizations.of(context)!.scheduledEmpty, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15), ), ], @@ -284,19 +290,22 @@ class _ScheduledMessagesScreenState extends State { (IconData, String)? _attachLabel(CachedMessage msg) { final attaches = msg.attachments; if (attaches == null || attaches.isEmpty) return null; + final l10n = AppLocalizations.of(context)!; switch (attaches.first.type) { case AttachmentType.photo: - return (Symbols.image, 'Фото'); + return (Symbols.image, l10n.scheduledAttachPhoto); case AttachmentType.video: - return (Symbols.videocam, 'Видео'); + return (Symbols.videocam, l10n.scheduledAttachVideo); case AttachmentType.audio: - return (Symbols.mic, 'Голосовое'); + return (Symbols.mic, l10n.scheduledAttachVoice); case AttachmentType.file: - return (Symbols.description, 'Файл'); + return (Symbols.description, l10n.scheduledAttachFile); case AttachmentType.location: - return (Symbols.location_on, 'Геопозиция'); + return (Symbols.location_on, l10n.scheduledAttachLocation); + case AttachmentType.forward: + return (Symbols.forward, l10n.scheduledAttachForwarded); default: - return (Symbols.attach_file, 'Вложение'); + return (Symbols.attach_file, l10n.scheduledAttachGeneric); } } @@ -309,84 +318,84 @@ class _ScheduledMessagesScreenState extends State { borderRadius: BorderRadius.circular(18), onTap: () => _edit(msg), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(18), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (attach != null) - Padding( - padding: EdgeInsets.only(bottom: hasText ? 4 : 0), - child: Row( - children: [ - Icon(attach.$1, size: 16, color: cs.onSurfaceVariant), - const SizedBox(width: 6), - Text( - attach.$2, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 14, - fontWeight: FontWeight.w500, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(18), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (attach != null) + Padding( + padding: EdgeInsets.only(bottom: hasText ? 4 : 0), + child: Row( + children: [ + Icon(attach.$1, size: 16, color: cs.onSurfaceVariant), + const SizedBox(width: 6), + Text( + attach.$2, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w500, + ), ), - ), - ], - ), - ), - if (hasText) - Text( - msg.text!, - style: TextStyle(color: cs.onSurface, fontSize: 15), - ) - else if (attach == null) - Text( - 'Вложение', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 15, - fontStyle: FontStyle.italic, - ), - ), - const SizedBox(height: 6), - Row( - children: [ - Icon( - Symbols.schedule, - size: 14, - color: cs.primary, - weight: 500, - ), - const SizedBox(width: 4), - Text( - formatDateTimeWords(fireAt), - style: TextStyle( - color: cs.primary, - fontSize: 12, - fontWeight: FontWeight.w500, + ], ), ), - ], - ), - ], + if (hasText) + Text( + msg.text!, + style: TextStyle(color: cs.onSurface, fontSize: 15), + ) + else if (attach == null) + Text( + AppLocalizations.of(context)!.scheduledAttachGeneric, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 15, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: 6), + Row( + children: [ + Icon( + Symbols.schedule, + size: 14, + color: cs.primary, + weight: 500, + ), + const SizedBox(width: 4), + Text( + formatDateTimeWords(fireAt), + style: TextStyle( + color: cs.primary, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), ), - ), - const SizedBox(width: 4), - IconButton( - icon: Icon(Symbols.edit, color: cs.onSurfaceVariant, weight: 400), - onPressed: () => _edit(msg), - ), - IconButton( - icon: Icon(Symbols.delete, color: cs.error, weight: 400), - onPressed: () => _delete(msg), - ), - ], - ), + const SizedBox(width: 4), + IconButton( + icon: Icon(Symbols.edit, color: cs.onSurfaceVariant, weight: 400), + onPressed: () => _edit(msg), + ), + IconButton( + icon: Icon(Symbols.delete, color: cs.error, weight: 400), + onPressed: () => _delete(msg), + ), + ], + ), ), ); } diff --git a/lib/frontend/screens/chats/search_screen.dart b/lib/frontend/screens/chats/search_screen.dart index e042a18..90c9683 100644 --- a/lib/frontend/screens/chats/search_screen.dart +++ b/lib/frontend/screens/chats/search_screen.dart @@ -7,6 +7,8 @@ import '../../../main.dart'; import '../../../backend/modules/chats.dart'; import '../../../backend/modules/contacts.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/utils/debouncer.dart'; +import '../../../core/utils/names.dart'; import '../../widgets/komet_avatar.dart'; import '../../widgets/swipe_route.dart'; import '../contacts/contact_profile_screen.dart'; @@ -22,7 +24,7 @@ class SearchScreen extends StatefulWidget { class _SearchScreenState extends State { final _controller = TextEditingController(); final _focusNode = FocusNode(); - Timer? _debounce; + final _debounce = Debouncer(const Duration(milliseconds: 300)); int _seq = 0; int? _accountId; @@ -47,15 +49,15 @@ class _SearchScreenState extends State { @override void dispose() { - _debounce?.cancel(); + _debounce.dispose(); _controller.dispose(); _focusNode.dispose(); super.dispose(); } void _onChanged(String value) { - _debounce?.cancel(); if (value.trim().isEmpty) { + _debounce.cancel(); _seq++; setState(() { _loading = false; @@ -71,7 +73,7 @@ class _SearchScreenState extends State { if (_phoneResult != null) { setState(() => _phoneResult = null); } - _debounce = Timer(const Duration(milliseconds: 300), _runSearch); + _debounce.run(_runSearch); } Future _runSearch() async { @@ -89,8 +91,8 @@ class _SearchScreenState extends State { accountId == null ? Future.value(const >[]) : AppDatabase.searchChatsByTitle(accountId, query), - ChatsModule.searchMessages(api, query), - ChatsModule.searchPublic(api, query), + chats.searchMessages(api, query), + chats.searchPublic(api, query), phoneQuery == null ? Future.value(null) : ContactsModule.findByPhone(api, phoneQuery), @@ -98,9 +100,9 @@ class _SearchScreenState extends State { if (!mounted || token != _seq) return; - final chats = results[1] as List>; + final localChats = results[1] as List>; final messages = results[2] as List; - final localChatIds = chats.map((c) => c['id'] as int).toSet(); + final localChatIds = localChats.map((c) => c['id'] as int).toSet(); final public = (results[3] as List) .where((c) => !localChatIds.contains(c.id)) .toList(); @@ -116,7 +118,7 @@ class _SearchScreenState extends State { setState(() { _phoneResult = results[4] as PhoneLookupResult?; _contacts = results[0] as List>; - _chats = chats; + _chats = localChats; _messages = messages; _msgChatMeta = meta; _public = public; @@ -125,10 +127,11 @@ class _SearchScreenState extends State { } String _contactName(Map row) { - final first = (row['first_name'] as String?)?.trim() ?? ''; - final last = (row['last_name'] as String?)?.trim() ?? ''; - final name = '$first $last'.trim(); - return name.isEmpty ? '+${row['phone']}' : name; + return displayName( + row['first_name'], + row['last_name'], + fallback: '+${row['phone']}', + ); } void _openChat(int chatId, String name, String? avatarUrl, String type) { @@ -178,7 +181,8 @@ class _SearchScreenState extends State { Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final query = _controller.text.trim(); - final hasResults = _phoneResult != null || + final hasResults = + _phoneResult != null || _contacts.isNotEmpty || _chats.isNotEmpty || _messages.isNotEmpty || @@ -238,8 +242,7 @@ class _SearchScreenState extends State { return ListView( keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, children: [ - if (_loading) - const LinearProgressIndicator(minHeight: 2), + if (_loading) const LinearProgressIndicator(minHeight: 2), if (phoneResult != null) ...[ _sectionHeader(cs, 'По номеру'), _ResultTile( @@ -287,11 +290,11 @@ class _SearchScreenState extends State { } Widget _chatTile(ChatSearchHit hit) => _ResultTile( - name: hit.title ?? '', - imageUrl: hit.avatarUrl, - subtitle: hit.subtitle, - onTap: () => _openChat(hit.id, hit.title ?? '', hit.avatarUrl, hit.type), - ); + name: hit.title ?? '', + imageUrl: hit.avatarUrl, + subtitle: hit.subtitle, + onTap: () => _openChat(hit.id, hit.title ?? '', hit.avatarUrl, hit.type), + ); Widget _messageTile(MessageSearchHit hit) { final meta = _msgChatMeta[hit.chatId]; @@ -307,27 +310,27 @@ class _SearchScreenState extends State { } Widget _sectionHeader(ColorScheme cs, String title) => Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 6), - child: Text( - title, - style: TextStyle( - color: cs.primary, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - ); + padding: const EdgeInsets.fromLTRB(20, 16, 20, 6), + child: Text( + title, + style: TextStyle( + color: cs.primary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ); Widget _buildHint(ColorScheme cs, IconData icon, String text) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 48, color: cs.outline), - const SizedBox(height: 12), - Text(text, style: TextStyle(color: cs.outline, fontSize: 15)), - ], - ), - ); + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 48, color: cs.outline), + const SizedBox(height: 12), + Text(text, style: TextStyle(color: cs.outline, fontSize: 15)), + ], + ), + ); } class _ResultTile extends StatelessWidget { diff --git a/lib/frontend/screens/contacts/contact_profile_screen.dart b/lib/frontend/screens/contacts/contact_profile_screen.dart index 5f25161..67001da 100644 --- a/lib/frontend/screens/contacts/contact_profile_screen.dart +++ b/lib/frontend/screens/contacts/contact_profile_screen.dart @@ -5,6 +5,8 @@ import '../../../core/cache/info_cache.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; import '../../../core/utils/format.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../models/contact_info.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/komet_avatar.dart'; @@ -30,7 +32,7 @@ class ContactProfileScreen extends StatefulWidget { class _ContactProfileScreenState extends State { bool _loading = true; - Map? _contact; + ContactInfo? _contact; int? _seenTime; int _presenceStatus = 0; @@ -42,63 +44,52 @@ class _ContactProfileScreenState extends State { Future _load() async { try { - final results = await Future.wait([ - ContactInfoFetch.get(widget.contactId), - PresenceFetch.get(widget.contactId), - ]); + final contactFuture = ContactInfoFetch.get(widget.contactId); + final presenceFuture = PresenceFetch.get(widget.contactId); + final contact = await contactFuture; + final presence = await presenceFuture; if (!mounted) return; - final contact = results[0]; if (contact != null) { _contact = contact; } - final presence = results[1]; if (presence != null) { _seenTime = presence['seen'] as int?; _presenceStatus = (presence['status'] as int?) ?? 0; } } catch (e) { - if (mounted) showCustomNotification(context, 'Ошибка: $e'); + if (mounted) { + showCustomNotification( + context, + AppLocalizations.of(context)!.contactProfileLoadError(e.toString()), + ); + } } finally { if (mounted) setState(() => _loading = false); } } String _displayName() { - final c = _contact; - if (c != null) { - final names = c['names']; - if (names is List && names.isNotEmpty) { - final n = names.first; - if (n is Map) { - final full = n['name']?.toString(); - if (full != null && full.isNotEmpty) return full; - final first = n['firstName']?.toString() ?? ''; - final last = n['lastName']?.toString() ?? ''; - final combined = '$first $last'.trim(); - if (combined.isNotEmpty) return combined; - } - } - } - return widget.initialName ?? 'User #${widget.contactId}'; + return _contact?.displayName ?? + widget.initialName ?? + 'User #${widget.contactId}'; } String? _avatarUrl() { - return (_contact?['baseUrl'] as String?) ?? widget.initialAvatarUrl; + return _contact?.avatarUrl ?? widget.initialAvatarUrl; } Set _options() { - final raw = _contact?['options']; - if (raw is List) return raw.whereType().toSet(); - return const {}; + return _contact?.options.toSet() ?? const {}; } bool get _isBot => _options().contains('BOT'); bool get _isVerified => _options().contains('OFFICIAL'); String _subtitle() { - if (_isBot) return 'Бот'; - if (_presenceStatus == 1) return 'В сети'; - if (_presenceStatus == 3) return 'Был(-а) недавно'; + final l10n = AppLocalizations.of(context)!; + if (_isBot) return l10n.contactProfileBot; + if (_presenceStatus == 1) return l10n.contactProfileOnline; + if (_presenceStatus == 3) return l10n.contactProfileRecentlyActive; if (_seenTime != null && _seenTime! > 0) return formatLastSeen(_seenTime!); return ''; } @@ -208,10 +199,16 @@ class _ContactProfileScreenState extends State { } Widget _buildActions(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; final actions = <({IconData icon, String label, VoidCallback? onTap})>[ - (icon: Symbols.chat_bubble, label: 'Чат', onTap: _openChat), - (icon: Symbols.notifications, label: 'Звук', onTap: null), - if (!_isBot) (icon: Symbols.call, label: 'Звонок', onTap: null), + ( + icon: Symbols.chat_bubble, + label: l10n.contactProfileActionChat, + onTap: _openChat, + ), + (icon: Symbols.notifications, label: l10n.contactProfileActionSound, onTap: null), + if (!_isBot) + (icon: Symbols.call, label: l10n.contactProfileActionCall, onTap: null), ]; return Row( children: [ @@ -248,70 +245,79 @@ class _ContactProfileScreenState extends State { final c = _contact; if (c == null) return const SizedBox.shrink(); + final l10n = AppLocalizations.of(context)!; final rows = []; - final phoneStr = formatPhone(c['phone']); + final phoneStr = formatPhone(c.raw['phone']); if (phoneStr != null) { - rows.add(_infoRow(cs, Symbols.phone, 'Телефон', phoneStr)); + rows.add(_infoRow(cs, Symbols.phone, l10n.contactProfileInfoPhone, phoneStr)); } - final country = c['country'] as String?; + final country = c.raw['country'] as String?; if (country != null && country.isNotEmpty) { - rows.add(_infoRow(cs, Symbols.public, 'Страна', country)); + rows.add(_infoRow(cs, Symbols.public, l10n.contactProfileInfoCountry, country)); } - final genderStr = formatGender(c['gender']); + final genderStr = formatGender(c.raw['gender']); if (genderStr != null) { - rows.add(_infoRow(cs, Symbols.wc, 'Пол', genderStr)); + rows.add(_infoRow(cs, Symbols.wc, l10n.contactProfileInfoGender, genderStr)); } - final regTime = c['registrationTime'] as int?; + final regTime = c.raw['registrationTime'] as int?; if (regTime != null && regTime > 0) { rows.add( _infoRow( cs, Symbols.event, - 'Регистрация', + l10n.contactProfileInfoRegistration, formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(regTime)), ), ); } - final updateTime = c['updateTime'] as int?; + final updateTime = c.raw['updateTime'] as int?; if (updateTime != null && updateTime > 0) { rows.add( _infoRow( cs, Symbols.update, - 'Обновлён', + l10n.contactProfileInfoUpdated, formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(updateTime)), ), ); } - final accountStatus = c['accountStatus']; + final accountStatus = c.raw['accountStatus']; if (accountStatus is int && accountStatus != 0) { rows.add( _infoRow( cs, Symbols.account_circle, - 'Статус аккаунта', + l10n.contactProfileInfoAccountStatus, accountStatus.toString(), ), ); } - final desc = (c['description'] as String?)?.trim(); + final desc = (c.raw['description'] as String?)?.trim(); if (desc != null && desc.isNotEmpty) { - rows.add(_infoRow(cs, Symbols.info, 'Описание', desc, multiline: true)); + rows.add( + _infoRow( + cs, + Symbols.info, + l10n.contactProfileInfoDescription, + desc, + multiline: true, + ), + ); } - final link = c['link'] as String?; + final link = c.raw['link'] as String?; if (link != null && link.isNotEmpty) { - rows.add(_infoRow(cs, Symbols.link, 'Ссылка', link)); + rows.add(_infoRow(cs, Symbols.link, l10n.contactProfileInfoLink, link)); } - final webApp = c['webApp'] as String?; + final webApp = c.raw['webApp'] as String?; if (webApp != null && webApp.isNotEmpty) { rows.add(_infoRow(cs, Symbols.web, 'Web app', webApp)); } @@ -319,7 +325,13 @@ class _ContactProfileScreenState extends State { final opts = _options(); if (opts.isNotEmpty) { rows.add( - _infoRow(cs, Symbols.label, 'Флаги', opts.join(', '), multiline: true), + _infoRow( + cs, + Symbols.label, + l10n.contactProfileInfoFlags, + opts.join(', '), + multiline: true, + ), ); } diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index 1b62570..be9f462 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -6,6 +6,7 @@ import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; import '../../../backend/modules/contacts.dart'; import '../../../main.dart'; +import '../../../models/contact_info.dart'; import '../../widgets/komet_avatar.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/sheet_helpers.dart'; @@ -74,7 +75,10 @@ class _ContactsTabState extends State { child: NfcExchangeSheet(), ), transitionBuilder: (_, anim, _, child) { - final curved = CurvedAnimation(parent: anim, curve: Curves.easeOutCubic); + final curved = CurvedAnimation( + parent: anim, + curve: Curves.easeOutCubic, + ); return SlideTransition( position: Tween( begin: const Offset(0, -1), @@ -104,7 +108,6 @@ class _ContactsTabState extends State { return; } final contacts = await ContactsModule.getContacts(p.id); - // Sort contacts by first name contacts.sort((a, b) => a.firstName.compareTo(b.firstName)); if (mounted) { setState(() { @@ -324,12 +327,7 @@ class _SearchContactSheetState extends State<_SearchContactSheet> { return; } final raw = Map.from(contacts.first as Map); - String? name; - final namesRaw = raw['names']; - if (namesRaw is List && namesRaw.isNotEmpty) { - final n = namesRaw.first; - if (n is Map) name = n['name']?.toString(); - } + final info = ContactInfo.fromMap(raw); if (!mounted) return; final navigator = Navigator.of(context); final accountId = await TokenStorage.getActiveAccountId(); @@ -343,8 +341,8 @@ class _SearchContactSheetState extends State<_SearchContactSheet> { MaterialPageRoute( builder: (_) => ChatInfoScreen( chatId: chatId, - name: name ?? 'User #$id', - imageUrl: raw['baseUrl'] as String? ?? '', + name: info.displayName ?? 'User #$id', + imageUrl: info.avatarUrl ?? '', chatType: 'DIALOG', dialogPeerId: id, ), diff --git a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart index ba2127a..ab35f3e 100644 --- a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart +++ b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart @@ -10,7 +10,9 @@ import '../../../core/cache/info_cache.dart'; import '../../../core/nfc/nfc_exchange_service.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/utils/format.dart'; +import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; +import '../../../models/contact_info.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/komet_avatar.dart'; @@ -43,7 +45,7 @@ class _NfcExchangeSheetState extends State _Stage _stage = _Stage.checking; int? _peerId; int? _peerPhone; - Map? _peerInfo; + ContactInfo? _peerInfo; String _failReason = ''; @override @@ -126,39 +128,15 @@ class _NfcExchangeSheetState extends State } String _peerName() { - final info = _peerInfo; - if (info != null) { - final names = info['names']; - if (names is List && names.isNotEmpty) { - for (final n in names) { - if (n is! Map) continue; - final full = n['name']?.toString(); - if (full != null && full.isNotEmpty) return full; - final first = n['firstName']?.toString() ?? ''; - final last = n['lastName']?.toString() ?? ''; - final combined = '$first $last'.trim(); - if (combined.isNotEmpty) return combined; - } - } - } - return 'Контакт #${_peerId ?? ''}'; + final l10n = AppLocalizations.of(context)!; + return _peerInfo?.displayName ?? + l10n.nfcPeerNameFallback('${_peerId ?? ''}'); } String _firstNameForAdd() { - final info = _peerInfo; - if (info != null) { - final names = info['names']; - if (names is List && names.isNotEmpty) { - for (final n in names) { - if (n is! Map) continue; - final first = n['firstName']?.toString(); - if (first != null && first.isNotEmpty) return first; - final full = n['name']?.toString(); - if (full != null && full.isNotEmpty) return full; - } - } - } - return 'Контакт'; + return _peerInfo?.firstName ?? + _peerInfo?.displayName ?? + AppLocalizations.of(context)!.nfcPeerFirstNameFallback; } Future _add() async { @@ -174,24 +152,28 @@ class _NfcExchangeSheetState extends State ); if (!mounted) return; setState(() => _stage = _Stage.added); - showCustomNotification(context, 'Контакт добавлен'); + showCustomNotification(context, AppLocalizations.of(context)!.nfcContactAdded); await Future.delayed(const Duration(milliseconds: 700)); if (mounted) Navigator.pop(context); } catch (e) { if (!mounted) return; setState(() => _stage = _Stage.found); - showCustomNotification(context, 'Не удалось добавить: $e'); + showCustomNotification( + context, + AppLocalizations.of(context)!.nfcAddFailed(e.toString()), + ); } } String _reasonText(String? reason) { + final l10n = AppLocalizations.of(context)!; switch (reason) { case 'bluetooth_off': - return 'Включите Bluetooth и попробуйте снова'; + return l10n.nfcReasonBluetoothOff; case 'permission': - return 'Нужны разрешения Bluetooth для обмена'; + return l10n.nfcReasonPermission; default: - return 'Не удалось установить соединение'; + return l10n.nfcReasonDefault; } } @@ -201,59 +183,62 @@ class _NfcExchangeSheetState extends State return SizedBox( width: double.infinity, child: Material( - color: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(bottom: Radius.circular(28)), - ), - clipBehavior: Clip.antiAlias, - child: SafeArea( - bottom: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( + color: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(bottom: Radius.circular(28)), + ), + clipBehavior: Clip.antiAlias, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Expanded( - child: Text( - 'Обмен контактом', - style: TextStyle( - color: cs.onSurface, - fontSize: 18, - fontWeight: FontWeight.w600, + Row( + children: [ + Expanded( + child: Text( + AppLocalizations.of(context)!.nfcSheetTitle, + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), ), - ), + IconButton( + onPressed: () => Navigator.pop(context), + icon: Icon(Symbols.close, color: cs.onSurfaceVariant), + ), + ], ), - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon(Symbols.close, color: cs.onSurfaceVariant), + const SizedBox(height: 12), + AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + switchInCurve: Curves.easeOutBack, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition(scale: animation, child: child), + ), + child: KeyedSubtree( + key: ValueKey( + _stage == _Stage.found ? 'found' : _stage.name, + ), + child: _buildContent(cs), + ), ), ], ), - const SizedBox(height: 12), - AnimatedSwitcher( - duration: const Duration(milliseconds: 350), - switchInCurve: Curves.easeOutBack, - switchOutCurve: Curves.easeIn, - transitionBuilder: (child, animation) => FadeTransition( - opacity: animation, - child: ScaleTransition(scale: animation, child: child), - ), - child: KeyedSubtree( - key: ValueKey(_stage == _Stage.found ? 'found' : _stage.name), - child: _buildContent(cs), - ), - ), - ], ), ), ), - ), ); } Widget _buildContent(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; switch (_stage) { case _Stage.checking: return const Padding( @@ -261,13 +246,9 @@ class _NfcExchangeSheetState extends State child: CircularProgressIndicator(), ); case _Stage.unsupported: - return _message(cs, Symbols.nfc, 'NFC недоступен на этом устройстве'); + return _message(cs, Symbols.nfc, l10n.nfcUnsupported); case _Stage.disabled: - return _message( - cs, - Symbols.nfc, - 'Включите NFC в настройках телефона и попробуйте снова', - ); + return _message(cs, Symbols.nfc, l10n.nfcDisabled); case _Stage.failed: return _message(cs, Symbols.bluetooth_disabled, _failReason); case _Stage.scanning: @@ -299,6 +280,7 @@ class _NfcExchangeSheetState extends State } Widget _scanning(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Column( @@ -319,7 +301,7 @@ class _NfcExchangeSheetState extends State ), const SizedBox(height: 20), Text( - 'Поднесите телефоны друг к другу', + l10n.nfcScanningTitle, textAlign: TextAlign.center, style: TextStyle( color: cs.onSurface, @@ -329,7 +311,7 @@ class _NfcExchangeSheetState extends State ), const SizedBox(height: 6), Text( - 'Оба устройства должны держать этот экран открытым', + l10n.nfcScanningSubtitle, textAlign: TextAlign.center, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), @@ -339,6 +321,7 @@ class _NfcExchangeSheetState extends State } Widget _exchanging(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return Padding( padding: const EdgeInsets.symmetric(vertical: 24), child: Column( @@ -359,7 +342,7 @@ class _NfcExchangeSheetState extends State ), const SizedBox(height: 22), Text( - 'Идёт обмен контактами…', + l10n.nfcExchangingTitle, textAlign: TextAlign.center, style: TextStyle( color: cs.onSurface, @@ -369,7 +352,7 @@ class _NfcExchangeSheetState extends State ), const SizedBox(height: 6), Text( - 'Почти готово', + l10n.nfcExchangingSubtitle, textAlign: TextAlign.center, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), @@ -379,6 +362,7 @@ class _NfcExchangeSheetState extends State } Widget _foundCard(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; final loading = _peerInfo == null && _stage == _Stage.found; return Padding( padding: const EdgeInsets.symmetric(vertical: 8), @@ -401,7 +385,7 @@ class _NfcExchangeSheetState extends State }, child: KometAvatar( name: _peerName(), - imageUrl: _peerInfo?['baseUrl'] as String?, + imageUrl: _peerInfo?.avatarUrl, size: 92, fontSize: 34, ), @@ -421,7 +405,7 @@ class _NfcExchangeSheetState extends State ), const SizedBox(height: 4), Text( - formatPhone(_peerPhone) ?? 'ID ${_peerId ?? ''}', + formatPhone(_peerPhone) ?? l10n.nfcPeerIdFallback('${_peerId ?? ''}'), style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 24), @@ -441,7 +425,11 @@ class _NfcExchangeSheetState extends State height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) - : Text(_stage == _Stage.added ? 'Добавлено' : 'Добавить контакт'), + : Text( + _stage == _Stage.added + ? l10n.nfcAdded + : l10n.nfcAddContact, + ), ), ), ], @@ -472,7 +460,9 @@ class _RadarPainter extends CustomPainter { canvas.drawCircle(center, radius, paint); } final corePaint = Paint() - ..color = color.withValues(alpha: 0.10 + 0.05 * math.sin(progress * 2 * math.pi)); + ..color = color.withValues( + alpha: 0.10 + 0.05 * math.sin(progress * 2 * math.pi), + ); canvas.drawCircle(center, maxRadius * 0.32, corePaint); } @@ -494,8 +484,7 @@ class _BurstPainter extends CustomPainter { final maxRadius = size.width / 2; final eased = Curves.easeOut.transform(progress.clamp(0.0, 1.0)); - final glow = Paint() - ..color = color.withValues(alpha: (1.0 - eased) * 0.18); + final glow = Paint()..color = color.withValues(alpha: (1.0 - eased) * 0.18); canvas.drawCircle(center, maxRadius * (0.45 + 0.55 * eased), glow); for (var i = 0; i < 3; i++) { diff --git a/lib/frontend/screens/digital_id/digital_id_screen.dart b/lib/frontend/screens/digital_id/digital_id_screen.dart index 4329946..9998de7 100644 --- a/lib/frontend/screens/digital_id/digital_id_screen.dart +++ b/lib/frontend/screens/digital_id/digital_id_screen.dart @@ -4,27 +4,32 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/digital_id.dart'; import '../../../backend/modules/webapp.dart'; import '../../../core/utils/webview_support.dart'; +import '../../../l10n/app_localizations.dart'; import '../../../main.dart' show digitalIdModule; import '../../../models/digital_id.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/error_view.dart'; import '../webapp/web_app_screen.dart'; -const Map _documentLabels = { - 'passport': 'Паспорт РФ', - 'oms': 'Полис ОМС', - 'inn': 'ИНН', - 'driver_license': 'Водительское удостоверение', - 'vehicle_sts': 'СТС', - 'snils': 'СНИЛС', - 'child_birth_cert': 'Свидетельство о рождении', - 'pension_cert': 'Пенсионное удостоверение', - 'disabled_cert': 'Справка об инвалидности', - 'large_family_cert': 'Удостоверение многодетной семьи', - 'student_ticket': 'Студенческий билет', - 'child_inn': 'ИНН ребёнка', - 'child_oms': 'Полис ОМС ребёнка', -}; +String _documentLabel(AppLocalizations l10n, String type) { + return switch (type) { + 'passport' => l10n.digitalIdDocPassport, + 'oms' => l10n.digitalIdDocOms, + 'inn' => l10n.digitalIdInnLabel, + 'driver_license' => l10n.digitalIdDocDriverLicense, + 'vehicle_sts' => l10n.digitalIdDocVehicleSts, + 'snils' => l10n.digitalIdSnilsLabel, + 'child_birth_cert' => l10n.digitalIdDocChildBirthCert, + 'pension_cert' => l10n.digitalIdDocPensionCert, + 'disabled_cert' => l10n.digitalIdDocDisabledCert, + 'large_family_cert' => l10n.digitalIdDocLargeFamilyCert, + 'student_ticket' => l10n.digitalIdDocStudentTicket, + 'child_inn' => l10n.digitalIdDocChildInn, + 'child_oms' => l10n.digitalIdDocChildOms, + _ => type, + }; +} class DigitalIdScreen extends StatefulWidget { const DigitalIdScreen({super.key}); @@ -94,7 +99,7 @@ class _DigitalIdScreenState extends State { if (!webViewSupported) { showCustomNotification( context, - 'Привязка Госуслуг недоступна на этой платформе. Сделайте это в приложении на телефоне.', + AppLocalizations.of(context)!.digitalIdGosuslugiLinkUnavailable, ); return; } @@ -103,14 +108,17 @@ class _DigitalIdScreenState extends State { final link = await digitalIdModule.createEsiaLink(); if (!mounted) return; if (link.url.isEmpty) { - showCustomNotification(context, 'Не удалось получить ссылку Госуслуг'); + showCustomNotification( + context, + AppLocalizations.of(context)!.digitalIdGosuslugiLinkFailed, + ); return; } await Navigator.push( context, MaterialPageRoute( builder: (context) => WebAppScreen( - title: 'Госуслуги', + title: AppLocalizations.of(context)!.digitalIdGosuslugiTitle, loader: () async => WebAppLaunch(url: link.url), ), ), @@ -120,7 +128,12 @@ class _DigitalIdScreenState extends State { } on DigitalIdException catch (e) { if (mounted) showCustomNotification(context, e.message); } catch (e) { - if (mounted) showCustomNotification(context, 'Ошибка: $e'); + if (mounted) { + showCustomNotification( + context, + AppLocalizations.of(context)!.devicesGenericError(e.toString()), + ); + } } finally { if (mounted) setState(() => _busy = false); } @@ -137,7 +150,7 @@ class _DigitalIdScreenState extends State { } else { showCustomNotification( context, - 'Документы пока недоступны. Попробуйте позже.', + AppLocalizations.of(context)!.digitalIdDocsUnavailable, ); } } on DigitalIdException catch (e) { @@ -145,7 +158,12 @@ class _DigitalIdScreenState extends State { if (e.isNoGosuslugiLink) setState(() => _needsGosuslugi = true); showCustomNotification(context, e.message); } catch (e) { - if (mounted) showCustomNotification(context, 'Ошибка: $e'); + if (mounted) { + showCustomNotification( + context, + AppLocalizations.of(context)!.devicesGenericError(e.toString()), + ); + } } finally { if (mounted) setState(() => _busy = false); } @@ -154,6 +172,7 @@ class _DigitalIdScreenState extends State { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return Scaffold( backgroundColor: cs.surface, floatingActionButtonLocation: FloatingActionButtonLocation.startFloat, @@ -161,7 +180,7 @@ class _DigitalIdScreenState extends State { appBar: AppBar( backgroundColor: cs.surface, surfaceTintColor: Colors.transparent, - title: const Text('Цифровой ID'), + title: Text(l10n.digitalIdTitle), leading: IconButton( icon: const Icon(Symbols.arrow_back), onPressed: () => Navigator.of(context).maybePop(), @@ -182,7 +201,7 @@ class _DigitalIdScreenState extends State { return const Center(child: CircularProgressIndicator()); } if (_error != null) { - return _ErrorView(message: _error!, onRetry: _load); + return ErrorView(message: _error!, onRetry: _load); } if (_docs == null) { return _buildOnboarding(cs); @@ -203,6 +222,7 @@ class _DigitalIdScreenState extends State { } Widget _buildOnboarding(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 16), @@ -217,7 +237,7 @@ class _DigitalIdScreenState extends State { Icon(Symbols.badge, size: 72, color: cs.primary), const SizedBox(height: 20), Text( - 'Цифровой ID не настроен', + l10n.digitalIdNotConfiguredTitle, textAlign: TextAlign.center, style: TextStyle( fontSize: 20, @@ -228,8 +248,8 @@ class _DigitalIdScreenState extends State { const SizedBox(height: 10), Text( _needsGosuslugi - ? 'Привяжите аккаунт Госуслуг, чтобы документы появились в Цифровом ID. Номер телефона в MAX должен совпадать с номером в профиле Госуслуг.' - : 'Привяжите Госуслуги, чтобы получить доступ к документам, или обновите страницу, если уже настраивали Цифровой ID.', + ? l10n.digitalIdLinkGosuslugiHint + : l10n.digitalIdLinkOrRefreshHint, textAlign: TextAlign.center, style: TextStyle( fontSize: 14, @@ -247,8 +267,8 @@ class _DigitalIdScreenState extends State { child: OutlinedButton.icon( onPressed: _busy ? null : _loadDocsExplicit, icon: const Icon(Symbols.sync, size: 18), - label: const Text( - 'Загрузить документы', + label: Text( + l10n.digitalIdLoadDocuments, textAlign: TextAlign.center, ), ), @@ -264,8 +284,8 @@ class _DigitalIdScreenState extends State { child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Symbols.link, size: 18), - label: const Text( - 'Привязать Госуслуги', + label: Text( + l10n.digitalIdLinkGosuslugiButton, textAlign: TextAlign.center, ), ), @@ -281,6 +301,7 @@ class _DigitalIdScreenState extends State { } List _buildProfile(ColorScheme cs, DigitalIdUserDocs docs) { + final l10n = AppLocalizations.of(context)!; final profile = docs.profile; return [ Container( @@ -298,7 +319,9 @@ class _DigitalIdScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - profile.fullName.isEmpty ? 'Профиль Госуслуг' : profile.fullName, + profile.fullName.isEmpty + ? l10n.digitalIdGosuslugiProfileFallback + : profile.fullName, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, @@ -307,7 +330,7 @@ class _DigitalIdScreenState extends State { ), if (profile.birthDate != null) Text( - 'Дата рождения: ${profile.birthDate}', + l10n.digitalIdBirthDate(profile.birthDate!), style: TextStyle( fontSize: 13, color: cs.onPrimaryContainer.withValues(alpha: 0.8), @@ -320,18 +343,23 @@ class _DigitalIdScreenState extends State { ), ), const SizedBox(height: 16), - _buildInfoSection(cs, 'Личные данные', [ - if (profile.snils != null) ('СНИЛС', profile.snils!), - if (profile.inn != null) ('ИНН', profile.inn!), - if (profile.gender != null) ('Пол', profile.gender!), - if (profile.birthPlace != null) ('Место рождения', profile.birthPlace!), + _buildInfoSection(cs, l10n.digitalIdPersonalDataTitle, [ + if (profile.snils != null) (l10n.digitalIdSnilsLabel, profile.snils!), + if (profile.inn != null) (l10n.digitalIdInnLabel, profile.inn!), + if (profile.gender != null) + (l10n.contactProfileInfoGender, profile.gender!), + if (profile.birthPlace != null) + (l10n.digitalIdBirthPlaceLabel, profile.birthPlace!), if (profile.registrationAddress != null) - ('Адрес регистрации', profile.registrationAddress!.formatted), + ( + l10n.digitalIdRegistrationAddressLabel, + profile.registrationAddress!.formatted, + ), ]), if (profile.documents.isNotEmpty) ...[ const SizedBox(height: 16), Text( - 'Документы', + l10n.digitalIdDocumentsTitle, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, @@ -368,37 +396,43 @@ class _DigitalIdScreenState extends State { ), ), const SizedBox(height: 8), - ...rows.map((row) => Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 130, - child: Text( - row.$1, - style: TextStyle(fontSize: 14, color: cs.onSurfaceVariant), + ...rows.map( + (row) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 130, + child: Text( + row.$1, + style: TextStyle( + fontSize: 14, + color: cs.onSurfaceVariant, ), ), - Expanded( - child: Text( - row.$2, - style: TextStyle(fontSize: 14, color: cs.onSurface), - ), + ), + Expanded( + child: Text( + row.$2, + style: TextStyle(fontSize: 14, color: cs.onSurface), ), - ], - ), - )), + ), + ], + ), + ), + ), ], ), ); } Widget _buildDocumentTile(ColorScheme cs, DigitalIdDocument doc) { - final label = _documentLabels[doc.type] ?? doc.type; + final l10n = AppLocalizations.of(context)!; + final label = _documentLabel(l10n, doc.type); final subtitleParts = [ - if (doc.series != null) 'серия ${doc.series}', - if (doc.number != null) '№ ${doc.number}', + if (doc.series != null) l10n.digitalIdDocSeries(doc.series!), + if (doc.number != null) l10n.digitalIdDocNumber(doc.number!), ]; return Container( margin: const EdgeInsets.only(bottom: 8), @@ -437,10 +471,11 @@ class _DigitalIdScreenState extends State { } List _buildCards(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return [ const SizedBox(height: 16), Text( - 'Пропуска', + l10n.digitalIdPassesTitle, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, @@ -448,42 +483,47 @@ class _DigitalIdScreenState extends State { ), ), const SizedBox(height: 8), - ..._cards.map((card) => Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(14), - ), - child: Row( - children: [ - Icon(Symbols.badge, color: cs.primary), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - card.companyName, - style: TextStyle(fontSize: 15, color: cs.onSurface), + ..._cards.map( + (card) => Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Icon(Symbols.badge, color: cs.primary), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + card.companyName, + style: TextStyle(fontSize: 15, color: cs.onSurface), + ), + Text( + l10n.digitalIdCardInn(card.inn), + style: TextStyle( + fontSize: 13, + color: cs.onSurfaceVariant, ), - Text( - 'ИНН ${card.inn}', - style: - TextStyle(fontSize: 13, color: cs.onSurfaceVariant), - ), - ], - ), + ), + ], ), - ], - ), - )), + ), + ], + ), + ), + ), ]; } Widget _buildBiometryInfo(ColorScheme cs) { final biometry = _biometry; if (biometry == null) return const SizedBox.shrink(); + final l10n = AppLocalizations.of(context)!; return Row( children: [ Icon( @@ -495,8 +535,8 @@ class _DigitalIdScreenState extends State { Expanded( child: Text( biometry.hasBiometryToken - ? 'Биометрия настроена на этом устройстве' - : 'Биометрия на этом устройстве не настроена', + ? l10n.digitalIdBiometryConfigured + : l10n.digitalIdBiometryNotConfigured, style: TextStyle(fontSize: 13, color: cs.onSurfaceVariant), ), ), @@ -504,37 +544,3 @@ class _DigitalIdScreenState extends State { ); } } - -class _ErrorView extends StatelessWidget { - final String message; - final VoidCallback onRetry; - - const _ErrorView({required this.message, required this.onRetry}); - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Symbols.cloud_off, size: 48, color: cs.onSurfaceVariant), - const SizedBox(height: 16), - Text( - message, - textAlign: TextAlign.center, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), - ), - const SizedBox(height: 24), - FilledButton( - onPressed: onRetry, - child: const Text('Повторить'), - ), - ], - ), - ), - ); - } -} diff --git a/lib/frontend/screens/digital_id/digital_id_web_screen.dart b/lib/frontend/screens/digital_id/digital_id_web_screen.dart index 0e7afea..7a4e326 100644 --- a/lib/frontend/screens/digital_id/digital_id_web_screen.dart +++ b/lib/frontend/screens/digital_id/digital_id_web_screen.dart @@ -1,15 +1,9 @@ -import 'dart:collection'; - import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import '../../../backend/modules/webapp.dart'; -import '../../../core/storage/spoofing_service.dart'; import '../../../main.dart' show webAppModule, digitalIdModule; -import '../../widgets/connection_status.dart'; -import '../../widgets/webview_permission_prompt.dart'; +import '../webapp/web_app_screen.dart'; Future resetDigitalIdWebData() async { await CookieManager.instance().deleteAllCookies(); @@ -165,106 +159,15 @@ const String _kBridge = r''' })(); '''; -class DigitalIdWebScreen extends StatefulWidget { +class DigitalIdWebScreen extends StatelessWidget { const DigitalIdWebScreen({super.key}); - @override - State createState() => _DigitalIdWebScreenState(); -} - -class _DigitalIdWebScreenState extends State { - InAppWebViewController? _controller; - WebAppLaunch? _launch; - String? _loadError; - String _userAgent = ''; - double _progress = 0; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - setState(() { - _loadError = null; - _launch = null; - }); - try { - _userAgent = await SpoofingService.getWebViewUserAgent() ?? ''; - final launch = await webAppModule.fetchDigitalId(); - if (!mounted) return; - setState(() => _launch = launch); - } catch (e) { - if (!mounted) return; - setState(() => _loadError = e.toString()); - } - } - - Future _handleBack() async { - final controller = _controller; - if (controller != null && await controller.canGoBack()) { - await controller.goBack(); - return false; - } - return true; - } - @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) async { - if (didPop) return; - final navigator = Navigator.of(context); - if (await _handleBack()) navigator.pop(); - }, - child: Scaffold( - backgroundColor: cs.surface, - floatingActionButtonLocation: FloatingActionButtonLocation.startFloat, - floatingActionButton: const ConnectionSpinner(), - appBar: AppBar( - backgroundColor: cs.surface, - surfaceTintColor: Colors.transparent, - title: const Text('Цифровой ID'), - leading: IconButton( - icon: const Icon(Symbols.close), - onPressed: () => Navigator.of(context).maybePop(), - ), - actions: [ - IconButton( - icon: const Icon(Symbols.refresh), - onPressed: _launch == null ? null : () => _controller?.reload(), - ), - ], - bottom: _progress > 0 && _progress < 1 - ? PreferredSize( - preferredSize: const Size.fromHeight(2), - child: LinearProgressIndicator( - value: _progress, - minHeight: 2, - backgroundColor: Colors.transparent, - ), - ) - : null, - ), - body: _buildBody(cs), - ), - ); - } - - Widget _buildBody(ColorScheme cs) { - if (_loadError != null) { - return _ErrorView(message: _loadError!, onRetry: _load); - } - final launch = _launch; - if (launch == null) { - return const Center(child: CircularProgressIndicator()); - } - return InAppWebView( - initialUrlRequest: URLRequest(url: WebUri(launch.url)), - initialUserScripts: UnmodifiableListView([ + return WebAppScreen( + title: 'Цифровой ID', + loader: () => webAppModule.fetchDigitalId(), + extraUserScripts: [ UserScript( source: 'window.__KOMET_DID_DEBUG=$kDebugMode;', injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, @@ -273,30 +176,16 @@ class _DigitalIdWebScreenState extends State { source: _kBridge, injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, ), - ]), - initialSettings: InAppWebViewSettings( - javaScriptEnabled: true, - domStorageEnabled: true, - thirdPartyCookiesEnabled: true, - supportZoom: false, - transparentBackground: true, - mediaPlaybackRequiresUserGesture: false, - useHybridComposition: true, - useShouldOverrideUrlLoading: true, - userAgent: _userAgent, - ), + ], onWebViewCreated: (controller) { - _controller = controller; controller.addJavaScriptHandler( handlerName: 'closeWebApp', callback: (args) { - if (mounted) Navigator.of(context).maybePop(); + if (context.mounted) Navigator.of(context).maybePop(); return null; }, ); }, - onPermissionRequest: (controller, request) => - askWebViewPermission(context, request), onConsoleMessage: kDebugMode ? (controller, consoleMessage) { debugPrint('[KOMET-DID] ${consoleMessage.message}'); @@ -305,22 +194,29 @@ class _DigitalIdWebScreenState extends State { onLoadStart: kDebugMode ? (controller, url) { final u = url?.toString() ?? ''; - debugPrint('[KOMET-DID] loadStart: ${u.length > 160 ? u.substring(0, 160) : u}'); + debugPrint( + '[KOMET-DID] loadStart: ${u.length > 160 ? u.substring(0, 160) : u}', + ); } : null, - shouldOverrideUrlLoading: (controller, action) async { + shouldOverrideUrlLoading: (controller, action, currentUrl) async { final uri = action.request.url; final url = uri?.toString() ?? ''; final scheme = uri?.scheme ?? ''; if (kDebugMode) { - debugPrint('[KOMET-DID] nav: ${url.length > 140 ? url.substring(0, 140) : url}'); + debugPrint( + '[KOMET-DID] nav: ${url.length > 140 ? url.substring(0, 140) : url}', + ); } - final isCallback = url.contains('?externalCallback=') || + final isCallback = + url.contains('?externalCallback=') || url.contains('&externalCallback='); if (isCallback || (scheme != 'http' && scheme != 'https')) { - final launchUrl = _launch?.url ?? 'https://digital-id.max.ru'; + final launchUrl = currentUrl ?? 'https://digital-id.max.ru'; final hashIdx = launchUrl.indexOf('#'); - final base = hashIdx >= 0 ? launchUrl.substring(0, hashIdx) : launchUrl; + final base = hashIdx >= 0 + ? launchUrl.substring(0, hashIdx) + : launchUrl; final frag = hashIdx >= 0 ? launchUrl.substring(hashIdx) : ''; final query = uri?.query ?? ''; final target = query.isEmpty ? launchUrl : '$base?$query$frag'; @@ -329,47 +225,6 @@ class _DigitalIdWebScreenState extends State { } return NavigationActionPolicy.ALLOW; }, - onProgressChanged: (controller, progress) { - if (!mounted) return; - setState(() => _progress = progress / 100); - }, - onReceivedError: (controller, request, error) { - if (!mounted) return; - if (request.isForMainFrame ?? false) { - setState(() => _loadError = error.description); - } - }, - ); - } -} - -class _ErrorView extends StatelessWidget { - final String message; - final VoidCallback onRetry; - - const _ErrorView({required this.message, required this.onRetry}); - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Symbols.cloud_off, size: 48, color: cs.onSurfaceVariant), - const SizedBox(height: 16), - Text( - message, - textAlign: TextAlign.center, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), - ), - const SizedBox(height: 24), - FilledButton(onPressed: onRetry, child: const Text('Повторить')), - ], - ), - ), ); } } diff --git a/lib/frontend/screens/profile/app_icon_screen.dart b/lib/frontend/screens/profile/app_icon_screen.dart index 367a8b1..31a667b 100644 --- a/lib/frontend/screens/profile/app_icon_screen.dart +++ b/lib/frontend/screens/profile/app_icon_screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:material_symbols_icons/symbols.dart'; import '../../widgets/connection_status.dart'; @@ -7,6 +6,7 @@ import '../../../core/config/app_icon.dart'; import '../../../core/utils/haptics.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; +import '../../widgets/settings_radio_tile.dart'; class AppIconScreen extends StatefulWidget { const AppIconScreen({super.key}); @@ -91,8 +91,23 @@ class _AppIconScreenState extends State { return Column( children: [ for (final icon in AppIcon.values) - _IconTile( - icon: icon, + SettingsRadioTile( + leading: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Image.asset( + icon.previewAsset, + width: 56, + height: 56, + fit: BoxFit.cover, + ), + ), + leadingGap: 16, + label: icon.title, + labelStyle: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), selected: current == icon, onTap: () => _select(icon), ), @@ -109,62 +124,3 @@ class _AppIconScreenState extends State { ); } } - -class _IconTile extends StatelessWidget { - final AppIcon icon; - final bool selected; - final VoidCallback onTap; - - const _IconTile({ - required this.icon, - required this.selected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(14), - child: Image.asset( - icon.previewAsset, - width: 56, - height: 56, - fit: BoxFit.cover, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Text( - icon.title, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), - Icon( - selected - ? Symbols.radio_button_checked - : Symbols.radio_button_unchecked, - color: selected ? cs.primary : cs.outline, - size: 22, - fill: selected ? 1 : 0, - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/frontend/screens/profile/appearance_screen.dart b/lib/frontend/screens/profile/appearance_screen.dart index 974f936..43f8a6b 100644 --- a/lib/frontend/screens/profile/appearance_screen.dart +++ b/lib/frontend/screens/profile/appearance_screen.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -12,7 +11,9 @@ import '../../../core/config/app_pill_gradient.dart'; import '../../../core/config/app_visual_style.dart'; import '../../../core/config/app_chat_chrome.dart'; import '../../../core/utils/bubble_radius.dart'; +import '../../../core/utils/debouncer.dart'; import '../../../core/utils/haptics.dart'; +import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; import '../../widgets/glossy_pill.dart'; @@ -30,7 +31,7 @@ class _AppearanceScreenState extends State { final ValueNotifier _isSystem = ValueNotifier(false); bool _initialized = false; bool _accentExpanded = false; - Timer? _debounce; + final _debounce = Debouncer(const Duration(milliseconds: 350)); @override void didChangeDependencies() { @@ -45,7 +46,7 @@ class _AppearanceScreenState extends State { @override void dispose() { - _debounce?.cancel(); + _debounce.dispose(); _color.dispose(); _isSystem.dispose(); super.dispose(); @@ -54,15 +55,14 @@ class _AppearanceScreenState extends State { void _onColorChanged(Color color) { _color.value = color; _isSystem.value = false; - _debounce?.cancel(); - _debounce = Timer(const Duration(milliseconds: 350), () { + _debounce.run(() { if (mounted) KometApp.stateOf(context)?.applyAccentColor(color); }); } void _resetToSystem() { Haptics.selection(); - _debounce?.cancel(); + _debounce.cancel(); _isSystem.value = true; _color.value = _fallback; KometApp.stateOf(context)?.applyAccentColor(null); @@ -86,11 +86,12 @@ class _AppearanceScreenState extends State { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return Scaffold( backgroundColor: cs.surface, appBar: ConnectionTitleBar( - titleText: 'Внешний вид', + titleText: l10n.appearanceTitle, backgroundColor: cs.surface, ), body: SafeArea( @@ -132,6 +133,7 @@ class _VisualStyleCard extends StatelessWidget { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return GlossyPill( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(28), @@ -141,7 +143,7 @@ class _VisualStyleCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Визуал', + l10n.appearanceVisualStyleTitle, style: TextStyle( color: cs.onSurface, fontSize: 16, @@ -150,7 +152,7 @@ class _VisualStyleCard extends StatelessWidget { ), const SizedBox(height: 4), Text( - 'Material You или объёмные Glossy-капсулы', + l10n.appearanceVisualStyleSubtitle, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 16), @@ -158,14 +160,14 @@ class _VisualStyleCard extends StatelessWidget { valueListenable: AppVisualStyle.current, builder: (context, current, _) { return SegmentedButton( - segments: const [ + segments: [ ButtonSegment( value: VisualStyle.materialYou, - label: Text('Material You'), + label: Text(l10n.appearanceVisualStyleMaterialYou), ), ButtonSegment( value: VisualStyle.glossy, - label: Text('Glossy'), + label: Text(l10n.appearanceVisualStyleGlossy), ), ], selected: {current}, @@ -190,6 +192,7 @@ class _ChatChromeCard extends StatelessWidget { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return GlossyPill( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(28), @@ -199,7 +202,7 @@ class _ChatChromeCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Элементы экрана чата', + l10n.appearanceChatChromeTitle, style: TextStyle( color: cs.onSurface, fontSize: 16, @@ -208,8 +211,7 @@ class _ChatChromeCard extends StatelessWidget { ), const SizedBox(height: 4), Text( - 'Фон панелей сверху и снизу: цвет, размытие или прозрачно. ' - 'При размытии и прозрачности сообщения заходят под панели', + l10n.appearanceChatChromeSubtitle, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 16), @@ -217,18 +219,18 @@ class _ChatChromeCard extends StatelessWidget { valueListenable: AppChatChrome.current, builder: (context, current, _) { return SegmentedButton( - segments: const [ + segments: [ ButtonSegment( value: ChatChromeStyle.color, - label: Text('Цвет'), + label: Text(l10n.appearanceChatChromeColor), ), ButtonSegment( value: ChatChromeStyle.blur, - label: Text('Блюр'), + label: Text(l10n.appearanceChatChromeBlur), ), ButtonSegment( value: ChatChromeStyle.none, - label: Text('Нет'), + label: Text(l10n.appearanceChatChromeNone), ), ], selected: {current}, @@ -253,6 +255,7 @@ class _GradientToggleCard extends StatelessWidget { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return GlossyPill( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(28), @@ -267,7 +270,7 @@ class _GradientToggleCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Градиент', + l10n.appearanceGradientTitle, style: TextStyle( color: cs.onSurface, fontSize: 16, @@ -276,7 +279,7 @@ class _GradientToggleCard extends StatelessWidget { ), const SizedBox(height: 2), Text( - 'Объём и блики в Glossy-капсулах', + l10n.appearanceGradientSubtitle, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), ], @@ -361,12 +364,12 @@ class _PreviewSectionState extends State<_PreviewSection> { class _ChatPreview extends StatelessWidget { const _ChatPreview(); - static const _messages = <_PreviewMsg>[ - _PreviewMsg('Привет!', true, true, false), - _PreviewMsg('Как тебе?', true, false, true), - _PreviewMsg('Привет!', false, true, false), - _PreviewMsg('хм...', false, false, false), - _PreviewMsg('Вполне неплохо!', false, false, true), + List<_PreviewMsg> _messagesFor(AppLocalizations l10n) => [ + _PreviewMsg(l10n.appearancePreviewHello, true, true, false), + _PreviewMsg(l10n.appearancePreviewHowIsIt, true, false, true), + _PreviewMsg(l10n.appearancePreviewHello, false, true, false), + _PreviewMsg(l10n.appearancePreviewHmm, false, false, false), + _PreviewMsg(l10n.appearancePreviewNotBad, false, false, true), ]; BorderRadius _radiusFor( @@ -386,6 +389,8 @@ class _ChatPreview extends StatelessWidget { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final messages = _messagesFor(l10n); return ListenableBuilder( listenable: Listenable.merge([ @@ -403,12 +408,12 @@ class _ChatPreview extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - for (var i = 0; i < _messages.length; i++) ...[ - if (i > 0) SizedBox(height: _messages[i].isTop ? 8 : 2), + for (var i = 0; i < messages.length; i++) ...[ + if (i > 0) SizedBox(height: messages[i].isTop ? 8 : 2), _PreviewBubble( - text: _messages[i].text, - isMe: _messages[i].isMe, - radius: _radiusFor(_messages[i], style, behavior), + text: messages[i].text, + isMe: messages[i].isMe, + radius: _radiusFor(messages[i], style, behavior), ), ], ], @@ -481,18 +486,19 @@ class _ColorPickerCard extends StatelessWidget { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return ValueListenableBuilder( valueListenable: isSystem, builder: (context, sys, _) { return ValueListenableBuilder( valueListenable: color, - builder: (context, col, _) => _buildBody(cs, col, sys), + builder: (context, col, _) => _buildBody(cs, l10n, col, sys), ); }, ); } - Widget _buildBody(ColorScheme cs, Color col, bool sys) { + Widget _buildBody(ColorScheme cs, AppLocalizations l10n, Color col, bool sys) { final swatchColor = sys ? cs.primary : col; return GlossyPill( @@ -524,7 +530,7 @@ class _ColorPickerCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Акцентный цвет', + l10n.appearanceAccentColorTitle, style: TextStyle( color: cs.onSurface, fontSize: 16, @@ -534,8 +540,8 @@ class _ColorPickerCard extends StatelessWidget { const SizedBox(height: 2), Text( sys - ? 'Системный' - : 'Основной цвет интерфейса и пузырей', + ? l10n.appearanceAccentColorSystem + : l10n.appearanceAccentColorSubtitle, style: TextStyle( color: cs.onSurfaceVariant, fontSize: 13, @@ -592,8 +598,8 @@ class _ColorPickerCard extends StatelessWidget { const SizedBox(width: 8), Text( sys - ? 'Системный цвет активен' - : 'Сбросить на системный', + ? l10n.appearanceAccentColorSystemActive + : l10n.appearanceAccentColorReset, ), ], ), @@ -618,6 +624,7 @@ class _BubbleShapeCard extends StatelessWidget { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return GlossyPill( color: cs.surfaceContainerHigh, @@ -628,7 +635,7 @@ class _BubbleShapeCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Форма сообщения', + l10n.appearanceBubbleShapeTitle, style: TextStyle( color: cs.onSurface, fontSize: 16, @@ -637,7 +644,7 @@ class _BubbleShapeCard extends StatelessWidget { ), const SizedBox(height: 4), Text( - 'Скругление углов пузырей', + l10n.appearanceBubbleShapeSubtitle, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 16), @@ -645,16 +652,16 @@ class _BubbleShapeCard extends StatelessWidget { valueListenable: AppBubbleShape.current, builder: (context, current, _) { return SegmentedButton( - segments: const [ + segments: [ ButtonSegment( value: BubbleStyle.mobile, - label: Text('TG Mobile'), - icon: Icon(Symbols.smartphone), + label: Text(l10n.appearanceBubbleShapeMobile), + icon: const Icon(Symbols.smartphone), ), ButtonSegment( value: BubbleStyle.desktop, - label: Text('TG Desktop'), - icon: Icon(Symbols.desktop_windows), + label: Text(l10n.appearanceBubbleShapeDesktop), + icon: const Icon(Symbols.desktop_windows), ), ], selected: {current}, @@ -678,6 +685,7 @@ class _BubbleBehaviorCard extends StatelessWidget { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return GlossyPill( color: cs.surfaceContainerHigh, @@ -688,7 +696,7 @@ class _BubbleBehaviorCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Поведение сообщения', + l10n.appearanceBubbleBehaviorTitle, style: TextStyle( color: cs.onSurface, fontSize: 16, @@ -697,7 +705,7 @@ class _BubbleBehaviorCard extends StatelessWidget { ), const SizedBox(height: 4), Text( - 'Меняется ли форма пузыря по соседям в группе', + l10n.appearanceBubbleBehaviorSubtitle, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 16), @@ -705,16 +713,16 @@ class _BubbleBehaviorCard extends StatelessWidget { valueListenable: AppBubbleBehavior.current, builder: (context, current, _) { return SegmentedButton( - segments: const [ + segments: [ ButtonSegment( value: BubbleBehavior.mutable, - label: Text('Изменяемая'), - icon: Icon(Symbols.auto_fix), + label: Text(l10n.appearanceBubbleBehaviorMutable), + icon: const Icon(Symbols.auto_fix), ), ButtonSegment( value: BubbleBehavior.immutable, - label: Text('Неизменяемая'), - icon: Icon(Symbols.lock), + label: Text(l10n.appearanceBubbleBehaviorImmutable), + icon: const Icon(Symbols.lock), ), ], selected: {current}, diff --git a/lib/frontend/screens/profile/cloud_storage_screen.dart b/lib/frontend/screens/profile/cloud_storage_screen.dart index 1b4568a..4016087 100644 --- a/lib/frontend/screens/profile/cloud_storage_screen.dart +++ b/lib/frontend/screens/profile/cloud_storage_screen.dart @@ -12,6 +12,7 @@ import '../../../backend/modules/cloud_storage.dart'; import '../../../backend/modules/upload_manager.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/utils/format.dart'; +import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; @@ -85,7 +86,10 @@ class _CloudStorageScreenState extends State if (!mounted) return; _uploadProgress.value = 0; setState(() => _isUploading = false); - showCustomNotification(context, 'Ошибка: $msg'); + showCustomNotification( + context, + AppLocalizations.of(context)!.devicesGenericError(msg), + ); }; } @@ -111,7 +115,7 @@ class _CloudStorageScreenState extends State final cachedId = await CloudStorageModule.getCachedEnvGroupId(profile.id); if (cachedId != null) { - final rows = await ChatsModule.getChat(profile.id, cachedId); + final rows = await chats.getChat(profile.id, cachedId); if (rows.isNotEmpty && CloudStorageModule.isCloudStorageGroup(rows.first)) { if (!mounted) return; @@ -127,9 +131,9 @@ class _CloudStorageScreenState extends State await CloudStorageModule.clearEnvGroupCache(profile.id); } - final chats = await ChatsModule.getChats(profile.id); - CachedChat? envGroup = CloudStorageModule.findEnvGroup(chats); - final orphans = CloudStorageModule.findOrphanGroups(chats); + final cachedChats = await chats.getChats(profile.id); + CachedChat? envGroup = CloudStorageModule.findEnvGroup(cachedChats); + final orphans = CloudStorageModule.findOrphanGroups(cachedChats); if (envGroup == null && orphans.isNotEmpty) { final repaired = await CloudStorageModule.repairOrphan( @@ -160,8 +164,8 @@ class _CloudStorageScreenState extends State } void _handleOrphansBackground(int accountId) async { - final chats = await ChatsModule.getChats(accountId); - for (final orphan in CloudStorageModule.findOrphanGroups(chats)) { + final cachedChats = await chats.getChats(accountId); + for (final orphan in CloudStorageModule.findOrphanGroups(cachedChats)) { _deleteOrLeave(accountId, orphan); } } @@ -169,14 +173,14 @@ class _CloudStorageScreenState extends State void _deleteOrLeave(int accountId, CachedChat chat) async { final isAdmin = chat.owner == accountId || chat.admins.contains(accountId); if (isAdmin) { - await ChatsModule.deleteChat( + await chats.deleteChat( api, chatId: chat.id, lastEventTime: chat.lastEventTime, forAll: true, ); } else { - await ChatsModule.leaveChat(api, chatId: chat.id); + await chats.leaveChat(api, chatId: chat.id); } } @@ -211,7 +215,10 @@ class _CloudStorageScreenState extends State final profile = await AppDatabase.loadActiveProfile(); if (!mounted) return; if (profile == null) { - showCustomNotification(context, 'Нет активного профиля'); + showCustomNotification( + context, + AppLocalizations.of(context)!.cloudStorageNoActiveProfile, + ); return; } setState(() => _isCreatingEnv = true); @@ -219,7 +226,10 @@ class _CloudStorageScreenState extends State if (!mounted) return; if (result == null) { setState(() => _isCreatingEnv = false); - showCustomNotification(context, 'Не удалось создать среду'); + showCustomNotification( + context, + AppLocalizations.of(context)!.cloudStorageSetupFailed, + ); return; } await CloudStorageModule.cacheEnvGroupId(profile.id, result.id); @@ -306,6 +316,7 @@ class _CloudStorageScreenState extends State @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return Scaffold( backgroundColor: cs.surface, @@ -317,7 +328,7 @@ class _CloudStorageScreenState extends State onPressed: _onBack, ), title: ConnectionTitleText( - 'Облачное хранилище', + l10n.cloudStorageTitle, style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600), ), ), @@ -330,6 +341,7 @@ class _CloudStorageScreenState extends State } Widget _buildNotConfigured(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: _horizontalPadding), @@ -337,7 +349,7 @@ class _CloudStorageScreenState extends State mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - 'Среда для облачного хранилища не настроена', + l10n.cloudStorageNotConfiguredTitle, textAlign: TextAlign.center, style: TextStyle( color: cs.onSurface, @@ -347,7 +359,7 @@ class _CloudStorageScreenState extends State ), const SizedBox(height: 6), Text( - 'Начнем? Это быстро.', + l10n.cloudStorageNotConfiguredSubtitle, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), const SizedBox(height: 24), @@ -371,9 +383,9 @@ class _CloudStorageScreenState extends State color: cs.onPrimary, ), ) - : const Text( - 'Начать', - style: TextStyle( + : Text( + l10n.cloudStorageStart, + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, ), @@ -432,6 +444,7 @@ class _CloudStorageScreenState extends State double t, double availableWidth, ) { + final l10n = AppLocalizations.of(context)!; final cardSide = availableWidth * _cardViewportFraction; return Center( child: Opacity( @@ -498,7 +511,9 @@ class _CloudStorageScreenState extends State ), const SizedBox(height: 8), Text( - 'Загрузка ${(progress * 100).toStringAsFixed(0)}%', + l10n.cloudStorageUploadingPercent( + (progress * 100).toStringAsFixed(0), + ), style: TextStyle( color: cs.onSurfaceVariant, fontSize: 13, @@ -509,7 +524,7 @@ class _CloudStorageScreenState extends State ), ] else if (_files.isEmpty) ...[ Text( - 'Начните загрузку для прогресс-бара', + l10n.cloudStorageStartUploadHint, textAlign: TextAlign.center, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), @@ -522,6 +537,7 @@ class _CloudStorageScreenState extends State } Widget _buildEmptyState(ColorScheme cs, double t, double availableHeight) { + final l10n = AppLocalizations.of(context)!; return Transform.translate( offset: Offset(0, -t * availableHeight * _translateFactor), child: Padding( @@ -530,7 +546,7 @@ class _CloudStorageScreenState extends State mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - 'Облачных файлов пока нет...', + l10n.cloudStorageEmptyTitle, textAlign: TextAlign.center, style: TextStyle( color: cs.onSurface, @@ -540,7 +556,7 @@ class _CloudStorageScreenState extends State ), const SizedBox(height: 6), Text( - 'Добавите?', + l10n.cloudStorageEmptySubtitle, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), const SizedBox(height: 24), @@ -557,9 +573,12 @@ class _CloudStorageScreenState extends State borderRadius: BorderRadius.circular(14), ), ), - child: const Text( - 'Загрузить', - style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + child: Text( + l10n.cloudStorageUpload, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), ), ), ], @@ -569,6 +588,7 @@ class _CloudStorageScreenState extends State } List _buildCornerActions(ColorScheme cs, double t) { + final l10n = AppLocalizations.of(context)!; final slide = (1 - t) * _cornerSlideAmount; return [ Positioned( @@ -578,7 +598,7 @@ class _CloudStorageScreenState extends State opacity: t, child: _CornerAction( icon: Symbols.upload_file, - label: 'С файла', + label: l10n.cloudStorageFromFile, onTap: _pickAndUploadFile, ), ), @@ -590,7 +610,7 @@ class _CloudStorageScreenState extends State opacity: t, child: _CornerAction( icon: Symbols.tag, - label: 'По ID', + label: l10n.cloudStorageById, onTap: _showSendByIdSheet, ), ), @@ -969,6 +989,7 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; final f = widget.file; final isExpired = _link == null || @@ -1000,9 +1021,15 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> { ), ), const SizedBox(height: 12), - _InfoRow(label: 'ID файла', value: f.fileId?.toString() ?? '—'), + _InfoRow( + label: l10n.cloudStorageFileIdLabel, + value: f.fileId?.toString() ?? '—', + ), const SizedBox(height: 6), - _InfoRow(label: 'Размер', value: _formatSize(f.size)), + _InfoRow( + label: l10n.cloudStorageSizeLabel, + value: _formatSize(f.size), + ), const SizedBox(height: 20), Container(height: 0.5, color: cs.outlineVariant), const SizedBox(height: 16), @@ -1011,11 +1038,13 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> { Expanded( child: isExpired ? Text( - 'Ссылки пока нет. Создайте.', + l10n.cloudStorageNoLinkYet, style: TextStyle(color: cs.error, fontSize: 13), ) : Text( - 'Ссылка истечет ${_formatExpiry(_link!.expires)}', + l10n.cloudStorageLinkExpiresIn( + _formatExpiry(_link!.expires), + ), style: TextStyle( color: cs.onSurfaceVariant, fontSize: 13, @@ -1048,7 +1077,7 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> { ); showCustomNotification( context, - 'Ссылка скопирована', + l10n.cloudStorageLinkCopied, ); }, ), @@ -1111,7 +1140,10 @@ class _SendByIdSheetState extends State<_SendByIdSheet> { Future _submit() async { final id = int.tryParse(_controller.text.trim()); if (id == null) { - showCustomNotification(context, 'Неверный ID'); + showCustomNotification( + context, + AppLocalizations.of(context)!.cloudStorageInvalidId, + ); return; } setState(() => _sending = true); @@ -1121,13 +1153,17 @@ class _SendByIdSheetState extends State<_SendByIdSheet> { Navigator.pop(context); } else { setState(() => _sending = false); - showCustomNotification(context, 'Ошибка отправки'); + showCustomNotification( + context, + AppLocalizations.of(context)!.cloudStorageSendError, + ); } } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return Container( decoration: BoxDecoration( color: cs.surface, @@ -1146,7 +1182,7 @@ class _SendByIdSheetState extends State<_SendByIdSheet> { const Center(child: SheetGrabber(margin: EdgeInsets.zero)), const SizedBox(height: 20), Text( - 'Отправить по ID', + l10n.cloudStorageSendByIdTitle, style: TextStyle( color: cs.onSurface, fontSize: 16, @@ -1193,9 +1229,9 @@ class _SendByIdSheetState extends State<_SendByIdSheet> { color: cs.onPrimary, ), ) - : const Text( - 'Отправить', - style: TextStyle(fontWeight: FontWeight.w600), + : Text( + l10n.cloudStorageSend, + style: const TextStyle(fontWeight: FontWeight.w600), ), ), ], diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index ec9ba11..d48cd57 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -1,22 +1,14 @@ import 'dart:convert'; import 'dart:io'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/chats.dart'; -import '../../../core/config/app_swipe_back_desktop.dart'; -import '../../../core/config/app_pranks.dart'; -import '../../../core/config/app_stories.dart'; -import '../../../core/config/app_commands.dart'; -import '../../../core/config/app_link_preview.dart'; -import '../../../core/config/app_show_extra_info.dart'; -import '../../../core/config/app_digital_id_mode.dart'; +import '../../../core/calls/call_controller.dart'; import '../../../core/config/app_media_cache.dart'; import '../../../core/protocol/opcode_map.dart'; -import '../../../core/storage/app_database.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/transport/traffic_monitor.dart'; import '../../../core/utils/debug_session_log.dart'; @@ -24,16 +16,17 @@ import '../../../core/utils/format.dart'; import '../../../core/utils/logger.dart'; import '../../../core/utils/media_cache.dart'; import '../../../main.dart'; +import '../../debug/cache_section.dart'; +import '../../debug/feature_toggles_section.dart'; +import '../../debug/header_section.dart'; +import '../../debug/id_search_section.dart'; +import '../../debug/network_section.dart'; +import '../../debug/previews_section.dart'; +import '../../debug/quick_actions_section.dart'; +import '../../debug/sync_probe_section.dart'; import '../../widgets/custom_notification.dart'; -import '../../widgets/glossy_pill.dart'; -import '../../widgets/sheet_helpers.dart'; -import '../../widgets/login_success_screen.dart'; -import '../auth/login_screen.dart'; -import '../calls/call_screen.dart'; -import '../../../core/calls/call_controller.dart'; import '../../widgets/connection_status.dart'; -import '../digital_id/digital_id_web_screen.dart'; -import 'traffic_monitor_screen.dart'; +import '../../widgets/sheet_helpers.dart'; class DebugMenuScreen extends StatefulWidget { const DebugMenuScreen({super.key}); @@ -46,7 +39,7 @@ class _DebugMenuScreenState extends State { final _idController = TextEditingController(); bool _isSearching = false; bool _hasSearched = false; - final List<_SearchHit> _hits = []; + final List _hits = []; final Map _errors = {}; int _cacheSize = 0; bool _clearingCache = false; @@ -84,7 +77,7 @@ class _DebugMenuScreenState extends State { return; } final bytes = Uint8List.fromList(utf8.encode(content)); - final fileName = 'komet_debug_${_fileStamp(DateTime.now())}.txt'; + final fileName = 'komet_debug_${formatFileStamp(DateTime.now())}.txt'; final isMobile = Platform.isAndroid || Platform.isIOS; try { final path = await FilePicker.platform.saveFile( @@ -107,12 +100,6 @@ class _DebugMenuScreenState extends State { } } - String _fileStamp(DateTime t) { - String two(int n) => n.toString().padLeft(2, '0'); - return '${t.year}${two(t.month)}${two(t.day)}_' - '${two(t.hour)}${two(t.minute)}${two(t.second)}'; - } - Future _clearCache() async { if (_clearingCache) return; setState(() => _clearingCache = true); @@ -219,7 +206,7 @@ class _DebugMenuScreenState extends State { }); return p.payload; }), - tryProbe('publicSearch', () => ChatsModule.searchById(api, id)), + tryProbe('publicSearch', () => chats.searchById(api, id)), ]); if (!mounted) return; @@ -231,7 +218,7 @@ class _DebugMenuScreenState extends State { if (contacts is List) { for (final c in contacts) { if (c is Map) { - final hit = _SearchHit.fromContact(source, c); + final hit = SearchHit.fromContact(source, c); if (hit != null) _hits.add(hit); } } @@ -240,7 +227,7 @@ class _DebugMenuScreenState extends State { if (chats is List) { for (final c in chats) { if (c is Map) { - final hit = _SearchHit.fromChat(source, c); + final hit = SearchHit.fromChat(source, c); if (hit != null) _hits.add(hit); } } @@ -261,1309 +248,49 @@ class _DebugMenuScreenState extends State { child: CustomScrollView( physics: const BouncingScrollPhysics(), slivers: [ - SliverToBoxAdapter( + const SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), - child: Row( - children: [ - IconButton( - icon: Icon( - Symbols.arrow_back, - color: cs.onSurface, - size: 24, - weight: 400, - ), - onPressed: () => Navigator.pop(context), - ), - const SizedBox(width: 4), - Expanded( - child: Text( - 'Для разработчиков', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w700, - fontFamily: 'Outfit', - ), - ), - ), - ], - ), + padding: EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: DebugHeaderSection(), ), ), SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: Material( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - child: InkWell( - borderRadius: BorderRadius.circular(20), - onTap: _exportDebugLog, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 17, - ), - child: Row( - children: [ - Icon( - Symbols.bug_report, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Отладочный лог', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - 'Все запросы за последние 3 захода в приложение', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ), - Icon( - Symbols.save_alt, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - ], - ), - ), - ), - ), + child: DebugQuickActionsSection(onExportLog: _exportDebugLog), + ), + SliverToBoxAdapter(child: DebugNetworkSection(appState: appState)), + const SliverToBoxAdapter(child: DebugFeatureTogglesSection()), + SliverToBoxAdapter( + child: DebugCacheSection( + cacheSize: _cacheSize, + clearingCache: _clearingCache, + cacheLimitLabel: _limitLabel(AppMediaCacheLimit.current.value), + onPickCacheLimit: _pickCacheLimit, + onClearCache: _clearCache, + ), + ), + SliverToBoxAdapter( + child: DebugPreviewsSection( + micSignalOn: _micSignalOn, + onMicSignalChanged: _sendMicSignal, ), ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: Material( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - child: InkWell( - borderRadius: BorderRadius.circular(20), - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const LoginScreen()), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 17, - ), - child: Row( - children: [ - Icon( - Symbols.dialpad, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Экран ввода номера', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - 'Открыть без выхода из аккаунта и обрыва сессии', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ), - Icon( - Symbols.chevron_right, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - ], - ), - ), - ), - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: appState == null - ? const SizedBox.shrink() - : ValueListenableBuilder( - valueListenable: appState.fpsOverlayEnabled, - builder: (context, fpsOn, _) { - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - depth: 6, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 17, - ), - child: Row( - children: [ - Icon( - Symbols.speed, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - 'Оверлей FPS', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - 'Показ текущего фреймрейта поверх интерфейса', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ), - Switch( - value: fpsOn, - onChanged: (v) { - appState.setFpsOverlayEnabled(v); - }, - ), - ], - ), - ); - }, - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: appState == null - ? const SizedBox.shrink() - : ValueListenableBuilder( - valueListenable: appState.vpnBypassEnabled, - builder: (context, bypassOn, _) { - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - depth: 6, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 17, - ), - child: Row( - children: [ - Icon( - Symbols.vpn_key_off, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - 'Обход VPN', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - 'Если обнаружен VPN (tun-интерфейс), ' - 'подключаться напрямую через Wi-Fi или ' - 'моб. сеть в обход туннеля. Только Android', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ), - Switch( - value: bypassOn, - onChanged: (v) { - appState.setVpnBypassEnabled(v); - }, - ), - ], - ), - ); - }, - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: ValueListenableBuilder( - valueListenable: debugForceOffline, - builder: (context, offline, _) { - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - depth: 6, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 17, - ), - child: Row( - children: [ - Icon( - Symbols.wifi_off, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Офлайн (тест)', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - 'Показать индикаторы соединения во всех ' - 'экранах, не разрывая реальную сессию', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ), - Switch( - value: offline, - onChanged: (v) => debugForceOffline.value = v, - ), - ], - ), - ); - }, - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: appState == null - ? const SizedBox.shrink() - : ValueListenableBuilder( - valueListenable: appState.tlsInsecureEnabled, - builder: (context, insecureOn, _) { - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - depth: 6, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 17, - ), - child: Row( - children: [ - Icon( - Symbols.gpp_bad, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - 'Отключить проверку TLS', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - 'Принимать любой сертификат сервера. ' - 'Только для отладки через MitM-прокси — ' - 'соединение становится уязвимым к ' - 'перехвату трафика', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ), - Switch( - value: insecureOn, - onChanged: (v) { - appState.setTlsInsecureEnabled(v); - }, - ), - ], - ), - ); - }, - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: Material( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - child: InkWell( - borderRadius: BorderRadius.circular(20), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const TrafficMonitorScreen(), - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 17, - ), - child: Row( - children: [ - Icon( - Symbols.lan, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Монитор трафика', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - 'Реалтайм: домены, опкоды и payload внутри ' - 'сокет-соединения', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ), - Icon( - Symbols.chevron_right, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - ], - ), - ), - ), - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: ValueListenableBuilder( - valueListenable: AppSwipeBackDesktop.current, - builder: (context, swipeOn, _) { - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - depth: 6, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 17, - ), - child: Row( - children: [ - Icon( - Symbols.swipe_right, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Свайп-назад в десктоп-режиме', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - 'Включает жест «провести от левого края, чтобы ' - 'закрыть» внутри встроенной панели чата на ' - 'десктопе — для тестирования курсором', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ), - Switch( - value: swipeOn, - onChanged: (v) { - AppSwipeBackDesktop.save(v); - }, - ), - ], - ), - ); - }, - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: ValueListenableBuilder( - valueListenable: AppPranks.current, - builder: (context, pranksOn, _) { - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - depth: 6, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 17, - ), - child: Row( - children: [ - Icon( - Symbols.auto_awesome, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Приколь4ики', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - Switch( - value: pranksOn, - onChanged: (v) { - AppPranks.save(v); - }, - ), - ], - ), - ); - }, - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: ValueListenableBuilder( - valueListenable: AppDigitalIdNative.current, - builder: (context, native, _) { - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - depth: 6, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 17, - ), - child: Row( - children: [ - Icon( - Symbols.badge, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Нативный Цифровой ID', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - native - ? 'Нативный экран (REST ext-api.max.ru)' - : 'Оригинальная страница в WebView', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ), - Switch( - value: native, - onChanged: (v) { - AppDigitalIdNative.save(v); - }, - ), - ], - ), - ); - }, - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: Material( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - child: InkWell( - borderRadius: BorderRadius.circular(20), - onTap: () async { - await resetDigitalIdWebData(); - if (!context.mounted) return; - showCustomNotification( - context, - 'Цифровой ID сброшен — Госуслуги спросят вход заново', - ); - }, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 17, - ), - child: Row( - children: [ - Icon( - Symbols.restart_alt, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Сбросить Цифровой ID', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - 'Очистить куки и данные WebView', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: ValueListenableBuilder( - valueListenable: AppStories.current, - builder: (context, storiesOn, _) { - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - depth: 6, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 17, - ), - child: Row( - children: [ - Icon( - Symbols.amp_stories, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Истории', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - 'Отображение ленты историй в списке чатов', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ), - Switch( - value: storiesOn, - onChanged: (v) { - AppStories.save(v); - }, - ), - ], - ), - ); - }, - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: ValueListenableBuilder( - valueListenable: AppCommands.current, - builder: (context, commandsOn, _) { - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - depth: 6, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 17, - ), - child: Row( - children: [ - Icon( - Symbols.terminal, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Команды', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - 'Панель команд по вводу «/» в строке сообщения', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ), - Switch( - value: commandsOn, - onChanged: (v) { - AppCommands.save(v); - }, - ), - ], - ), - ); - }, - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: ValueListenableBuilder( - valueListenable: AppLinkPreview.current, - builder: (context, linkPreviewOn, _) { - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - depth: 6, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 17, - ), - child: Row( - children: [ - Icon( - Symbols.link, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Предпросмотр ссылок', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - 'Карточки с превью для ссылок в сообщениях', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ), - Switch( - value: linkPreviewOn, - onChanged: (v) { - AppLinkPreview.save(v); - }, - ), - ], - ), - ); - }, - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: ValueListenableBuilder( - valueListenable: AppShowExtraInfo.current, - builder: (context, extraInfoOn, _) { - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - depth: 6, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 17, - ), - child: Row( - children: [ - Icon( - Symbols.info, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Доп. информация', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - 'Раздел «Info» в настройках и вкладка с ' - 'технической информацией в профиле собеседника', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ), - Switch( - value: extraInfoOn, - onChanged: (v) { - AppShowExtraInfo.save(v); - }, - ), - ], - ), - ); - }, - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: Material( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - child: InkWell( - borderRadius: BorderRadius.circular(20), - onTap: _pickCacheLimit, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 17, - ), - child: Row( - children: [ - Icon( - Symbols.data_usage, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Лимит кэша медиа', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - _limitLabel(AppMediaCacheLimit.current.value), - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ), - Icon( - Symbols.chevron_right, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - ], - ), - ), - ), - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: Material( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - child: InkWell( - borderRadius: BorderRadius.circular(20), - onTap: _clearingCache ? null : _clearCache, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 17, - ), - child: Row( - children: [ - Icon( - Symbols.delete_sweep, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Очистить кэш медиа', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - _clearingCache - ? 'Очистка…' - : 'Занято: ${formatBytes(_cacheSize)}', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ), - if (_clearingCache) - SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: Material( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - child: InkWell( - borderRadius: BorderRadius.circular(20), - onTap: () async { - final profile = await AppDatabase.loadActiveProfile(); - if (!context.mounted) return; - final avatar = await precacheLoginAvatar( - context, - profile?.baseUrl, - ); - if (!context.mounted) return; - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => - LoginSuccessScreen(preview: true, avatar: avatar), - ), - ); - }, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 17, - ), - child: Row( - children: [ - Icon( - Symbols.celebration, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'test hello', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - 'Показать приветственную анимацию входа', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ), - Icon( - Symbols.chevron_right, - color: cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - ], - ), - ), - ), - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - depth: 6, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Экран звонка', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 4), - Text( - 'Превью экранов звонков', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - const SizedBox(height: 12), - _DebugCallButton( - label: 'Экран звонка (превью)', - icon: Symbols.phone, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const CallScreen(name: 'Кирил Г.'), - ), - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Сигнал микрофона (тест)', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - 'Шлёт change-media-settings в активный звонок, ' - 'не меняя реальный микрофон', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ), - Switch( - value: _micSignalOn, - onChanged: _sendMicSignal, - ), - ], - ), - ], - ), - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - depth: 6, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Поиск по ID', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 4), - Text( - 'Параллельно: contactInfo (32) + chatInfo (48) + publicSearch (60)', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 12, - ), - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: TextField( - controller: _idController, - keyboardType: TextInputType.number, - decoration: InputDecoration( - hintText: 'Введите ID', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - onSubmitted: (_) => _search(), - ), - ), - const SizedBox(width: 12), - FilledButton( - onPressed: _isSearching ? null : _search, - child: _isSearching - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon(Symbols.search, size: 20), - ), - ], - ), - if (_hasSearched && !_isSearching) ...[ - const SizedBox(height: 12), - if (_hits.isEmpty && _errors.isEmpty) - Padding( - padding: const EdgeInsets.all(12), - child: Text( - 'Ничего не найдено', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ), - for (final hit in _hits) ...[ - _SearchResultCard(hit: hit), - const SizedBox(height: 8), - ], - for (final entry in _errors.entries) ...[ - _ErrorChip(label: entry.key, message: entry.value), - const SizedBox(height: 6), - ], - ], - ], - ), + child: DebugIdSearchSection( + idController: _idController, + isSearching: _isSearching, + hasSearched: _hasSearched, + hits: _hits, + errors: _errors, + onSearch: _search, ), ), ), const SliverToBoxAdapter( child: Padding( padding: EdgeInsets.fromLTRB(16, 12, 16, 0), - child: _SyncProbeCard(), + child: DebugSyncProbeSection(), ), ), const SliverToBoxAdapter(child: SizedBox(height: 120)), @@ -1573,542 +300,3 @@ class _DebugMenuScreenState extends State { ); } } - -class _SyncProbeCard extends StatefulWidget { - const _SyncProbeCard(); - - @override - State<_SyncProbeCard> createState() => _SyncProbeCardState(); -} - -class _SyncProbeCardState extends State<_SyncProbeCard> { - final _phoneController = TextEditingController(); - final _nameController = TextEditingController(); - bool _loading = false; - String? _result; - - @override - void dispose() { - _phoneController.dispose(); - _nameController.dispose(); - super.dispose(); - } - - Future _send() async { - final phone = _phoneController.text.trim(); - final name = _nameController.text.trim(); - if (phone.isEmpty) { - setState(() => _result = 'Введите номер'); - return; - } - setState(() { - _loading = true; - _result = null; - }); - try { - final packet = await api.sendRequest(Opcode.sync, { - 'contactList': { - phone: {'firstName': name}, - }, - }); - if (!mounted) return; - setState(() { - _loading = false; - _result = _pretty(packet.payload); - }); - } on PacketError catch (e) { - if (!mounted) return; - setState(() { - _loading = false; - _result = 'PacketError: ${e.message}'; - }); - } catch (e) { - if (!mounted) return; - setState(() { - _loading = false; - _result = 'Ошибка: $e'; - }); - } - } - - String _pretty(dynamic payload) { - const encoder = JsonEncoder.withIndent(' '); - try { - return encoder.convert(_jsonSafe(payload)); - } catch (_) { - return payload.toString(); - } - } - - dynamic _jsonSafe(dynamic v) { - if (v is Map) { - return v.map((k, val) => MapEntry(k.toString(), _jsonSafe(val))); - } - if (v is List) return v.map(_jsonSafe).toList(); - if (v is String || v is num || v is bool || v == null) return v; - return v.toString(); - } - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - depth: 6, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Sync contactList (21)', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 4), - Text( - 'Резолв контакта по номеру и имени, полный ответ сервера', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), - ), - const SizedBox(height: 12), - TextField( - controller: _phoneController, - keyboardType: TextInputType.phone, - enabled: !_loading, - decoration: InputDecoration( - hintText: '+6282233831826', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 12, - ), - ), - ), - const SizedBox(height: 10), - TextField( - controller: _nameController, - enabled: !_loading, - decoration: InputDecoration( - hintText: 'Имя', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 12, - ), - ), - ), - const SizedBox(height: 12), - FilledButton( - onPressed: _loading ? null : _send, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(44), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: _loading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Отправить'), - ), - if (_result != null) ...[ - const SizedBox(height: 12), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - ), - child: SelectableText( - _result!, - style: TextStyle( - color: cs.onSurface, - fontSize: 12, - fontFamily: 'monospace', - ), - ), - ), - ], - ], - ), - ); - } -} - -enum _HitKind { dialog, chat, channel, bot, official, contact, user, unknown } - -class _SearchHit { - final String source; - final int id; - final String title; - final String? subtitle; - final String? avatarUrl; - final List<_HitKind> badges; - final bool isChatEntity; - - _SearchHit({ - required this.source, - required this.id, - required this.title, - required this.avatarUrl, - required this.badges, - required this.isChatEntity, - this.subtitle, - }); - - static _SearchHit? fromContact(String source, Map raw) { - final id = raw['id']; - if (id is! int) return null; - final namesRaw = raw['names']; - String title = 'User #$id'; - if (namesRaw is List && namesRaw.isNotEmpty) { - final n = namesRaw.first; - if (n is Map) { - final full = n['name']?.toString(); - if (full != null && full.isNotEmpty) title = full; - } - } - final opts = (raw['options'] is List) - ? (raw['options'] as List).whereType().toSet() - : {}; - final badges = <_HitKind>[]; - if (opts.contains('BOT')) badges.add(_HitKind.bot); - if (opts.contains('OFFICIAL')) badges.add(_HitKind.official); - if (badges.isEmpty) badges.add(_HitKind.contact); - return _SearchHit( - source: source, - id: id, - title: title, - subtitle: (raw['description'] as String?)?.trim().isNotEmpty == true - ? raw['description'] as String - : (raw['phone'] != null ? 'Телефон скрыт' : null), - avatarUrl: raw['baseUrl'] as String?, - badges: badges, - isChatEntity: false, - ); - } - - static _SearchHit? fromChat(String source, Map raw) { - final id = raw['id']; - if (id is! int) return null; - final type = (raw['type'] as String?) ?? 'CHAT'; - final title = (raw['title'] as String?) ?? 'Chat #$id'; - final pCount = raw['participantsCount'] as int?; - final badges = <_HitKind>[]; - switch (type) { - case 'DIALOG': - badges.add(_HitKind.dialog); - case 'CHANNEL': - badges.add(_HitKind.channel); - case 'CHAT': - badges.add(_HitKind.chat); - default: - badges.add(_HitKind.unknown); - } - final opts = raw['options']; - if (opts is Map && opts['OFFICIAL'] == true) { - badges.add(_HitKind.official); - } - String? subtitle; - if (type == 'CHANNEL') { - subtitle = pCount != null ? 'Канал · $pCount подписч.' : 'Канал'; - } else if (type == 'CHAT') { - subtitle = pCount != null ? 'Группа · $pCount участн.' : 'Группа'; - } else { - subtitle = 'Диалог'; - } - return _SearchHit( - source: source, - id: id, - title: title, - subtitle: subtitle, - avatarUrl: raw['baseIconUrl'] as String?, - badges: badges, - isChatEntity: true, - ); - } -} - -class _SearchResultCard extends StatelessWidget { - final _SearchHit hit; - const _SearchResultCard({required this.hit}); - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return Container( - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(14), - ), - padding: const EdgeInsets.fromLTRB(12, 10, 8, 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _HitAvatar(hit: hit, cs: cs), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Flexible( - child: Text( - hit.title, - style: TextStyle( - color: cs.onSurface, - fontSize: 15, - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - for (final b in hit.badges) ...[ - const SizedBox(width: 6), - _BadgeChip(kind: b, cs: cs), - ], - ], - ), - if (hit.subtitle != null) ...[ - const SizedBox(height: 2), - Text( - hit.subtitle!, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - const SizedBox(height: 2), - Row( - children: [ - Text( - 'id: ${hit.id}', - style: TextStyle( - color: cs.outline, - fontSize: 11, - fontFamily: 'monospace', - ), - ), - const SizedBox(width: 8), - Text( - 'via ${hit.source}', - style: TextStyle(color: cs.outline, fontSize: 11), - ), - ], - ), - ], - ), - ), - IconButton( - tooltip: 'Скопировать id', - icon: Icon( - Symbols.content_copy, - size: 18, - color: cs.onSurfaceVariant, - ), - onPressed: () async { - await Clipboard.setData(ClipboardData(text: hit.id.toString())); - if (context.mounted) { - showCustomNotification(context, 'id скопирован'); - } - }, - ), - ], - ), - ); - } -} - -class _HitAvatar extends StatelessWidget { - final _SearchHit hit; - final ColorScheme cs; - const _HitAvatar({required this.hit, required this.cs}); - - @override - Widget build(BuildContext context) { - const size = 44.0; - final url = hit.avatarUrl; - if (url != null && url.isNotEmpty) { - return ClipOval( - child: CachedNetworkImage( - imageUrl: url, - width: size, - height: size, - fit: BoxFit.cover, - placeholder: (_, _) => _fallback(), - errorWidget: (_, _, _) => _fallback(), - ), - ); - } - return _fallback(); - } - - Widget _fallback() { - final initial = hit.title.isNotEmpty ? hit.title[0].toUpperCase() : '?'; - return Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: cs.primaryContainer, - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: Text( - initial, - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - ); - } -} - -class _BadgeChip extends StatelessWidget { - final _HitKind kind; - final ColorScheme cs; - const _BadgeChip({required this.kind, required this.cs}); - - @override - Widget build(BuildContext context) { - String label; - Color bg; - Color fg; - switch (kind) { - case _HitKind.bot: - label = 'Bot'; - bg = cs.tertiaryContainer; - fg = cs.onTertiaryContainer; - case _HitKind.official: - label = '✓'; - bg = cs.primary; - fg = cs.onPrimary; - case _HitKind.contact: - label = 'Контакт'; - bg = cs.surface; - fg = cs.onSurfaceVariant; - case _HitKind.user: - label = 'User'; - bg = cs.surface; - fg = cs.onSurfaceVariant; - case _HitKind.dialog: - label = 'Диалог'; - bg = cs.secondaryContainer; - fg = cs.onSecondaryContainer; - case _HitKind.chat: - label = 'Группа'; - bg = cs.secondaryContainer; - fg = cs.onSecondaryContainer; - case _HitKind.channel: - label = 'Канал'; - bg = cs.tertiaryContainer; - fg = cs.onTertiaryContainer; - case _HitKind.unknown: - label = '?'; - bg = cs.surface; - fg = cs.onSurfaceVariant; - } - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - label, - style: TextStyle(color: fg, fontSize: 10, fontWeight: FontWeight.w600), - ), - ); - } -} - -class _ErrorChip extends StatelessWidget { - final String label; - final String message; - const _ErrorChip({required this.label, required this.message}); - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: cs.errorContainer.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(10), - ), - child: Row( - children: [ - Icon(Symbols.error_outline, size: 16, color: cs.onErrorContainer), - const SizedBox(width: 8), - Expanded( - child: Text( - '$label: $message', - style: TextStyle(color: cs.onErrorContainer, fontSize: 12), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - } -} - -class _DebugCallButton extends StatelessWidget { - final String label; - final IconData icon; - final VoidCallback onTap; - - const _DebugCallButton({ - required this.label, - required this.icon, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return Material( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(14), - child: InkWell( - borderRadius: BorderRadius.circular(14), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: cs.onSurfaceVariant, size: 22, fill: 1), - const SizedBox(height: 4), - Text( - label, - style: TextStyle( - color: cs.onSurface, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart index 44a05db..53a15f3 100644 --- a/lib/frontend/screens/profile/devices_screen.dart +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -6,11 +6,14 @@ import 'package:flutter/foundation.dart' import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../core/utils/format.dart'; +import '../../../core/config/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; import '../../../main.dart' show accountModule; import '../../../backend/modules/account.dart' show SessionInfo; import '../../widgets/custom_notification.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/glossy_pill.dart'; +import '../../widgets/prompt_dialog.dart'; import '../../widgets/web_qr_login.dart'; import 'web_qr_scan_screen.dart'; @@ -57,59 +60,23 @@ class _DevicesScreenState extends State } } catch (e) { if (mounted) { - showCustomNotification(context, 'Ошибка загрузки: $e'); + showCustomNotification( + context, + AppLocalizations.of(context)!.devicesLoadFailed(e.toString()), + ); setState(() => _isLoading = false); } } } - Future _showPasteQrDialog() async { - final tec = TextEditingController(); - try { - return await showDialog( - context: context, - builder: (dialogContext) { - final cs = Theme.of(dialogContext).colorScheme; - return AlertDialog( - backgroundColor: cs.surfaceContainerHigh, - title: Text( - 'Ссылка из QR', - style: TextStyle(fontFamily: 'Outfit', - fontWeight: FontWeight.w600, - fontSize: 18, - color: cs.onSurface, - ), - ), - content: TextField( - controller: tec, - decoration: const InputDecoration( - hintText: 'Вставьте содержимое QR-кода', - ), - autofocus: true, - maxLines: 4, - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text( - 'Отмена', - style: TextStyle(color: cs.onSurfaceVariant), - ), - ), - FilledButton( - onPressed: () { - final v = tec.text.trim(); - Navigator.pop(dialogContext, v.isEmpty ? null : v); - }, - child: const Text('Подтвердить'), - ), - ], - ); - }, - ); - } finally { - tec.dispose(); - } + Future _showPasteQrDialog() { + final l10n = AppLocalizations.of(context)!; + return showTextInputDialog( + context, + title: l10n.devicesQrLinkDialogTitle, + hint: l10n.devicesQrLinkDialogHint, + maxLines: 4, + ); } Future _startWebQrAuth() async { @@ -139,12 +106,18 @@ class _DevicesScreenState extends State try { await accountModule.terminateOtherSessions(); if (mounted) { - showCustomNotification(context, 'Все сессии завершены'); + showCustomNotification( + context, + AppLocalizations.of(context)!.devicesAllTerminated, + ); _loadSessions(); } } catch (e) { if (mounted) { - showCustomNotification(context, 'Ошибка: $e'); + showCustomNotification( + context, + AppLocalizations.of(context)!.devicesGenericError(e.toString()), + ); } } } @@ -188,7 +161,10 @@ class _DevicesScreenState extends State } catch (e) { if (mounted) { setState(() => _loadingIps.remove(id)); - showCustomNotification(context, 'Ошибка IP: $e'); + showCustomNotification( + context, + AppLocalizations.of(context)!.devicesIpLookupError(e.toString()), + ); } } finally { client?.close(); @@ -216,6 +192,7 @@ class _DevicesScreenState extends State @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return Scaffold( backgroundColor: cs.surface, @@ -228,8 +205,9 @@ class _DevicesScreenState extends State onPressed: () => Navigator.pop(context), ), title: ConnectionTitleText( - 'Устройства', - style: TextStyle(fontFamily: 'Outfit', + l10n.devicesTitle, + style: TextStyle( + fontFamily: 'Outfit', fontSize: 20, fontWeight: FontWeight.w600, color: cs.onSurface, @@ -253,6 +231,7 @@ class _DevicesScreenState extends State } Widget _buildPromoCard(BuildContext context, ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: GlossyPill( @@ -261,66 +240,69 @@ class _DevicesScreenState extends State padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20), depth: 6, child: Center( - child: Column( - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - shape: BoxShape.circle, - border: Border.all( - color: cs.onSurface.withValues(alpha: 0.1), - width: 1, + child: Column( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + shape: BoxShape.circle, + border: Border.all( + color: cs.onSurface.withValues(alpha: 0.1), + width: 1, + ), + ), + child: Icon(Symbols.devices, color: cs.onSurface, size: 28), + ), + const SizedBox(height: 16), + Text( + l10n.devicesPromoTitle, + style: TextStyle( + fontFamily: 'Outfit', + fontSize: 18, + fontWeight: FontWeight.w700, + color: cs.onSurface, ), ), - child: Icon(Symbols.devices, color: cs.onSurface, size: 28), - ), - const SizedBox(height: 16), - Text( - 'Устройства в KOMET', - style: TextStyle(fontFamily: 'Outfit', - fontSize: 18, - fontWeight: FontWeight.w700, - color: cs.onSurface, - ), - ), - const SizedBox(height: 8), - Text( - 'Кто имеет доступ к вашему аккаунту?', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: cs.onSurfaceVariant.withValues(alpha: 0.7), - height: 1.3, - ), - ), - const SizedBox(height: 20), - FilledButton.icon( - onPressed: _startWebQrAuth, - icon: const Icon(Symbols.qr_code_scanner, size: 22), - label: Text( - 'Сканировать QR', - style: TextStyle(fontFamily: 'Outfit', - fontSize: 15, - fontWeight: FontWeight.w600, + const SizedBox(height: 8), + Text( + l10n.devicesPromoSubtitle, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: cs.onSurfaceVariant.withValues(alpha: 0.7), + height: 1.3, ), ), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 14, + const SizedBox(height: 20), + FilledButton.icon( + onPressed: _startWebQrAuth, + icon: const Icon(Symbols.qr_code_scanner, size: 22), + label: Text( + l10n.devicesScanQrButton, + style: TextStyle( + fontFamily: 'Outfit', + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 14, + ), ), ), - ), - ], - ), + ], + ), ), ), ); } Widget _buildDevicesList(BuildContext context, ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: GlossyPill( @@ -329,54 +311,56 @@ class _DevicesScreenState extends State padding: const EdgeInsets.symmetric(vertical: 8), depth: 6, child: Column( - children: [ - if (_isLoading) - ...List.generate(5, (index) => _buildShimmerItem(cs)) - else - ..._sessions.map( - (session) => _buildDeviceItem( - context, - cs, - id: session.uniqueId, - title: session.client + (session.current ? ' (текущая)' : ''), - platform: session.info, - location: session.location, - status: session.current ? 'В сети' : null, - time: session.current ? null : _formatTime(session.time), - isOnline: session.current, - ), - ), - if (!_isLoading) ...[ - const SizedBox(height: 12), - Divider( - height: 1, - color: cs.onSurface.withValues(alpha: 0.05), - indent: 20, - endIndent: 20, - ), - InkWell( - onTap: _terminateOthers, - borderRadius: const BorderRadius.vertical( - bottom: Radius.circular(24), - ), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - vertical: 20, - horizontal: 20, + children: [ + if (_isLoading) + ...List.generate(5, (index) => _buildShimmerItem(cs)) + else + ..._sessions.map( + (session) => _buildDeviceItem( + context, + cs, + id: session.uniqueId, + title: + session.client + + (session.current ? l10n.devicesCurrentSuffix : ''), + platform: session.info, + location: session.location, + status: session.current ? l10n.devicesOnlineStatus : null, + time: session.current ? null : _formatTime(session.time), + isOnline: session.current, ), - child: Text( - 'Завершить все сессии, кроме текущей', - style: TextStyle( - color: cs.error.withValues(alpha: 0.8), - fontSize: 15, - fontWeight: FontWeight.w600, + ), + if (!_isLoading) ...[ + const SizedBox(height: 12), + Divider( + height: 1, + color: cs.onSurface.withValues(alpha: 0.05), + indent: 20, + endIndent: 20, + ), + InkWell( + onTap: _terminateOthers, + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(24), + ), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + vertical: 20, + horizontal: 20, + ), + child: Text( + l10n.devicesTerminateOthersButton, + style: TextStyle( + color: cs.error.withValues(alpha: 0.8), + fontSize: 15, + fontWeight: FontWeight.w600, + ), ), ), ), - ), + ], ], - ], ), ), ); @@ -454,6 +438,7 @@ class _DevicesScreenState extends State String? time, bool isOnline = false, }) { + final l10n = AppLocalizations.of(context)!; final details = _ipDetails[id]; final isLoading = _loadingIps.contains(id); final isExpanded = _expandedSessions.contains(id); @@ -472,7 +457,8 @@ class _DevicesScreenState extends State children: [ Text( title, - style: TextStyle(fontFamily: 'Outfit', + style: TextStyle( + fontFamily: 'Outfit', fontSize: 16, fontWeight: FontWeight.w700, color: cs.onSurface, @@ -584,66 +570,67 @@ class _DevicesScreenState extends State child: SizedBox( width: double.infinity, child: Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildDetailRow( - cs, - Symbols.location_city, - '${details['city'] ?? 'Unknown'}, ${details['country'] ?? ''}', + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailRow( + cs, + Symbols.location_city, + '${details['city'] ?? 'Unknown'}, ${details['country'] ?? ''}', + ), + _buildDetailRow( + cs, + Symbols.dns, + details['isp'] ?? 'Unknown', + ), + _buildDetailRow( + cs, + Symbols.public, + details['as'] ?? 'Unknown', + ), + if (details['mobile'] == true) + _buildDetailRow( + cs, + Symbols.stay_current_portrait, + l10n.devicesMobileNetworkLabel, + color: Colors.blueAccent, + ), + if (details['proxy'] == true) + _buildDetailRow( + cs, + Symbols.vpn_lock, + l10n.devicesProxyDetectedLabel, + color: Colors.orangeAccent, + ), + _buildDetailRow( + cs, + Symbols.schedule, + details['timezone'] ?? 'Unknown', + ), + ], ), - _buildDetailRow( - cs, - Symbols.dns, - details['isp'] ?? 'Unknown', - ), - _buildDetailRow( - cs, - Symbols.public, - details['as'] ?? 'Unknown', - ), - if (details['mobile'] == true) - _buildDetailRow( - cs, - Symbols.stay_current_portrait, - 'Мобильная сеть', - color: Colors.blueAccent, - ), - if (details['proxy'] == true) - _buildDetailRow( - cs, - Symbols.vpn_lock, - 'Обнаружен прокси/VPN', - color: Colors.orangeAccent, - ), - _buildDetailRow( - cs, - Symbols.schedule, - details['timezone'] ?? 'Unknown', - ), - ], - ), - Positioned( - right: 0, - bottom: 0, - child: InkWell( - onTap: () => - setState(() => _expandedSessions.remove(id)), - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(4), - child: Icon( - Symbols.do_not_disturb_on, - size: 20, - color: cs.onSurfaceVariant.withValues( - alpha: 0.4, + Positioned( + right: 0, + bottom: 0, + child: InkWell( + onTap: () => setState( + () => _expandedSessions.remove(id), + ), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(4), + child: Icon( + Symbols.do_not_disturb_on, + size: 20, + color: cs.onSurfaceVariant.withValues( + alpha: 0.4, + ), + ), ), ), ), - ), - ), - ], + ], ), ), ), @@ -665,11 +652,7 @@ class _DevicesScreenState extends State padding: const EdgeInsets.symmetric(vertical: 2), child: Row( children: [ - Icon( - icon, - size: 14, - color: color ?? cs.onSurfaceVariant.withValues(alpha: 0.6), - ), + Icon(icon, size: 14, color: color ?? cs.mutedText), const SizedBox(width: 8), Expanded( child: Text( diff --git a/lib/frontend/screens/profile/edit_profile_screen.dart b/lib/frontend/screens/profile/edit_profile_screen.dart index 5aa4075..1bc23c6 100644 --- a/lib/frontend/screens/profile/edit_profile_screen.dart +++ b/lib/frontend/screens/profile/edit_profile_screen.dart @@ -7,6 +7,7 @@ import '../../../l10n/app_localizations.dart'; import '../../../main.dart' show accountModule, fileUploader, KometApp; import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/komet_avatar.dart'; class EditProfileScreen extends StatefulWidget { const EditProfileScreen({super.key}); @@ -212,24 +213,11 @@ class _EditProfileScreenState extends State { width: 2.5, ), ), - child: ClipOval( - child: _avatarUrl != null && _avatarUrl!.isNotEmpty - ? Image.network(_avatarUrl!, fit: BoxFit.cover) - : Container( - color: cs.primaryContainer, - alignment: Alignment.center, - child: Text( - _firstNameController.text.isNotEmpty - ? _firstNameController.text[0] - .toUpperCase() - : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 32, - fontWeight: FontWeight.bold, - ), - ), - ), + child: KometAvatar( + name: _firstNameController.text, + imageUrl: _avatarUrl, + size: 88, + fontSize: 32, ), ), Positioned( diff --git a/lib/frontend/screens/profile/font_settings_screen.dart b/lib/frontend/screens/profile/font_settings_screen.dart index 367df42..5e255f8 100644 --- a/lib/frontend/screens/profile/font_settings_screen.dart +++ b/lib/frontend/screens/profile/font_settings_screen.dart @@ -5,10 +5,12 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../core/config/app_fonts.dart'; import '../../../core/config/custom_font_service.dart'; import '../../../core/utils/haptics.dart'; +import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; +import '../../widgets/prompt_dialog.dart'; class FontSettingsScreen extends StatefulWidget { const FontSettingsScreen({super.key}); @@ -43,18 +45,25 @@ class _FontSettingsScreenState extends State { final parsed = AppFonts.familyFromInput(raw); if (parsed == null) { if (mounted) { - showCustomNotification(context, 'Введите ссылку или название шрифта'); + showCustomNotification( + context, + AppLocalizations.of(context)!.fontSettingsInvalidInput, + ); } return; } setState(() => _adding = true); - final family = await CustomFontService.addFamily(parsed); + String? family; + try { + family = await CustomFontService.addFamily(parsed); + } finally { + if (mounted) setState(() => _adding = false); + } if (!mounted) return; - setState(() => _adding = false); if (family == null) { showCustomNotification( context, - 'Шрифт «$parsed» не найден или нет сети', + AppLocalizations.of(context)!.fontSettingsFontNotFound(parsed), ); return; } @@ -62,7 +71,10 @@ class _FontSettingsScreenState extends State { if (!mounted) return; KometApp.stateOf(context)?.applyAppFont(AppFonts.customId(family)); Haptics.success(); - showCustomNotification(context, 'Шрифт «$family» добавлен'); + showCustomNotification( + context, + AppLocalizations.of(context)!.fontSettingsFontAdded(family), + ); } Future _removeFont(String family) async { @@ -73,63 +85,20 @@ class _FontSettingsScreenState extends State { if (app != null && app.fontId == AppFonts.customId(family)) { app.applyAppFont(AppFonts.fallback.id); } - showCustomNotification(context, 'Шрифт «$family» удалён'); + showCustomNotification( + context, + AppLocalizations.of(context)!.fontSettingsFontRemoved(family), + ); } Future _showAddFontDialog() async { - final cs = Theme.of(context).colorScheme; - final controller = TextEditingController(); - final result = await showDialog( - context: context, - builder: (ctx) { - return AlertDialog( - backgroundColor: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(28), - ), - title: const Text('Добавить шрифт'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Вставьте ссылку Google Fonts или название шрифта', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - height: 1.35, - ), - ), - const SizedBox(height: 18), - TextField( - controller: controller, - autofocus: true, - textInputAction: TextInputAction.done, - decoration: InputDecoration( - hintText: 'fonts.google.com/specimen/Roboto', - filled: true, - fillColor: cs.surface, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, - ), - ), - onSubmitted: (v) => Navigator.pop(ctx, v), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Отмена'), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, controller.text), - child: const Text('Добавить'), - ), - ], - ); - }, + final l10n = AppLocalizations.of(context)!; + final result = await showTextInputDialog( + context, + title: l10n.fontSettingsAddFontTitle, + description: l10n.fontSettingsAddFontDescription, + hint: 'fonts.google.com/specimen/Roboto', + confirmLabel: l10n.fontSettingsAddFontConfirm, ); if (result != null) await _addFont(result); } @@ -137,13 +106,14 @@ class _FontSettingsScreenState extends State { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; final app = KometApp.stateOf(context); final currentId = app?.fontId ?? AppFonts.fallback.id; return Scaffold( backgroundColor: cs.surface, appBar: ConnectionTitleBar( - titleText: 'Шрифты', + titleText: l10n.fontSettingsTitle, backgroundColor: cs.surface, ), body: SafeArea( @@ -154,7 +124,10 @@ class _FontSettingsScreenState extends State { children: [ _PreviewCard(fontId: currentId), const SizedBox(height: 28), - const _SectionLabel(icon: Symbols.text_fields, text: 'Шрифт'), + _SectionLabel( + icon: Symbols.text_fields, + text: l10n.fontSettingsSectionFont, + ), const SizedBox(height: 14), for (final font in AppFonts.builtIn) ...[ _FontOption( @@ -181,13 +154,17 @@ class _FontSettingsScreenState extends State { style: ButtonM3EStyle.outlined, size: ButtonM3ESize.md, icon: Icon(_adding ? Symbols.hourglass_top : Symbols.add), - label: Text(_adding ? 'Загрузка…' : 'Добавить шрифт'), + label: Text( + _adding + ? l10n.fontSettingsLoading + : l10n.fontSettingsAddFontTitle, + ), ), ), const SizedBox(height: 30), - const _SectionLabel( + _SectionLabel( icon: Symbols.format_size, - text: 'Размер шрифта', + text: l10n.fontSettingsSectionFontSize, ), const SizedBox(height: 6), if (app != null) @@ -232,7 +209,7 @@ class _PreviewCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'ПРЕДПРОСМОТР', + AppLocalizations.of(context)!.fontSettingsPreviewLabel, style: TextStyle( color: cs.primary, fontSize: 12, @@ -335,7 +312,7 @@ class _FontOption extends StatelessWidget { const SizedBox(width: 8), IconButton( onPressed: onDelete, - tooltip: 'Удалить', + tooltip: AppLocalizations.of(context)!.msgActionsDelete, icon: Icon(Symbols.delete, color: cs.onSurfaceVariant, weight: 500), ), ], @@ -410,7 +387,7 @@ class _FontSizeControl extends StatelessWidget { enabled: !isDefault, style: ButtonM3EStyle.text, size: ButtonM3ESize.sm, - label: const Text('Сбросить'), + label: Text(AppLocalizations.of(context)!.fontSettingsReset), ), ], ), diff --git a/lib/frontend/screens/profile/info_screen.dart b/lib/frontend/screens/profile/info_screen.dart index 518a3aa..b528f40 100644 --- a/lib/frontend/screens/profile/info_screen.dart +++ b/lib/frontend/screens/profile/info_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; +import '../../../core/utils/format.dart'; import '../../../l10n/app_localizations.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; @@ -180,32 +181,32 @@ class _InfoScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), depth: 6, child: Row( - children: [ - Expanded( - flex: 2, - child: Text( - label, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 14, - fontWeight: FontWeight.w400, + children: [ + Expanded( + flex: 2, + child: Text( + label, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + ), ), ), - ), - const SizedBox(width: 12), - Expanded( - flex: 3, - child: Text( - value, - style: TextStyle( - color: cs.onSurface, - fontSize: 14, - fontWeight: FontWeight.w500, + const SizedBox(width: 12), + Expanded( + flex: 3, + child: Text( + value, + style: TextStyle( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.end, ), - textAlign: TextAlign.end, ), - ), - ], + ], ), ), ); @@ -262,8 +263,7 @@ class _InfoScreenState extends State { final weeks = value ~/ 604800; final days = (value % 604800) ~/ 86400; if (weeks > 0) { - return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}' - .trim(); + return '$weeks нед ${days > 0 ? '$days дн' : ''}'.trim(); } final h = value ~/ 3600; final m = (value % 3600) ~/ 60; @@ -276,21 +276,7 @@ class _InfoScreenState extends State { String _formatTs(int ts) { if (ts < 1000000000000) return ts.toString(); final dt = DateTime.fromMillisecondsSinceEpoch(ts); - return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' - '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; - } - - String _w(int n) { - final m = n % 10; - if (m == 1 && n != 11) return 'нед'; - if ((m == 2 || m == 3 || m == 4) && (n < 10 || n > 20)) return 'нед'; - return 'нед'; - } - - String _d(int n) { - final m = n % 10; - if (m == 1 && n != 11) return 'дн'; - if ((m == 2 || m == 3 || m == 4) && (n < 10 || n > 20)) return 'дн'; - return 'дн'; + return '${dt.year}-${pad2(dt.month)}-${pad2(dt.day)} ' + '${pad2(dt.hour)}:${pad2(dt.minute)}:${pad2(dt.second)}'; } } diff --git a/lib/frontend/screens/profile/komet_settings_screen.dart b/lib/frontend/screens/profile/komet_settings_screen.dart index 228bd13..fea5314 100644 --- a/lib/frontend/screens/profile/komet_settings_screen.dart +++ b/lib/frontend/screens/profile/komet_settings_screen.dart @@ -5,8 +5,8 @@ import '../../widgets/connection_status.dart'; import '../../../core/config/komet_settings.dart'; import '../../../main.dart'; -import '../../widgets/glossy_pill.dart'; import '../../widgets/section_header.dart'; +import '../../widgets/settings_card.dart'; class KometSettingsScreen extends StatelessWidget { const KometSettingsScreen({super.key}); @@ -17,7 +17,10 @@ class KometSettingsScreen extends StatelessWidget { return Scaffold( backgroundColor: cs.surface, - appBar: ConnectionTitleBar(titleText: 'Komet', backgroundColor: cs.surface), + appBar: ConnectionTitleBar( + titleText: 'Komet', + backgroundColor: cs.surface, + ), body: SafeArea( top: false, child: ListView( @@ -29,70 +32,82 @@ class KometSettingsScreen extends StatelessWidget { padding: EdgeInsets.fromLTRB(8, 0, 8, 8), fontSize: 14, ), - _card(cs, [ - _toggle( - cs, - icon: Symbols.delete_history, - label: 'View deleted message', - subtitle: 'Показывать удалённые сообщения', - notifier: KometSettings.viewDeleted, - onChanged: KometSettings.setViewDeleted, - ), - _divider(cs), - _toggle( - cs, - icon: Symbols.history_edu, - label: 'View redacted message history', - subtitle: 'Показывать историю у редактированных сообщений', - notifier: KometSettings.viewRedacted, - onChanged: KometSettings.setViewRedacted, - ), - _divider(cs), - _toggle( - cs, - icon: Symbols.schedule, - label: 'View full timestamp', - subtitle: 'Показывать время в секундах у сообщений', - notifier: KometSettings.fullTimestamp, - onChanged: KometSettings.setFullTimestamp, - ), - ]), + SettingsCard( + children: [ + ValueListenableBuilder( + valueListenable: KometSettings.viewDeleted, + builder: (context, value, _) => SettingsToggleTile( + icon: Symbols.delete_history, + label: 'View deleted message', + subtitle: 'Показывать удалённые сообщения', + value: value, + onChanged: KometSettings.setViewDeleted, + ), + ), + ValueListenableBuilder( + valueListenable: KometSettings.viewRedacted, + builder: (context, value, _) => SettingsToggleTile( + icon: Symbols.history_edu, + label: 'View redacted message history', + subtitle: 'Показывать историю у редактированных сообщений', + value: value, + onChanged: KometSettings.setViewRedacted, + ), + ), + ValueListenableBuilder( + valueListenable: KometSettings.fullTimestamp, + builder: (context, value, _) => SettingsToggleTile( + icon: Symbols.schedule, + label: 'View full timestamp', + subtitle: 'Показывать время в секундах у сообщений', + value: value, + onChanged: KometSettings.setFullTimestamp, + ), + ), + ], + ), const SizedBox(height: 20), const SectionHeader( 'Ghost Mode', padding: EdgeInsets.fromLTRB(8, 0, 8, 8), fontSize: 14, ), - _card(cs, [ - _toggle( - cs, - icon: Symbols.visibility_off, - label: 'Ghost Mode', - subtitle: 'Вас не видно в сети', - notifier: KometSettings.ghostMode, - onChanged: _setGhostMode, - ), - _divider(cs), - _toggle( - cs, - icon: Symbols.mark_chat_read, - label: 'Anti read', - subtitle: 'Нечиталка сообщений', - notifier: KometSettings.antiRead, - onChanged: KometSettings.setAntiRead, - ), - _divider(cs), - _toggle( - cs, - icon: Symbols.radar, - label: 'Self Online Check', - subtitle: - 'Каждые ~10 секунд сверяет, когда вы были онлайн. ' - 'Полезно для проверки ghost mode', - notifier: KometSettings.selfOnlineCheck, - onChanged: KometSettings.setSelfOnlineCheck, - ), - ]), + SettingsCard( + children: [ + ValueListenableBuilder( + valueListenable: KometSettings.ghostMode, + builder: (context, value, _) => SettingsToggleTile( + icon: Symbols.visibility_off, + label: 'Ghost Mode', + subtitle: 'Вас не видно в сети', + value: value, + onChanged: _setGhostMode, + ), + ), + ValueListenableBuilder( + valueListenable: KometSettings.antiRead, + builder: (context, value, _) => SettingsToggleTile( + icon: Symbols.mark_chat_read, + label: 'Anti read', + subtitle: 'Нечиталка сообщений', + value: value, + onChanged: KometSettings.setAntiRead, + ), + ), + ValueListenableBuilder( + valueListenable: KometSettings.selfOnlineCheck, + builder: (context, value, _) => SettingsToggleTile( + icon: Symbols.radar, + label: 'Self Online Check', + subtitle: + 'Каждые ~10 секунд сверяет, когда вы были онлайн. ' + 'Полезно для проверки ghost mode', + value: value, + onChanged: KometSettings.setSelfOnlineCheck, + ), + ), + ], + ), ], ), ), @@ -103,80 +118,4 @@ class KometSettingsScreen extends StatelessWidget { await KometSettings.setGhostMode(value); api.sendPing(interactive: !value); } - - Widget _card(ColorScheme cs, List children) { - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - depth: 6, - child: Column(children: children), - ); - } - - Widget _divider(ColorScheme cs) { - return Padding( - padding: const EdgeInsets.only(left: 58), - child: Divider( - height: 1, - thickness: 1, - color: cs.outlineVariant.withValues(alpha: 0.35), - ), - ); - } - - Widget _toggle( - ColorScheme cs, { - required IconData icon, - required String label, - required String subtitle, - required ValueNotifier notifier, - required Future Function(bool) onChanged, - }) { - return ValueListenableBuilder( - valueListenable: notifier, - builder: (context, value, _) { - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => onChanged(!value), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - child: Row( - children: [ - Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - subtitle, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - height: 1.3, - ), - ), - ], - ), - ), - const SizedBox(width: 12), - Switch(value: value, onChanged: onChanged), - ], - ), - ), - ), - ); - }, - ); - } } diff --git a/lib/frontend/screens/profile/message_actions_screen.dart b/lib/frontend/screens/profile/message_actions_screen.dart index 65f04b3..b6591e7 100644 --- a/lib/frontend/screens/profile/message_actions_screen.dart +++ b/lib/frontend/screens/profile/message_actions_screen.dart @@ -6,6 +6,7 @@ import '../../widgets/connection_status.dart'; import '../../../core/config/app_message_actions_style.dart'; import '../../../core/utils/haptics.dart'; import '../../widgets/glossy_pill.dart'; +import '../../widgets/settings_radio_tile.dart'; class MessageActionsScreen extends StatelessWidget { const MessageActionsScreen({super.key}); @@ -24,9 +25,7 @@ class MessageActionsScreen extends StatelessWidget { child: ListView( physics: const BouncingScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), - children: const [ - _StyleCard(), - ], + children: const [_StyleCard()], ), ), ); @@ -60,114 +59,49 @@ class _StyleCard extends StatelessWidget { padding: const EdgeInsets.fromLTRB(20, 18, 20, 12), depth: 6, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Стиль', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w700, - ), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Стиль', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, ), - const SizedBox(height: 4), - Text( - 'Как показывается меню при долгом нажатии на сообщение', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), - ), - const SizedBox(height: 8), - ValueListenableBuilder( - valueListenable: AppMessageActionsStyle.current, - builder: (context, current, _) { - return Column( - children: [ - for (final item in _items) - _StyleTile( - icon: item.icon, - label: item.label, - description: item.description, - selected: current == item.style, - onTap: () { - if (current == item.style) return; - Haptics.selection(); - AppMessageActionsStyle.save(item.style); - }, - ), - ], - ); - }, - ), - ], - ), - ); - } -} - -class _StyleTile extends StatelessWidget { - final IconData icon; - final String label; - final String description; - final bool selected; - final VoidCallback onTap; - - const _StyleTile({ - required this.icon, - required this.label, - required this.description, - required this.selected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), - child: Row( - children: [ - Icon(icon, color: cs.onSurface, size: 22, weight: 500), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle( - color: cs.onSurface, - fontSize: 15, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - description, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 12.5, - height: 1.3, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - Icon( - selected - ? Symbols.radio_button_checked - : Symbols.radio_button_unchecked, - color: selected ? cs.primary : cs.outline, - size: 22, - fill: selected ? 1 : 0, - ), - ], ), - ), + const SizedBox(height: 4), + Text( + 'Как показывается меню при долгом нажатии на сообщение', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: AppMessageActionsStyle.current, + builder: (context, current, _) { + return Column( + children: [ + for (final item in _items) + SettingsRadioTile( + leading: Icon( + item.icon, + color: cs.onSurface, + size: 22, + weight: 500, + ), + label: item.label, + description: item.description, + selected: current == item.style, + onTap: () { + if (current == item.style) return; + Haptics.selection(); + AppMessageActionsStyle.save(item.style); + }, + ), + ], + ); + }, + ), + ], ), ); } diff --git a/lib/frontend/screens/profile/notifications_screen.dart b/lib/frontend/screens/profile/notifications_screen.dart index a1fc326..bb8fd25 100644 --- a/lib/frontend/screens/profile/notifications_screen.dart +++ b/lib/frontend/screens/profile/notifications_screen.dart @@ -2,11 +2,12 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../core/utils/haptics.dart'; +import '../../../l10n/app_localizations.dart'; import '../../../main.dart' show accountModule, isOnemeFlavor; import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; -import '../../widgets/glossy_pill.dart'; import '../../widgets/section_header.dart'; +import '../../widgets/settings_card.dart'; class NotificationsScreen extends StatefulWidget { const NotificationsScreen({super.key}); @@ -16,8 +17,6 @@ class NotificationsScreen extends StatefulWidget { } class _NotificationsScreenState extends State { - static const String _defaultSound = 'oki.aiff'; - bool _loading = true; bool _saving = false; @@ -49,7 +48,7 @@ class _NotificationsScreenState extends State { Future _apply( bool value, - Map settings, + Future Function() action, ValueChanged assign, ) async { if (_saving) return; @@ -58,11 +57,14 @@ class _NotificationsScreenState extends State { _saving = true; }); try { - await accountModule.updatePrivacyConfig(settings); + await action(); } catch (e) { if (mounted) { setState(() => assign(!value)); - showCustomNotification(context, 'Не удалось сохранить: $e'); + showCustomNotification( + context, + AppLocalizations.of(context)!.notificationsSaveFailed(e.toString()), + ); } } finally { if (mounted) setState(() => _saving = false); @@ -76,22 +78,24 @@ class _NotificationsScreenState extends State { } void _onFkmTap() { + final l10n = AppLocalizations.of(context)!; showCustomNotification( context, isOnemeFlavor - ? 'А зачем? У тебя уже FCM.' - : 'Скачай лучше FCM-версию.', + ? l10n.notificationsFkmAlreadyHasFcm + : l10n.notificationsFkmDownloadFcm, ); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return Scaffold( backgroundColor: cs.surface, appBar: ConnectionTitleBar( - titleText: 'Уведомления', + titleText: l10n.notificationsTitle, backgroundColor: cs.surface, ), body: SafeArea( @@ -102,207 +106,123 @@ class _NotificationsScreenState extends State { physics: const BouncingScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), children: [ - const SectionHeader( - 'FKM', - padding: EdgeInsets.fromLTRB(8, 0, 8, 8), + SectionHeader( + l10n.notificationsFkmSectionTitle, + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), fontSize: 14, ), - _card(cs, [ - _toggleRow( - cs, - icon: Symbols.notifications_active, - label: 'Включить уведомления', - subtitle: - 'Для работы FKM уведомлений, приложению понадобится держать уведомление в шторке.', - value: false, - onChanged: (_) => _onFkmTap(), - ), - ]), + SettingsCard( + children: [ + SettingsToggleTile( + icon: Symbols.notifications_active, + label: l10n.notificationsFkmEnableLabel, + subtitle: l10n.notificationsFkmEnableSubtitle, + value: false, + onChanged: (_) => _onFkmTap(), + ), + ], + ), const SizedBox(height: 20), - const SectionHeader( - 'Уведомления', - padding: EdgeInsets.fromLTRB(8, 0, 8, 8), + SectionHeader( + l10n.notificationsMainSectionTitle, + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), fontSize: 14, ), - _card(cs, [ - _toggleRow( - cs, - icon: Symbols.notifications, - label: 'Все уведомления', - value: _allNotifications, - onChanged: (v) => _apply( - v, - {'CHATS_PUSH_NOTIFICATION': v ? 'ON' : 'OFF'}, - (b) => _allNotifications = b, - ), - ), - ]), - const SizedBox(height: 20), - const SectionHeader( - 'Все новые уведомления', - padding: EdgeInsets.fromLTRB(8, 0, 8, 8), - fontSize: 14, - ), - _card(cs, [ - _toggleRow( - cs, - icon: Symbols.chat, - label: 'Предпросмотр сообщений', - value: _messagePreview, - enabled: _allNotifications, - onChanged: (v) => _apply( - v, - {'PUSH_DETAILS': v}, - (b) => _messagePreview = b, - ), - ), - _divider(cs), - _toggleRow( - cs, - icon: Symbols.music_note, - label: 'Звук', - value: _sound, - enabled: _allNotifications, - onChanged: (v) => _apply( - v, - { - 'PUSH_SOUND': v ? _defaultSound : '', - 'CHATS_PUSH_SOUND': v ? _defaultSound : '', - }, - (b) => _sound = b, - ), - ), - ]), - const SizedBox(height: 20), - const SectionHeader( - 'Дополнительно', - padding: EdgeInsets.fromLTRB(8, 0, 8, 8), - fontSize: 14, - ), - _card(cs, [ - _toggleRow( - cs, - icon: Symbols.call, - label: 'Уведомления о звонках', - value: _callNotifications, - onChanged: (v) => _apply( - v, - {'M_CALL_PUSH_NOTIFICATION': v ? 'ON' : 'OFF'}, - (b) => _callNotifications = b, - ), - ), - _divider(cs), - _toggleRow( - cs, - icon: Symbols.person_add, - label: 'Уведомления от новых контактов', - value: _newContacts, - onChanged: (v) => _apply( - v, - {'PUSH_NEW_CONTACTS': v}, - (b) => _newContacts = b, - ), - ), - ]), - const SizedBox(height: 20), - const SectionHeader( - 'Тактильная отдача', - padding: EdgeInsets.fromLTRB(8, 0, 8, 8), - fontSize: 14, - ), - _card(cs, [ - _toggleRow( - cs, - icon: Symbols.vibration, - label: 'Тактильная отдача', - subtitle: 'Виброотклик при действиях в приложении', - value: _hapticsEnabled, - onChanged: _setHaptics, - ), - ]), - ], - ), - ), - ); - } - - Widget _card(ColorScheme cs, List children) { - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - depth: 6, - child: Column(children: children), - ); - } - - Widget _divider(ColorScheme cs) { - return Padding( - padding: const EdgeInsets.only(left: 58), - child: Divider( - height: 1, - thickness: 1, - color: cs.outlineVariant.withValues(alpha: 0.35), - ), - ); - } - - Widget _toggleRow( - ColorScheme cs, { - required IconData icon, - required String label, - String? subtitle, - required bool value, - required ValueChanged onChanged, - bool enabled = true, - }) { - return AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: enabled ? 1 : 0.4, - child: IgnorePointer( - ignoring: !enabled, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => onChanged(!value), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - child: Row( - children: [ - Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), + SettingsCard( + children: [ + SettingsToggleTile( + icon: Symbols.notifications, + label: l10n.notificationsAllLabel, + value: _allNotifications, + onChanged: (v) => _apply( + v, + () => accountModule.setChatsPushNotification(v), + (b) => _allNotifications = b, ), - if (subtitle != null) ...[ - const SizedBox(height: 2), - Text( - subtitle, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - height: 1.3, - ), - ), - ], - ], - ), + ), + ], + ), + const SizedBox(height: 20), + SectionHeader( + l10n.notificationsNewSectionTitle, + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + fontSize: 14, + ), + SettingsCard( + children: [ + SettingsToggleTile( + icon: Symbols.chat, + label: l10n.notificationsPreviewLabel, + value: _messagePreview, + enabled: _allNotifications, + onChanged: (v) => _apply( + v, + () => accountModule.setMessagePreview(v), + (b) => _messagePreview = b, + ), + ), + SettingsToggleTile( + icon: Symbols.music_note, + label: l10n.notificationsSoundLabel, + value: _sound, + enabled: _allNotifications, + onChanged: (v) => _apply( + v, + () => accountModule.setNotificationSound(v), + (b) => _sound = b, + ), + ), + ], + ), + const SizedBox(height: 20), + SectionHeader( + l10n.notificationsAdditionalSectionTitle, + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + fontSize: 14, + ), + SettingsCard( + children: [ + SettingsToggleTile( + icon: Symbols.call, + label: l10n.notificationsCallsLabel, + value: _callNotifications, + onChanged: (v) => _apply( + v, + () => accountModule.setCallNotifications(v), + (b) => _callNotifications = b, + ), + ), + SettingsToggleTile( + icon: Symbols.person_add, + label: l10n.notificationsNewContactsLabel, + value: _newContacts, + onChanged: (v) => _apply( + v, + () => accountModule.setNewContacts(v), + (b) => _newContacts = b, + ), + ), + ], + ), + const SizedBox(height: 20), + SectionHeader( + l10n.notificationsHapticsSectionTitle, + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + fontSize: 14, + ), + SettingsCard( + children: [ + SettingsToggleTile( + icon: Symbols.vibration, + label: l10n.notificationsHapticsLabel, + subtitle: l10n.notificationsHapticsSubtitle, + value: _hapticsEnabled, + onChanged: _setHaptics, + ), + ], ), - const SizedBox(width: 12), - Switch(value: value, onChanged: onChanged), ], ), - ), - ), - ), ), ); } diff --git a/lib/frontend/screens/profile/password_entry_screen.dart b/lib/frontend/screens/profile/password_entry_screen.dart index 362f682..cfca998 100644 --- a/lib/frontend/screens/profile/password_entry_screen.dart +++ b/lib/frontend/screens/profile/password_entry_screen.dart @@ -3,8 +3,10 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart' show accountModule; import '../../../backend/modules/account.dart' show TwoFactorDetails; import '../../../core/storage/app_database.dart'; +import '../../../l10n/app_localizations.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; +import '../../widgets/primary_loading_button.dart'; class PasswordEntryScreen extends StatefulWidget { const PasswordEntryScreen({super.key}); @@ -51,34 +53,43 @@ class _PasswordEntryScreenState extends State { }); _passwordController.clear(); } catch (_) { - if (mounted) setState(() => _errorMessage = 'Неверный пароль'); + if (mounted) { + setState( + () => _errorMessage = AppLocalizations.of( + context, + )!.passwordEntryWrongPassword, + ); + } } finally { if (mounted) _isVerifying.value = false; } } Future _promptPassword() async { + final l10n = AppLocalizations.of(context)!; final controller = TextEditingController(); try { return await showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Подтвердите пароль'), + title: Text(l10n.passwordEntryConfirmTitle), content: TextField( controller: controller, obscureText: true, autofocus: true, - decoration: const InputDecoration(hintText: 'Текущий пароль'), + decoration: InputDecoration( + hintText: l10n.passwordEntryCurrentPasswordHint, + ), onSubmitted: (v) => Navigator.of(ctx).pop(v), ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(), - child: const Text('Отмена'), + child: Text(l10n.spoofDialogCancel), ), FilledButton( onPressed: () => Navigator.of(ctx).pop(controller.text), - child: const Text('Продолжить'), + child: Text(l10n.passwordEntryContinue), ), ], ), @@ -88,12 +99,14 @@ class _PasswordEntryScreenState extends State { } } - Future _openWithPassword(Widget Function(String password) builder) async { + Future _openWithPassword( + Widget Function(String password) builder, + ) async { final password = await _promptPassword(); if (password == null || password.isEmpty || !mounted) return; - await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => builder(password)), - ); + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => builder(password))); } Future _check2faStatus() async { @@ -113,7 +126,10 @@ class _PasswordEntryScreenState extends State { } } catch (e) { if (mounted) { - showCustomNotification(context, 'Ошибка: $e'); + showCustomNotification( + context, + AppLocalizations.of(context)!.contactProfileLoadError(e.toString()), + ); setState(() => _isLoading = false); } } @@ -166,7 +182,7 @@ class _PasswordEntryScreenState extends State { ), const SizedBox(width: 4), Text( - 'Пароль для входа', + AppLocalizations.of(context)!.securityPasswordTitle, style: TextStyle( color: cs.onSurface, fontSize: 20, @@ -186,6 +202,7 @@ class _PasswordEntryScreenState extends State { } Widget _buildSetupSection(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return GlossyPill( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(20), @@ -195,14 +212,14 @@ class _PasswordEntryScreenState extends State { _buildHeaderTile( cs, icon: Symbols.lock_open, - title: 'Пароль не установлен', - subtitle: 'Двухфакторная аутентификация', + title: l10n.passwordEntryNotSetTitle, + subtitle: l10n.passwordEntry2faSubtitle, ), Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)), _buildActionRow( cs, icon: Symbols.settings, - label: 'Установить пароль', + label: l10n.passwordEntrySetupAction, isLast: true, onTap: () => Navigator.push( context, @@ -217,6 +234,7 @@ class _PasswordEntryScreenState extends State { } Widget _buildPasswordGate(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return GlossyPill( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(20), @@ -225,82 +243,67 @@ class _PasswordEntryScreenState extends State { child: SizedBox( width: double.infinity, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: cs.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Icon(Symbols.lock, color: cs.primary, size: 24), - ), - const SizedBox(width: 16), - Expanded( - child: Text( - 'Введите пароль для входа, чтобы управлять защитой', - style: TextStyle( - color: cs.onSurface, - fontSize: 15, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - const SizedBox(height: 20), - if (_errorMessage != null) - Container( - padding: const EdgeInsets.all(12), - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: cs.errorContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - _errorMessage!, - style: TextStyle(color: cs.onErrorContainer), - ), - ), - _PasswordField(controller: _passwordController, hintText: 'Пароль'), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: ValueListenableBuilder( - valueListenable: _isVerifying, - builder: (context, loading, _) => FilledButton( - onPressed: loading ? null : _authenticate, - style: FilledButton.styleFrom( - backgroundColor: cs.primary, - foregroundColor: cs.onPrimary, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: cs.primaryContainer, borderRadius: BorderRadius.circular(12), ), + child: Icon(Symbols.lock, color: cs.primary, size: 24), ), - child: loading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimary, - ), - ) - : const Text('Продолжить'), + const SizedBox(width: 16), + Expanded( + child: Text( + l10n.passwordEntryGateMessage, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 20), + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _errorMessage!, + style: TextStyle(color: cs.onErrorContainer), + ), + ), + _PasswordField( + controller: _passwordController, + hintText: l10n.passwordEntryGenericPasswordHint, + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: PrimaryLoadingButton( + loading: _isVerifying, + onPressed: _authenticate, + child: Text(l10n.passwordEntryContinue), ), ), - ), - ], - ), + ], + ), ), ); } Widget _buildManageSection(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return Column( children: [ GlossyPill( @@ -311,56 +314,56 @@ class _PasswordEntryScreenState extends State { child: SizedBox( width: double.infinity, child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: cs.primaryContainer, - borderRadius: BorderRadius.circular(12), + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: cs.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Symbols.lock, color: cs.primary, size: 24), ), - child: Icon(Symbols.lock, color: cs.primary, size: 24), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Пароль установлен', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - if (_details?.email != null && - _details!.email!.isNotEmpty) ...[ - const SizedBox(height: 4), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - _details!.email!, + l10n.passwordEntrySetTitle, style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, ), ), - ], - if (_details?.hint != null && - _details!.hint!.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - 'Подсказка: ${_details!.hint}', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, + if (_details?.email != null && + _details!.email!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + _details!.email!, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), ), - ), + ], + if (_details?.hint != null && + _details!.hint!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + l10n.passwordEntryHintPrefix(_details!.hint!), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], ], - ], + ), ), - ), - ], - ), + ], + ), ), ), const SizedBox(height: 16), @@ -373,7 +376,7 @@ class _PasswordEntryScreenState extends State { _buildActionRow( cs, icon: Symbols.password, - label: 'Изменить пароль', + label: l10n.passwordEntryChangePasswordAction, isLast: false, onTap: () => _openWithPassword( (pwd) => TwoFactorPasswordChangeScreen(currentPassword: pwd), @@ -386,7 +389,7 @@ class _PasswordEntryScreenState extends State { _buildActionRow( cs, icon: Icons.email_outlined, - label: 'Изменить почту', + label: l10n.passwordEntryChangeEmailAction, isLast: false, onTap: () => _openWithPassword( (pwd) => TwoFactorEmailChangeScreen(currentPassword: pwd), @@ -399,7 +402,7 @@ class _PasswordEntryScreenState extends State { _buildActionRow( cs, icon: Icons.delete_outline, - label: 'Удалить пароль', + label: l10n.passwordEntryDeleteAction, isLast: true, textColor: cs.error, onTap: () => _openWithPassword( @@ -518,7 +521,6 @@ class _PasswordEntryScreenState extends State { ], ); } - } class TwoFactorSetupScreen extends StatefulWidget { @@ -552,6 +554,7 @@ class _TwoFactorSetupScreenState extends State { } Future _nextStep() async { + final l10n = AppLocalizations.of(context)!; _isLoading.value = true; setState(() => _errorMessage = null); @@ -559,9 +562,7 @@ class _TwoFactorSetupScreenState extends State { switch (_step) { case 0: if (_passwordController.text.length < 6) { - setState( - () => _errorMessage = 'Пароль должен быть минимум 6 символов', - ); + setState(() => _errorMessage = l10n.passwordEntryMinPasswordError); break; } final trackId = await accountModule.create2faTrack(); @@ -573,7 +574,7 @@ class _TwoFactorSetupScreenState extends State { break; case 1: if (_confirmController.text != _passwordController.text) { - setState(() => _errorMessage = 'Пароли не совпадают'); + setState(() => _errorMessage = l10n.passwordEntryMismatchError); break; } await accountModule.set2faPassword( @@ -596,7 +597,7 @@ class _TwoFactorSetupScreenState extends State { break; } if (!_emailController.text.contains('@')) { - setState(() => _errorMessage = 'Введите корректный email'); + setState(() => _errorMessage = l10n.passwordEntryInvalidEmailError); break; } await accountModule.verify2faEmail(_trackId!, _emailController.text); @@ -605,7 +606,7 @@ class _TwoFactorSetupScreenState extends State { break; case 4: if (_codeController.text.length != 6) { - setState(() => _errorMessage = 'Введите 6-значный код'); + setState(() => _errorMessage = l10n.passwordEntryInvalidCodeError); break; } await accountModule.verify2faCode(_trackId!, _codeController.text); @@ -629,11 +630,11 @@ class _TwoFactorSetupScreenState extends State { withEmail: withEmail, ); if (mounted) { - showCustomNotification(context, 'Пароль установлен'); - Navigator.popUntil( + showCustomNotification( context, - (route) => route.isFirst || route.settings.name == 'SecurityScreen', + AppLocalizations.of(context)!.passwordEntrySetTitle, ); + Navigator.popUntil(context, ModalRoute.withName('SecurityScreen')); } } @@ -650,7 +651,7 @@ class _TwoFactorSetupScreenState extends State { onPressed: () => Navigator.pop(context), ), title: Text( - 'Установка пароля', + AppLocalizations.of(context)!.passwordEntrySetupTitle, style: TextStyle( color: cs.onSurface, fontSize: 20, @@ -663,6 +664,7 @@ class _TwoFactorSetupScreenState extends State { } Widget _buildStepContent(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( @@ -698,28 +700,13 @@ class _TwoFactorSetupScreenState extends State { const SizedBox(height: 24), SizedBox( width: double.infinity, - child: ValueListenableBuilder( - valueListenable: _isLoading, - builder: (context, loading, _) => FilledButton( - onPressed: loading ? null : _nextStep, - style: FilledButton.styleFrom( - backgroundColor: cs.primary, - foregroundColor: cs.onPrimary, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: loading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimary, - ), - ) - : Text(_step == 4 ? 'Установить пароль' : 'Продолжить'), + child: PrimaryLoadingButton( + loading: _isLoading, + onPressed: _nextStep, + child: Text( + _step == 4 + ? l10n.passwordEntrySetupAction + : l10n.passwordEntryContinue, ), ), ), @@ -729,7 +716,14 @@ class _TwoFactorSetupScreenState extends State { } Widget _buildStepIndicator(ColorScheme cs) { - final steps = ['Пароль', 'Подсказка', 'Почта', 'Код', 'Готово']; + final l10n = AppLocalizations.of(context)!; + final steps = [ + l10n.passwordEntryStepPassword, + l10n.passwordEntryStepHint, + l10n.passwordEntryStepEmail, + l10n.passwordEntryStepCode, + l10n.loginDone, + ]; return Row( children: List.generate(steps.length, (index) { final isActive = index <= _step; @@ -790,11 +784,12 @@ class _TwoFactorSetupScreenState extends State { } Widget _buildPasswordField(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Придумайте пароль', + l10n.passwordEntryChoosePassword, style: TextStyle( color: cs.onSurface, fontSize: 17, @@ -803,24 +798,25 @@ class _TwoFactorSetupScreenState extends State { ), const SizedBox(height: 8), Text( - 'Минимум 6 символов', + l10n.passwordEntryMinCharsHint, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), const SizedBox(height: 16), _PasswordField( controller: _passwordController, - hintText: 'Введите пароль', + hintText: l10n.passwordEntryEnterPasswordHint, ), ], ); } Widget _buildPasswordConfirmField(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Подтвердите пароль', + l10n.passwordEntryConfirmTitle, style: TextStyle( color: cs.onSurface, fontSize: 17, @@ -829,24 +825,25 @@ class _TwoFactorSetupScreenState extends State { ), const SizedBox(height: 8), Text( - 'Введите пароль ещё раз', + l10n.passwordEntryEnterAgain, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), const SizedBox(height: 16), _PasswordField( controller: _confirmController, - hintText: 'Повторите пароль', + hintText: l10n.passwordEntryRepeatHint, ), ], ); } Widget _buildHintField(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Подсказка для пароля', + l10n.passwordEntryHintForPassword, style: TextStyle( color: cs.onSurface, fontSize: 17, @@ -855,14 +852,14 @@ class _TwoFactorSetupScreenState extends State { ), const SizedBox(height: 8), Text( - 'Необязательно', + l10n.passwordEntryOptional, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), const SizedBox(height: 16), TextField( controller: _hintController, decoration: InputDecoration( - hintText: 'Введите подсказку (необязательно)', + hintText: l10n.passwordEntryHintFieldHint, filled: true, fillColor: cs.surfaceContainerHighest, border: OutlineInputBorder( @@ -876,11 +873,12 @@ class _TwoFactorSetupScreenState extends State { } Widget _buildEmailField(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Привяжите email', + l10n.passwordEntryLinkEmail, style: TextStyle( color: cs.onSurface, fontSize: 17, @@ -889,7 +887,7 @@ class _TwoFactorSetupScreenState extends State { ), const SizedBox(height: 8), Text( - 'Для восстановления пароля. Необязательно', + l10n.passwordEntryEmailPurpose, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), const SizedBox(height: 16), @@ -897,7 +895,7 @@ class _TwoFactorSetupScreenState extends State { controller: _emailController, keyboardType: TextInputType.emailAddress, decoration: InputDecoration( - hintText: 'example@mail.ru (необязательно)', + hintText: l10n.passwordEntryEmailHintOptional, filled: true, fillColor: cs.surfaceContainerHighest, border: OutlineInputBorder( @@ -911,11 +909,12 @@ class _TwoFactorSetupScreenState extends State { } Widget _buildCodeField(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Введите код', + l10n.passwordEntryEnterCode, style: TextStyle( color: cs.onSurface, fontSize: 17, @@ -924,7 +923,7 @@ class _TwoFactorSetupScreenState extends State { ), const SizedBox(height: 8), Text( - 'Код отправлен на ${_emailController.text}', + l10n.passwordEntryCodeSentTo(_emailController.text), style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), const SizedBox(height: 16), @@ -951,7 +950,10 @@ class _TwoFactorSetupScreenState extends State { class TwoFactorPasswordChangeScreen extends StatefulWidget { final String currentPassword; - const TwoFactorPasswordChangeScreen({super.key, required this.currentPassword}); + const TwoFactorPasswordChangeScreen({ + super.key, + required this.currentPassword, + }); @override State createState() => @@ -976,12 +978,13 @@ class _TwoFactorPasswordChangeScreenState } Future _changePassword() async { + final l10n = AppLocalizations.of(context)!; if (_newPasswordController.text.length < 6) { - setState(() => _errorMessage = 'Пароль должен быть минимум 6 символов'); + setState(() => _errorMessage = l10n.passwordEntryMinPasswordError); return; } if (_confirmController.text != _newPasswordController.text) { - setState(() => _errorMessage = 'Пароли не совпадают'); + setState(() => _errorMessage = l10n.passwordEntryMismatchError); return; } @@ -997,11 +1000,8 @@ class _TwoFactorPasswordChangeScreenState hint: _hintController.text.isEmpty ? null : _hintController.text, ); if (mounted) { - showCustomNotification(context, 'Пароль изменён'); - Navigator.popUntil( - context, - (route) => route.isFirst || route.settings.name == 'SecurityScreen', - ); + showCustomNotification(context, l10n.passwordEntryChangedNotif); + Navigator.popUntil(context, ModalRoute.withName('SecurityScreen')); } } catch (e) { if (mounted) setState(() => _errorMessage = e.toString()); @@ -1013,6 +1013,7 @@ class _TwoFactorPasswordChangeScreenState @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return Scaffold( backgroundColor: cs.surface, appBar: AppBar( @@ -1023,7 +1024,7 @@ class _TwoFactorPasswordChangeScreenState onPressed: () => Navigator.pop(context), ), title: Text( - 'Изменить пароль', + l10n.passwordEntryChangePasswordAction, style: TextStyle( color: cs.onSurface, fontSize: 20, @@ -1050,7 +1051,7 @@ class _TwoFactorPasswordChangeScreenState ), ), Text( - 'Новый пароль', + l10n.passwordEntryNewPassword, style: TextStyle( color: cs.onSurface, fontSize: 17, @@ -1060,16 +1061,16 @@ class _TwoFactorPasswordChangeScreenState const SizedBox(height: 16), _PasswordField( controller: _newPasswordController, - hintText: 'Введите новый пароль', + hintText: l10n.passwordEntryNewPasswordHint, ), const SizedBox(height: 16), _PasswordField( controller: _confirmController, - hintText: 'Повторите новый пароль', + hintText: l10n.passwordEntryRepeatNewPasswordHint, ), const SizedBox(height: 24), Text( - 'Подсказка', + l10n.passwordEntryStepHint, style: TextStyle( color: cs.onSurface, fontSize: 17, @@ -1080,7 +1081,7 @@ class _TwoFactorPasswordChangeScreenState TextField( controller: _hintController, decoration: InputDecoration( - hintText: 'Введите подсказку (необязательно)', + hintText: l10n.passwordEntryHintFieldHint, filled: true, fillColor: cs.surfaceContainerHighest, border: OutlineInputBorder( @@ -1092,29 +1093,10 @@ class _TwoFactorPasswordChangeScreenState const SizedBox(height: 24), SizedBox( width: double.infinity, - child: ValueListenableBuilder( - valueListenable: _isLoading, - builder: (context, loading, _) => FilledButton( - onPressed: loading ? null : _changePassword, - style: FilledButton.styleFrom( - backgroundColor: cs.primary, - foregroundColor: cs.onPrimary, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: loading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimary, - ), - ) - : const Text('Сохранить'), - ), + child: PrimaryLoadingButton( + loading: _isLoading, + onPressed: _changePassword, + child: Text(l10n.editProfileSave), ), ), ], @@ -1160,6 +1142,7 @@ class _TwoFactorEmailChangeScreenState } Future _nextStep() async { + final l10n = AppLocalizations.of(context)!; _isLoading.value = true; setState(() => _errorMessage = null); @@ -1167,7 +1150,7 @@ class _TwoFactorEmailChangeScreenState switch (_step) { case 0: if (!_emailController.text.contains('@')) { - setState(() => _errorMessage = 'Введите корректный email'); + setState(() => _errorMessage = l10n.passwordEntryInvalidEmailError); break; } final trackId = await _ensureTrack(); @@ -1177,18 +1160,17 @@ class _TwoFactorEmailChangeScreenState break; case 1: if (_codeController.text.length != 6) { - setState(() => _errorMessage = 'Введите 6-значный код'); + setState(() => _errorMessage = l10n.passwordEntryInvalidCodeError); break; } await accountModule.verify2faCode(_trackId!, _codeController.text); await accountModule.commit2faEmailChange(_trackId!); if (mounted) { - showCustomNotification(context, 'Почта изменена'); - Navigator.popUntil( + showCustomNotification( context, - (route) => - route.isFirst || route.settings.name == 'SecurityScreen', + l10n.passwordEntryEmailChangedNotif, ); + Navigator.popUntil(context, ModalRoute.withName('SecurityScreen')); } break; } @@ -1202,6 +1184,7 @@ class _TwoFactorEmailChangeScreenState @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return Scaffold( backgroundColor: cs.surface, appBar: AppBar( @@ -1212,7 +1195,7 @@ class _TwoFactorEmailChangeScreenState onPressed: () => Navigator.pop(context), ), title: Text( - 'Изменить почту', + l10n.passwordEntryChangeEmailAction, style: TextStyle( color: cs.onSurface, fontSize: 20, @@ -1240,7 +1223,7 @@ class _TwoFactorEmailChangeScreenState ), if (_step == 0) ...[ Text( - 'Новая почта', + l10n.passwordEntryNewEmail, style: TextStyle( color: cs.onSurface, fontSize: 17, @@ -1252,7 +1235,7 @@ class _TwoFactorEmailChangeScreenState controller: _emailController, keyboardType: TextInputType.emailAddress, decoration: InputDecoration( - hintText: 'example@mail.ru', + hintText: l10n.passwordEntryEmailHint, filled: true, fillColor: cs.surfaceContainerHighest, border: OutlineInputBorder( @@ -1263,7 +1246,7 @@ class _TwoFactorEmailChangeScreenState ), ] else ...[ Text( - 'Введите код', + l10n.passwordEntryEnterCode, style: TextStyle( color: cs.onSurface, fontSize: 17, @@ -1272,7 +1255,7 @@ class _TwoFactorEmailChangeScreenState ), const SizedBox(height: 8), Text( - 'Код отправлен на ${_emailController.text}', + l10n.passwordEntryCodeSentTo(_emailController.text), style: TextStyle(color: cs.onSurfaceVariant), ), const SizedBox(height: 16), @@ -1295,28 +1278,13 @@ class _TwoFactorEmailChangeScreenState const SizedBox(height: 24), SizedBox( width: double.infinity, - child: ValueListenableBuilder( - valueListenable: _isLoading, - builder: (context, loading, _) => FilledButton( - onPressed: loading ? null : _nextStep, - style: FilledButton.styleFrom( - backgroundColor: cs.primary, - foregroundColor: cs.onPrimary, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: loading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimary, - ), - ) - : Text(_step == 1 ? 'Сохранить' : 'Продолжить'), + child: PrimaryLoadingButton( + loading: _isLoading, + onPressed: _nextStep, + child: Text( + _step == 1 + ? l10n.editProfileSave + : l10n.passwordEntryContinue, ), ), ), @@ -1347,6 +1315,7 @@ class _TwoFactorRemoveScreenState extends State { } Future _remove2fa() async { + final l10n = AppLocalizations.of(context)!; _isLoading.value = true; setState(() => _errorMessage = null); @@ -1355,11 +1324,8 @@ class _TwoFactorRemoveScreenState extends State { await accountModule.check2faPassword(trackId, widget.currentPassword); await accountModule.remove2fa(trackId); if (mounted) { - showCustomNotification(context, 'Пароль удалён'); - Navigator.popUntil( - context, - (route) => route.isFirst || route.settings.name == 'SecurityScreen', - ); + showCustomNotification(context, l10n.passwordEntryRemovedNotif); + Navigator.popUntil(context, ModalRoute.withName('SecurityScreen')); } } catch (e) { if (mounted) setState(() => _errorMessage = e.toString()); @@ -1371,6 +1337,7 @@ class _TwoFactorRemoveScreenState extends State { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return Scaffold( backgroundColor: cs.surface, appBar: AppBar( @@ -1381,7 +1348,7 @@ class _TwoFactorRemoveScreenState extends State { onPressed: () => Navigator.pop(context), ), title: Text( - 'Удаление пароля', + l10n.passwordEntryRemoveTitle, style: TextStyle( color: cs.onSurface, fontSize: 20, @@ -1406,7 +1373,7 @@ class _TwoFactorRemoveScreenState extends State { const SizedBox(width: 12), Expanded( child: Text( - 'Внимание! После удаления пароля защита вашего аккаунта ослабнет.', + l10n.passwordEntryRemoveWarning, style: TextStyle(color: cs.onSurface), ), ), @@ -1429,29 +1396,12 @@ class _TwoFactorRemoveScreenState extends State { ), SizedBox( width: double.infinity, - child: ValueListenableBuilder( - valueListenable: _isLoading, - builder: (context, loading, _) => FilledButton( - onPressed: loading ? null : _remove2fa, - style: FilledButton.styleFrom( - backgroundColor: cs.error, - foregroundColor: cs.onError, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: loading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onError, - ), - ) - : const Text('Удалить пароль'), - ), + child: PrimaryLoadingButton( + loading: _isLoading, + onPressed: _remove2fa, + background: cs.error, + foreground: cs.onError, + child: Text(l10n.passwordEntryDeleteAction), ), ), ], diff --git a/lib/frontend/screens/profile/performance_screen.dart b/lib/frontend/screens/profile/performance_screen.dart index 7c401fb..47fc4ac 100644 --- a/lib/frontend/screens/profile/performance_screen.dart +++ b/lib/frontend/screens/profile/performance_screen.dart @@ -50,6 +50,7 @@ class _PerformanceScreenState extends State { _lowWarnDismissed = true; await AppCacheExtent.save(v); } else { + if (!mounted) return; setState(() => _value = _preZoneValue); await AppCacheExtent.save(_preZoneValue); } @@ -66,6 +67,7 @@ class _PerformanceScreenState extends State { _highWarnDismissed = true; await AppCacheExtent.save(v); } else { + if (!mounted) return; setState(() => _value = _preZoneValue); await AppCacheExtent.save(_preZoneValue); } diff --git a/lib/frontend/screens/profile/security_screen.dart b/lib/frontend/screens/profile/security_screen.dart index 5e4f42c..8feed08 100644 --- a/lib/frontend/screens/profile/security_screen.dart +++ b/lib/frontend/screens/profile/security_screen.dart @@ -5,6 +5,8 @@ import '../../../main.dart' show accountModule; import '../../../backend/modules/account.dart' show PrivacyConfig, BlockedContact; import '../../../core/storage/app_database.dart'; +import '../../../core/config/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; import '../../widgets/confirm_dialog.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/connection_status.dart'; @@ -68,7 +70,10 @@ class _SecurityScreenState extends State } } catch (e) { if (mounted) { - showCustomNotification(context, 'Ошибка загрузки: $e'); + showCustomNotification( + context, + AppLocalizations.of(context)!.securityLoadError(e.toString()), + ); setState(() => _isLoading = false); } } @@ -84,7 +89,10 @@ class _SecurityScreenState extends State } } catch (e) { if (mounted) { - showCustomNotification(context, 'Ошибка сохранения: $e'); + showCustomNotification( + context, + AppLocalizations.of(context)!.securitySaveError(e.toString()), + ); } } finally { if (mounted) { @@ -199,7 +207,7 @@ class _SecurityScreenState extends State ), const SizedBox(width: 4), ConnectionTitleText( - 'Безопасность', + AppLocalizations.of(context)!.securityTitle, style: TextStyle( color: cs.onSurface, fontSize: 20, @@ -226,20 +234,22 @@ class _SecurityScreenState extends State } String _getPrivacyLabel(String value) { + final l10n = AppLocalizations.of(context)!; switch (value) { case 'ALL': - return 'Все'; + return l10n.securityPrivacyAll; case 'CONTACTS': - return 'Мои контакты'; + return l10n.securityPrivacyContacts; case 'NONE': case 'NOBODY': - return 'Никто'; + return l10n.securityPrivacyNobody; default: return value; } } Widget _buildTopSection(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return GlossyPill( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(20), @@ -247,13 +257,13 @@ class _SecurityScreenState extends State child: Column( children: [ _buildPasswordRow(cs), - _buildNavRow( + _settingsRow( cs, icon: Symbols.shield, - label: 'Семейная защита', + label: l10n.securityFamilyProtection, subtitle: _privacyConfig?.familyProtection == 'ON' - ? 'Включена' - : 'Отключена', + ? l10n.securityEnabledFem + : l10n.securityDisabledFem, isLast: true, ), ], @@ -262,6 +272,7 @@ class _SecurityScreenState extends State } Widget _buildPasswordRow(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return Column( children: [ Material( @@ -292,7 +303,7 @@ class _SecurityScreenState extends State crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Пароль для входа', + l10n.securityPasswordTitle, style: TextStyle( color: cs.onSurface, fontSize: 16, @@ -301,7 +312,9 @@ class _SecurityScreenState extends State ), const SizedBox(height: 2), Text( - _is2faEnabled ? 'Включён' : 'Отключён', + _is2faEnabled + ? l10n.securityEnabledMasc + : l10n.securityDisabledMasc, style: TextStyle( color: cs.onSurfaceVariant, fontSize: 13, @@ -336,6 +349,7 @@ class _SecurityScreenState extends State } Widget _buildPrivacySettings(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; final isSafeMode = _privacyConfig?.safeMode ?? false; return GlossyPill( color: cs.surfaceContainerHigh, @@ -359,7 +373,7 @@ class _SecurityScreenState extends State crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Безопасный режим', + l10n.securityModeTitle, style: TextStyle( color: cs.onSurface, fontSize: 16, @@ -368,7 +382,7 @@ class _SecurityScreenState extends State ), const SizedBox(height: 2), Text( - 'Скрывает личную информацию', + l10n.securityModeSubtitle, style: TextStyle( color: cs.onSurfaceVariant, fontSize: 13, @@ -381,7 +395,7 @@ class _SecurityScreenState extends State value: isSafeMode, onChanged: (v) => showCustomNotification( context, - 'Изменение настроек пока недоступно', + l10n.securitySettingsUnavailable, ), ), ], @@ -396,34 +410,56 @@ class _SecurityScreenState extends State color: cs.outlineVariant.withValues(alpha: 0.35), ), ), - _buildSubRow( + _settingsRow( cs, - label: 'Найти меня по номеру', - value: _getPrivacyLabel(_privacyConfig?.searchByPhone ?? 'ALL'), + label: l10n.securityFindByPhone, + trailingText: _getPrivacyLabel( + _privacyConfig?.searchByPhone ?? 'ALL', + ), + verticalPadding: 16, + labelFontSize: 15, + labelFontWeight: null, + chevronSize: 18, + insetDivider: false, isLast: false, ), - _buildSubRow( + _settingsRow( cs, - label: 'Кто может мне звонить', - value: _getPrivacyLabel( + label: l10n.securityWhoCanCall, + trailingText: _getPrivacyLabel( _privacyConfig?.incomingCall ?? 'CONTACTS', ), + verticalPadding: 16, + labelFontSize: 15, + labelFontWeight: null, + chevronSize: 18, + insetDivider: false, isLast: false, ), - _buildSubRow( + _settingsRow( cs, - label: 'Кто может приглашать в чаты', - value: _getPrivacyLabel( + label: l10n.securityWhoCanInvite, + trailingText: _getPrivacyLabel( _privacyConfig?.chatsInvite ?? 'CONTACTS', ), + verticalPadding: 16, + labelFontSize: 15, + labelFontWeight: null, + chevronSize: 18, + insetDivider: false, isLast: false, ), - _buildSubRow( + _settingsRow( cs, - label: 'Показывать контакт', - value: _privacyConfig?.contentLevelAccess == true - ? 'Безопасный' - : 'Весь', + label: l10n.securityShowContact, + trailingText: _privacyConfig?.contentLevelAccess == true + ? l10n.securityContentSafe + : l10n.securityContentAll, + verticalPadding: 16, + labelFontSize: 15, + labelFontWeight: null, + chevronSize: 18, + insetDivider: false, isLast: true, ), ], @@ -436,80 +472,93 @@ class _SecurityScreenState extends State color: cs.outlineVariant.withValues(alpha: 0.35), ), ), - _buildOptionRow( + _settingsRow( cs, icon: Symbols.phone, - label: 'Кто может мне звонить', - value: _getPrivacyLabel( + label: l10n.securityWhoCanCall, + trailingText: _getPrivacyLabel( _privacyConfig?.incomingCall ?? 'CONTACTS', ), isLast: false, onTap: () => _showOptionSheet( context, cs, - title: 'Кто может мне звонить', + title: l10n.securityWhoCanCall, currentValue: _privacyConfig?.incomingCall ?? 'CONTACTS', - options: const [('ALL', 'Все'), ('CONTACTS', 'Мои контакты')], + options: [ + ('ALL', l10n.securityPrivacyAll), + ('CONTACTS', l10n.securityPrivacyContacts), + ], onSelect: (value) => _updateSetting('INCOMING_CALL', value), ), ), - _buildOptionRow( + _settingsRow( cs, icon: Symbols.group, - label: 'Кто может приглашать в чаты', - value: _getPrivacyLabel( + label: l10n.securityWhoCanInvite, + trailingText: _getPrivacyLabel( _privacyConfig?.chatsInvite ?? 'CONTACTS', ), isLast: false, onTap: () => _showOptionSheet( context, cs, - title: 'Кто может приглашать в чаты', + title: l10n.securityWhoCanInvite, currentValue: _privacyConfig?.chatsInvite ?? 'CONTACTS', - options: const [('ALL', 'Все'), ('CONTACTS', 'Мои контакты')], + options: [ + ('ALL', l10n.securityPrivacyAll), + ('CONTACTS', l10n.securityPrivacyContacts), + ], onSelect: (value) => _updateSetting('CHATS_INVITE', value), ), ), - _buildOptionRow( + _settingsRow( cs, icon: Symbols.contact_phone, - label: 'Найти меня по номеру', - value: _getPrivacyLabel(_privacyConfig?.searchByPhone ?? 'ALL'), + label: l10n.securityFindByPhone, + trailingText: _getPrivacyLabel( + _privacyConfig?.searchByPhone ?? 'ALL', + ), isLast: false, onTap: () => _showOptionSheet( context, cs, - title: 'Найти меня по номеру', + title: l10n.securityFindByPhone, currentValue: _privacyConfig?.searchByPhone ?? 'ALL', - options: const [('ALL', 'Все'), ('CONTACTS', 'Мои контакты')], + options: [ + ('ALL', l10n.securityPrivacyAll), + ('CONTACTS', l10n.securityPrivacyContacts), + ], onSelect: (value) => _updateSetting('SEARCH_BY_PHONE', value), ), ), - _buildOptionRow( + _settingsRow( cs, icon: Icons.visibility_off_outlined, - label: 'Видеть статус «в сети»', - value: _privacyConfig?.hidden == true ? 'Никто' : 'Мои контакты', + label: l10n.securityShowOnlineStatus, + trailingText: _privacyConfig?.hidden == true + ? l10n.securityPrivacyNobody + : l10n.securityPrivacyContacts, isLast: false, onTap: () => _showHiddenStatusSheet(context, cs), ), - _buildOptionRow( + _settingsRow( cs, icon: Symbols.contact_page, - label: 'Видеть мой номер', - value: _getPrivacyLabel( + label: l10n.securityShowMyNumber, + trailingText: _getPrivacyLabel( _privacyConfig?.phoneNumberPrivacy ?? 'ALL', ), isLast: true, onTap: () => _showOptionSheet( context, cs, - title: 'Видеть мой номер', + title: l10n.securityShowMyNumber, currentValue: _privacyConfig?.phoneNumberPrivacy ?? 'ALL', - options: const [ - ('ALL', 'Все'), - ('CONTACTS', 'Мои контакты'), - ('NOBODY', 'Никто'), + options: [ + ('ALL', l10n.securityPrivacyAll), + ('CONTACTS', l10n.securityPrivacyContacts), + ('NOBODY', l10n.securityPrivacyNobody), ], onSelect: (value) => _updateSetting('PHONE_NUMBER_PRIVACY', value), @@ -532,9 +581,7 @@ class _SecurityScreenState extends State showModalBottomSheet( context: context, backgroundColor: cs.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), + shape: kSheetShape, builder: (context) { return SafeArea( child: Column( @@ -597,59 +644,35 @@ class _SecurityScreenState extends State BuildContext context, ColorScheme cs, ) async { + final l10n = AppLocalizations.of(context)!; final currentValue = _privacyConfig?.hidden == true ? 'NONE' : 'CONTACTS'; if (currentValue == 'NONE') { final confirmed = await showConfirmDialog( context, - title: 'Вы уверены?', - message: 'Вы не сможете видеть статусы посещения других пользователей.', - confirmLabel: 'Да', + title: l10n.securityConfirmTitle, + message: l10n.securityHiddenStatusWarning, + confirmLabel: l10n.spoofDialogYes, ); if (confirmed) _updateSetting('HIDDEN', false); return; } - showModalBottomSheet( - context: context, - backgroundColor: cs.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (context) { - return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 8), - const SheetGrabber(margin: EdgeInsets.zero), - const SizedBox(height: 16), - Text( - 'Видеть статус «в сети»', - style: TextStyle( - color: cs.onSurface, - fontSize: 17, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - _buildOptionSheetItem( - cs, - 'Мои контакты', - currentValue == 'CONTACTS', - () { - Navigator.pop(context); - _updateSetting('HIDDEN', false); - }, - ), - _buildOptionSheetItem(cs, 'Никто', currentValue == 'NONE', () { - Navigator.pop(context); - _showHiddenStatusConfirmDialog(context, cs); - }, isLast: true), - const SizedBox(height: 16), - ], - ), - ); + _showOptionSheet( + context, + cs, + title: l10n.securityShowOnlineStatus, + currentValue: currentValue, + options: [ + ('CONTACTS', l10n.securityPrivacyContacts), + ('NONE', l10n.securityPrivacyNobody), + ], + onSelect: (value) { + if (value == 'CONTACTS') { + _updateSetting('HIDDEN', false); + } else { + _showHiddenStatusConfirmDialog(context, cs); + } }, ); } @@ -658,64 +681,23 @@ class _SecurityScreenState extends State BuildContext context, ColorScheme cs, ) async { + final l10n = AppLocalizations.of(context)!; final confirmed = await showConfirmDialog( context, - title: 'Вы уверены?', - message: 'Вы не сможете видеть статусы посещения других пользователей.', - confirmLabel: 'Да', + title: l10n.securityConfirmTitle, + message: l10n.securityHiddenStatusWarning, + confirmLabel: l10n.spoofDialogYes, ); if (confirmed) _updateSetting('HIDDEN', true); } - Widget _buildOptionSheetItem( - ColorScheme cs, - String label, - bool isSelected, - VoidCallback onTap, { - bool isLast = false, - }) { - return Column( - children: [ - Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - child: Row( - children: [ - Expanded( - child: Text( - label, - style: TextStyle(color: cs.onSurface, fontSize: 16), - ), - ), - if (isSelected) - Icon(Symbols.check, color: cs.primary, size: 20), - ], - ), - ), - ), - ), - if (!isLast) - Padding( - padding: const EdgeInsets.only(left: 20), - child: Divider( - height: 1, - color: cs.outlineVariant.withValues(alpha: 0.3), - ), - ), - ], - ); - } - Widget _buildInfoLabel(ColorScheme cs) { return Padding( padding: const EdgeInsets.only(left: 4, bottom: 0), child: Text( - 'КОНФИДЕНЦИАЛЬНОСТЬ', + AppLocalizations.of(context)!.securityConfidentialityHeader, style: TextStyle( - color: cs.onSurfaceVariant.withValues(alpha: 0.6), + color: cs.mutedText, fontSize: 12, fontWeight: FontWeight.w600, letterSpacing: 0.8, @@ -725,43 +707,73 @@ class _SecurityScreenState extends State } Widget _buildConfidentialSection(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; + final showReadMark = _privacyConfig?.showReadMark ?? true; + final altKeyboard = _privacyConfig?.altKeyboard ?? false; + final unsafeFiles = _privacyConfig?.unsafeFiles ?? true; + final audioTranscription = + _privacyConfig?.audioTranscriptionEnabled ?? true; return GlossyPill( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(20), depth: 6, child: Column( children: [ - _buildSwitchRow( + _settingsRow( cs, icon: Symbols.description, - label: 'Галочки «Прочитано»', - value: _privacyConfig?.showReadMark ?? true, + label: l10n.securityReadReceipts, + trailingWidget: Switch( + value: showReadMark, + onChanged: (v) => _updateSetting('SHOW_READ_MARK', v), + ), + showChevron: false, + verticalPadding: 14, isLast: false, - onChanged: (v) => _updateSetting('SHOW_READ_MARK', v), + onTap: () => _updateSetting('SHOW_READ_MARK', !showReadMark), ), - _buildSwitchRow( + _settingsRow( cs, icon: Symbols.keyboard_alt, - label: 'Альтернативная клавиатура', - value: _privacyConfig?.altKeyboard ?? false, + label: l10n.securityAltKeyboard, + trailingWidget: Switch( + value: altKeyboard, + onChanged: (v) => _updateSetting('ALT_KEYBOARD', v), + ), + showChevron: false, + verticalPadding: 14, isLast: false, - onChanged: (v) => _updateSetting('ALT_KEYBOARD', v), + onTap: () => _updateSetting('ALT_KEYBOARD', !altKeyboard), ), - _buildSwitchRow( + _settingsRow( cs, icon: Symbols.warning, - label: 'Принимать опасные файлы', - value: _privacyConfig?.unsafeFiles ?? true, + label: l10n.securityUnsafeFiles, + trailingWidget: Switch( + value: unsafeFiles, + onChanged: (v) => _updateSetting('UNSAFE_FILES', v), + ), + showChevron: false, + verticalPadding: 14, isLast: false, - onChanged: (v) => _updateSetting('UNSAFE_FILES', v), + onTap: () => _updateSetting('UNSAFE_FILES', !unsafeFiles), ), - _buildSwitchRow( + _settingsRow( cs, icon: Icons.mic_none_outlined, - label: 'Транскрибация аудио', - value: _privacyConfig?.audioTranscriptionEnabled ?? true, + label: l10n.securityAudioTranscription, + trailingWidget: Switch( + value: audioTranscription, + onChanged: (v) => + _updateSetting('AUDIO_TRANSCRIPTION_ENABLED', v), + ), + showChevron: false, + verticalPadding: 14, isLast: true, - onChanged: (v) => _updateSetting('AUDIO_TRANSCRIPTION_ENABLED', v), + onTap: () => _updateSetting( + 'AUDIO_TRANSCRIPTION_ENABLED', + !audioTranscription, + ), ), ], ), @@ -769,6 +781,7 @@ class _SecurityScreenState extends State } Widget _buildBlacklistSection(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; final count = _blockedContacts.length; return GlossyPill( color: cs.surfaceContainerHigh, @@ -779,7 +792,7 @@ class _SecurityScreenState extends State child: InkWell( onTap: () => showCustomNotification( context, - 'Чёрный список: $count контактов', + l10n.securityBlacklistNotification('$count'), ), borderRadius: BorderRadius.circular(20), child: Padding( @@ -798,7 +811,7 @@ class _SecurityScreenState extends State crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Чёрный список', + l10n.securityBlacklistTitle, style: TextStyle( color: cs.onSurface, fontSize: 16, @@ -839,30 +852,47 @@ class _SecurityScreenState extends State return 'контактов'; } - Widget _buildNavRow( + Widget _settingsRow( ColorScheme cs, { - required IconData icon, + IconData? icon, required String label, String? subtitle, - String? value, - Widget? trailing, - required bool isLast, + String? trailingText, + Widget? trailingWidget, + bool showChevron = true, + double chevronSize = 20, + double verticalPadding = 17, + double labelFontSize = 16, + FontWeight? labelFontWeight = FontWeight.w500, + bool insetDivider = true, + bool isLast = false, + VoidCallback? onTap, }) { return Column( children: [ Material( color: Colors.transparent, child: InkWell( - onTap: () => showCustomNotification(context, label), + onTap: onTap ?? () => showCustomNotification(context, label), borderRadius: isLast ? const BorderRadius.vertical(bottom: Radius.circular(20)) : BorderRadius.zero, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + padding: EdgeInsets.symmetric( + horizontal: 20, + vertical: verticalPadding, + ), child: Row( children: [ - Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), - const SizedBox(width: 16), + if (icon != null) ...[ + Icon( + icon, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + ], Expanded( child: subtitle != null ? Column( @@ -872,8 +902,8 @@ class _SecurityScreenState extends State label, style: TextStyle( color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, + fontSize: labelFontSize, + fontWeight: labelFontWeight, ), ), const SizedBox(height: 2), @@ -890,208 +920,51 @@ class _SecurityScreenState extends State label, style: TextStyle( color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, + fontSize: labelFontSize, + fontWeight: labelFontWeight, ), ), ), - if (value != null) + if (trailingText != null) Text( - value, + trailingText, style: TextStyle( color: cs.onSurfaceVariant, fontSize: 14, ), ), - ?trailing, - const SizedBox(width: 4), - Icon( - Symbols.chevron_right, - color: cs.outline, - size: 20, - weight: 400, - ), - ], - ), - ), - ), - ), - if (!isLast) - Padding( - padding: const EdgeInsets.only(left: 58), - child: Divider( - height: 1, - thickness: 1, - color: cs.outlineVariant.withValues(alpha: 0.35), - ), - ), - ], - ); - } - - Widget _buildOptionRow( - ColorScheme cs, { - required IconData icon, - required String label, - required String value, - required bool isLast, - required VoidCallback onTap, - }) { - return Column( - children: [ - Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: isLast - ? const BorderRadius.vertical(bottom: Radius.circular(20)) - : BorderRadius.zero, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), - child: Row( - children: [ - Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), - const SizedBox(width: 16), - Expanded( - child: Text( - label, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), + ?trailingWidget, + if (showChevron) ...[ + const SizedBox(width: 4), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: chevronSize, + weight: 400, ), - ), - Text( - value, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), - ), - const SizedBox(width: 4), - Icon( - Symbols.chevron_right, - color: cs.outline, - size: 20, - weight: 400, - ), + ], ], ), ), ), ), if (!isLast) - Padding( - padding: const EdgeInsets.only(left: 58), - child: Divider( - height: 1, - thickness: 1, - color: cs.outlineVariant.withValues(alpha: 0.35), - ), - ), - ], - ); - } - - Widget _buildSubRow( - ColorScheme cs, { - required String label, - required String value, - required bool isLast, - }) { - return Column( - children: [ - Material( - color: Colors.transparent, - child: InkWell( - onTap: () => showCustomNotification(context, label), - borderRadius: isLast - ? const BorderRadius.vertical(bottom: Radius.circular(20)) - : BorderRadius.zero, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - child: Row( - children: [ - Expanded( - child: Text( - label, - style: TextStyle(color: cs.onSurface, fontSize: 15), - ), + insetDivider + ? Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), ), - Text( - value, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), - ), - const SizedBox(width: 4), - Icon( - Symbols.chevron_right, - color: cs.outline, - size: 18, - weight: 400, - ), - ], - ), - ), - ), - ), - if (!isLast) - Divider( - height: 1, - thickness: 1, - color: cs.outlineVariant.withValues(alpha: 0.35), - indent: 20, - endIndent: 20, - ), - ], - ); - } - - Widget _buildSwitchRow( - ColorScheme cs, { - required IconData icon, - required String label, - required bool value, - required bool isLast, - required void Function(bool) onChanged, - }) { - return Column( - children: [ - Material( - color: Colors.transparent, - child: InkWell( - onTap: () => onChanged(!value), - borderRadius: isLast - ? const BorderRadius.vertical(bottom: Radius.circular(20)) - : BorderRadius.zero, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - child: Row( - children: [ - Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), - const SizedBox(width: 16), - Expanded( - child: Text( - label, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), - Switch(value: value, onChanged: onChanged), - ], - ), - ), - ), - ), - if (!isLast) - Padding( - padding: const EdgeInsets.only(left: 58), - child: Divider( - height: 1, - thickness: 1, - color: cs.outlineVariant.withValues(alpha: 0.35), - ), - ), + ) + : Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + indent: 20, + endIndent: 20, + ), ], ); } diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 08346c0..24c3448 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -3,20 +3,18 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import '../../../backend/modules/chats.dart'; -import '../../../backend/modules/messages.dart'; import '../../../core/cache/self_presence.dart'; +import '../../../core/config/app_colors.dart'; import '../../../core/config/komet_settings.dart'; import '../../../core/config/app_show_extra_info.dart'; import '../../../core/storage/app_database.dart'; -import '../../../core/storage/token_storage.dart'; import '../../../core/utils/format.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; import '../../widgets/connection_status.dart'; -import '../../widgets/glossy_pill.dart'; import '../../widgets/info_action_sheet.dart'; import '../../widgets/komet_avatar.dart'; +import '../../widgets/settings_card.dart'; import '../../widgets/sheet_helpers.dart'; import '../auth/login_screen.dart'; import '../auth/proxy_settings_sheet.dart'; @@ -199,17 +197,7 @@ class _SettingsTabState extends State { Future _doLogout() async { final navState = KometApp.navigatorKey.currentState; - try { - await api.disconnect(); - } catch (_) {} - final accountId = await TokenStorage.getActiveAccountId(); - if (accountId != null) { - await TokenStorage.deleteAccount(accountId); - await AppDatabase.deleteAccount(accountId); - } - ContactCache.clear(); - TranscriptionCache.clear(); - ChatsModule.resetForAccountSwitch(); + await accountModule.logout(); await resetDigitalIdSession(); try { await api.connect(); @@ -252,7 +240,6 @@ class _SettingsTabState extends State { builder: (context, showExtraInfo, _) { return _buildSection( context, - cs, items: [ _SettingsItem( icon: Symbols.badge, @@ -309,7 +296,6 @@ class _SettingsTabState extends State { padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: _buildSection( context, - cs, items: [ _SettingsItem( icon: Symbols.palette, @@ -332,7 +318,6 @@ class _SettingsTabState extends State { padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: _buildSection( context, - cs, items: [ _SettingsItem( icon: Symbols.notifications_active, @@ -386,6 +371,9 @@ class _SettingsTabState extends State { Navigator.push( context, MaterialPageRoute( + settings: const RouteSettings( + name: 'SecurityScreen', + ), builder: (context) => const SecurityScreen(), ), ); @@ -435,7 +423,6 @@ class _SettingsTabState extends State { padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: _buildSection( context, - cs, items: [ _SettingsItem( icon: Symbols.construction, @@ -464,7 +451,6 @@ class _SettingsTabState extends State { padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: _buildSection( context, - cs, items: [ _SettingsItem( leading: Image.asset( @@ -623,7 +609,7 @@ class _SettingsTabState extends State { Icon( _isPhoneVisible ? Symbols.visibility : Symbols.visibility_off, size: 14, - color: cs.onSurfaceVariant.withValues(alpha: 0.6), + color: cs.mutedText, ), ], ), @@ -659,7 +645,9 @@ class _SettingsTabState extends State { builder: (context, seen, _) { final label = online ? 'онлайн' - : (seen != null ? 'Был(-а) ${_formatSelfSeen(seen)}' : 'офлайн'); + : (seen != null + ? 'Был(-а) ${_formatSelfSeen(seen)}' + : 'офлайн'); return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -667,9 +655,7 @@ class _SettingsTabState extends State { Symbols.check_circle, fill: 1, size: 15, - color: online - ? const Color(0xFF34C759) - : cs.onSurfaceVariant.withValues(alpha: 0.6), + color: online ? kOnlineGreen : cs.mutedText, ), const SizedBox(width: 5), Text( @@ -691,82 +677,21 @@ class _SettingsTabState extends State { } Widget _buildSection( - BuildContext context, - ColorScheme cs, { + BuildContext context, { required List<_SettingsItem> items, }) { - return GlossyPill( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - depth: 6, - child: Column( - children: List.generate(items.length, (index) { - final item = items[index]; - final isLast = index == items.length - 1; - return _buildSettingsRow(context, cs, item, isLast: isLast); - }), - ), - ); - } - - Widget _buildSettingsRow( - BuildContext context, - ColorScheme cs, - _SettingsItem item, { - bool isLast = false, - }) { - return Column( - children: [ - Material( - color: Colors.transparent, - child: InkWell( - onTap: item.onTap ?? () {}, - borderRadius: isLast - ? const BorderRadius.vertical(bottom: Radius.circular(20)) - : null, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), - child: Row( - children: [ - item.leading ?? - Icon( - item.icon, - color: item.tintColor ?? cs.onSurfaceVariant, - size: 22, - weight: 400, - ), - const SizedBox(width: 16), - Expanded( - child: Text( - item.label, - style: TextStyle( - color: item.tintColor ?? cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), - Icon( - Symbols.chevron_right, - color: cs.outline, - size: 20, - weight: 400, - ), - ], - ), - ), - ), - ), - if (!isLast) - Padding( - padding: const EdgeInsets.only(left: 58), - child: Divider( - height: 1, - thickness: 1, - color: cs.outlineVariant.withValues(alpha: 0.35), - ), - ), - ], + return SettingsCard( + children: List.generate(items.length, (index) { + final item = items[index]; + return SettingsNavTile( + icon: item.icon, + leading: item.leading, + label: item.label, + tintColor: item.tintColor, + onTap: item.onTap, + isLast: index == items.length - 1, + ); + }), ); } } @@ -865,7 +790,6 @@ class _SpoilerPainter extends CustomPainter { ..color = color.withValues(alpha: 0.15) ..style = PaintingStyle.fill; - // Draw the background canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH(0, 0, size.width, size.height), @@ -874,10 +798,8 @@ class _SpoilerPainter extends CustomPainter { paint, ); - // Draw "noisy" particles final particlePaint = Paint()..style = PaintingStyle.fill; - // Simple noise effect with dots using animation value for movement for (int i = 0; i < 60; i++) { double dx = (i * 17.5 + animation.value * 20) % size.width; double dy = (i * 13.7 + animation.value * 15) % size.height; diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart index 06ff282..a84a334 100644 --- a/lib/frontend/screens/profile/spoof_screen.dart +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -15,6 +15,7 @@ import '../../../l10n/app_localizations.dart'; import '../../../models/spoof_profile.dart'; import '../../../main.dart'; import '../../widgets/connection_status.dart'; +import '../../widgets/custom_notification.dart'; import '../../widgets/info_action_sheet.dart'; import '../../widgets/section_header.dart'; import '../auth/login_screen.dart'; @@ -115,14 +116,16 @@ class _SpoofScreenState extends State { _timezoneController.text = profile.timezone; _localeController.text = profile.locale; _deviceIdController.text = profile.deviceId; - _appVersionController.text = - profile.appVersion.isEmpty ? _hardcodedVersion : profile.appVersion; + _appVersionController.text = profile.appVersion.isEmpty + ? _hardcodedVersion + : profile.appVersion; _selectedArch = profile.arch.isEmpty ? 'arm64-v8a' : profile.arch; _buildNumberController.text = profile.buildNumber == 0 ? '$_hardcodedBuildNumber' : '${profile.buildNumber}'; - _pushDeviceTypeController.text = - profile.pushDeviceType.isEmpty ? 'GCM' : profile.pushDeviceType; + _pushDeviceTypeController.text = profile.pushDeviceType.isEmpty + ? 'GCM' + : profile.pushDeviceType; _userAgent = profile.userAgent; if (profile.deviceLocale.isNotEmpty) { @@ -240,10 +243,12 @@ class _SpoofScreenState extends State { } Future _applyGeneratedData() async { - final type = - _selectedMethod == SpoofingMethod.full ? _selectedDeviceType : 'ANDROID'; - final filteredPresets = - devicePresets.where((p) => p.deviceType == type).toList(); + final type = _selectedMethod == SpoofingMethod.full + ? _selectedDeviceType + : 'ANDROID'; + final filteredPresets = devicePresets + .where((p) => p.deviceType == type) + .toList(); if (filteredPresets.isEmpty) return; @@ -309,7 +314,8 @@ class _SpoofScreenState extends State { identityChanged = true; } else { identityChanged = - jsonEncode(_initialProfile!.toJson()) != jsonEncode(newProfile.toJson()); + jsonEncode(_initialProfile!.toJson()) != + jsonEncode(newProfile.toJson()); } if (!identityChanged) { @@ -390,13 +396,9 @@ class _SpoofScreenState extends State { } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context)!.spoofErrorApplyFailed(e.toString()), - ), - backgroundColor: Theme.of(context).colorScheme.error, - ), + showCustomNotification( + context, + AppLocalizations.of(context)!.spoofErrorApplyFailed(e.toString()), ); } } @@ -623,11 +625,7 @@ class _SpoofScreenState extends State { selected: _selectedDeviceType, onSelected: _onDeviceTypeChanged, trailing: [ - _buildDisabledChip( - 'iOS', - Icons.phone_iphone_outlined, - theme, - ), + _buildDisabledChip('iOS', Icons.phone_iphone_outlined, theme), ], ) else @@ -638,11 +636,7 @@ class _SpoofScreenState extends State { selected: 'ANDROID', onSelected: _onDeviceTypeChanged, trailing: [ - _buildDisabledChip( - 'iOS', - Icons.phone_iphone_outlined, - theme, - ), + _buildDisabledChip('iOS', Icons.phone_iphone_outlined, theme), _buildDisabledChip( 'Desktop', Icons.desktop_windows_outlined, @@ -906,31 +900,31 @@ class _SpoofScreenState extends State { ...options.map((opt) { final isSelected = opt.value == selected; return ChoiceChip( - label: Text(opt.label), - avatar: isSelected - ? Icon(Icons.check, size: 18, color: cs.onSecondaryContainer) - : (opt.icon != null - ? Icon(opt.icon, size: 18, color: cs.onSurfaceVariant) - : null), - selected: isSelected, - showCheckmark: false, - onSelected: (_) => onSelected(opt.value), - labelStyle: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: isSelected ? cs.onSecondaryContainer : cs.onSurface, - ), - backgroundColor: cs.surfaceContainerHighest, - selectedColor: cs.secondaryContainer, - side: BorderSide( - color: isSelected ? Colors.transparent : cs.outlineVariant, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ); + label: Text(opt.label), + avatar: isSelected + ? Icon(Icons.check, size: 18, color: cs.onSecondaryContainer) + : (opt.icon != null + ? Icon(opt.icon, size: 18, color: cs.onSurfaceVariant) + : null), + selected: isSelected, + showCheckmark: false, + onSelected: (_) => onSelected(opt.value), + labelStyle: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: isSelected ? cs.onSecondaryContainer : cs.onSurface, + ), + backgroundColor: cs.surfaceContainerHighest, + selectedColor: cs.secondaryContainer, + side: BorderSide( + color: isSelected ? Colors.transparent : cs.outlineVariant, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); }), ...trailing, ], diff --git a/lib/frontend/screens/profile/theme_settings_screen.dart b/lib/frontend/screens/profile/theme_settings_screen.dart index 54cf9a5..8513039 100644 --- a/lib/frontend/screens/profile/theme_settings_screen.dart +++ b/lib/frontend/screens/profile/theme_settings_screen.dart @@ -7,8 +7,10 @@ import '../../../core/config/app_amoled.dart'; import '../../../core/config/app_theme_mode.dart'; import '../../../core/config/app_theme_schedule.dart'; import '../../../core/utils/haptics.dart'; +import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; import '../../widgets/glossy_pill.dart'; +import '../../widgets/settings_radio_tile.dart'; class ThemeSettingsScreen extends StatelessWidget { const ThemeSettingsScreen({super.key}); @@ -16,10 +18,14 @@ class ThemeSettingsScreen extends StatelessWidget { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return Scaffold( backgroundColor: cs.surface, - appBar: ConnectionTitleBar(titleText: 'Тема', backgroundColor: cs.surface), + appBar: ConnectionTitleBar( + titleText: l10n.themeSettingsTitle, + backgroundColor: cs.surface, + ), body: SafeArea( top: false, child: ListView( @@ -42,15 +48,29 @@ class _ThemeModeCard extends StatelessWidget { const _ThemeModeCard(); static const _items = [ - (mode: AppThemeMode.system, icon: Symbols.brightness_auto, label: 'Системная'), - (mode: AppThemeMode.light, icon: Symbols.light_mode, label: 'Светлая'), - (mode: AppThemeMode.dark, icon: Symbols.dark_mode, label: 'Тёмная'), - (mode: AppThemeMode.schedule, icon: Symbols.schedule, label: 'По расписанию'), + (mode: AppThemeMode.system, icon: Symbols.brightness_auto), + (mode: AppThemeMode.light, icon: Symbols.light_mode), + (mode: AppThemeMode.dark, icon: Symbols.dark_mode), + (mode: AppThemeMode.schedule, icon: Symbols.schedule), ]; + String _labelFor(AppLocalizations l10n, AppThemeMode mode) { + switch (mode) { + case AppThemeMode.system: + return l10n.themeSettingsModeSystem; + case AppThemeMode.light: + return l10n.themeSettingsModeLight; + case AppThemeMode.dark: + return l10n.themeSettingsModeDark; + case AppThemeMode.schedule: + return l10n.themeSettingsModeSchedule; + } + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return GlossyPill( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(28), @@ -60,42 +80,43 @@ class _ThemeModeCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Режим темы', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w700, - ), + l10n.themeSettingsModeCardTitle, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, ), - const SizedBox(height: 4), - Text( - 'Светлая, тёмная или авто-переключение', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), - ), - const SizedBox(height: 8), - ValueListenableBuilder( - valueListenable: AppThemeModeConfig.current, - builder: (context, current, _) { - return Column( - children: [ - for (final item in _items) - _ModeTile( - icon: item.icon, - label: item.label, - selected: current == item.mode, - onTap: (position) { - if (current == item.mode) return; - Haptics.selection(); - KometApp.stateOf(context) - ?.applyThemeModeWithReveal(item.mode, position); - }, - ), - ], - ); - }, - ), - ], - ), + ), + const SizedBox(height: 4), + Text( + l10n.themeSettingsModeCardSubtitle, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: AppThemeModeConfig.current, + builder: (context, current, _) { + return Column( + children: [ + for (final item in _items) + _ModeTile( + icon: item.icon, + label: _labelFor(l10n, item.mode), + selected: current == item.mode, + onTap: (position) { + if (current == item.mode) return; + Haptics.selection(); + KometApp.stateOf( + context, + )?.applyThemeModeWithReveal(item.mode, position); + }, + ), + ], + ); + }, + ), + ], + ), ); } } @@ -123,40 +144,12 @@ class _ModeTileState extends State<_ModeTile> { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return Material( - color: Colors.transparent, - child: InkWell( - onTapDown: (d) => _lastTapPosition = d.globalPosition, - onTap: () => widget.onTap(_lastTapPosition), - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), - child: Row( - children: [ - Icon(widget.icon, color: cs.onSurface, size: 22, weight: 500), - const SizedBox(width: 14), - Expanded( - child: Text( - widget.label, - style: TextStyle( - color: cs.onSurface, - fontSize: 15, - fontWeight: FontWeight.w500, - ), - ), - ), - Icon( - widget.selected - ? Symbols.radio_button_checked - : Symbols.radio_button_unchecked, - color: widget.selected ? cs.primary : cs.outline, - size: 22, - fill: widget.selected ? 1 : 0, - ), - ], - ), - ), - ), + return SettingsRadioTile( + leading: Icon(widget.icon, color: cs.onSurface, size: 22, weight: 500), + label: widget.label, + selected: widget.selected, + onTapDown: (d) => _lastTapPosition = d.globalPosition, + onTap: () => widget.onTap(_lastTapPosition), ); } } @@ -174,6 +167,7 @@ class _AmoledCardState extends State<_AmoledCard> { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return Listener( behavior: HitTestBehavior.translucent, onPointerDown: (e) => _lastPointerPosition = e.position, @@ -184,54 +178,45 @@ class _AmoledCardState extends State<_AmoledCard> { depth: 6, child: Row( children: [ - Icon( - Symbols.contrast, - color: cs.onSurface, - size: 24, - weight: 500, - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'AMOLED-чёрный', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w700, - ), + Icon(Symbols.contrast, color: cs.onSurface, size: 24, weight: 500), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.themeSettingsAmoledTitle, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, ), - const SizedBox(height: 2), - Text( - 'Чистый чёрный фон для OLED-экранов', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), + ), + const SizedBox(height: 2), + Text( + l10n.themeSettingsAmoledSubtitle, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], ), - ValueListenableBuilder( - valueListenable: AppAmoled.current, - builder: (context, value, _) { - return Switch( - value: value, - onChanged: (v) { - Haptics.selection(); - KometApp.stateOf(context)?.applyAmoledWithReveal( - v, - _lastPointerPosition, - ); - }, - ); - }, - ), - ], - ), + ), + ValueListenableBuilder( + valueListenable: AppAmoled.current, + builder: (context, value, _) { + return Switch( + value: value, + onChanged: (v) { + Haptics.selection(); + KometApp.stateOf( + context, + )?.applyAmoledWithReveal(v, _lastPointerPosition); + }, + ); + }, + ), + ], ), + ), ); } } @@ -242,6 +227,7 @@ class _ScheduleCard extends StatelessWidget { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return ValueListenableBuilder( valueListenable: AppThemeModeConfig.current, builder: (context, mode, _) { @@ -258,61 +244,61 @@ class _ScheduleCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Расписание', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w700, - ), + l10n.themeSettingsScheduleTitle, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, ), - const SizedBox(height: 4), - Text( - enabled - ? 'Когда автоматически включается тёмная тема' - : 'Доступно в режиме «По расписанию»', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), - ), - const SizedBox(height: 12), - ValueListenableBuilder( - valueListenable: AppThemeSchedule.current, - builder: (context, schedule, _) { - return Column( - children: [ - _TimeRow( - icon: Symbols.bedtime, - label: 'Тёмная с', - time: schedule.darkStart, - enabled: enabled, - onPick: (picked) { - KometApp.stateOf(context)?.applyThemeSchedule( - ThemeSchedule( - darkStart: picked, - darkEnd: schedule.darkEnd, - ), - ); - }, - ), - const SizedBox(height: 8), - _TimeRow( - icon: Symbols.wb_sunny, - label: 'Светлая с', - time: schedule.darkEnd, - enabled: enabled, - onPick: (picked) { - KometApp.stateOf(context)?.applyThemeSchedule( - ThemeSchedule( - darkStart: schedule.darkStart, - darkEnd: picked, - ), - ); - }, - ), - ], - ); - }, - ), - ], - ), + ), + const SizedBox(height: 4), + Text( + enabled + ? l10n.themeSettingsScheduleSubtitleEnabled + : l10n.themeSettingsScheduleSubtitleDisabled, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + const SizedBox(height: 12), + ValueListenableBuilder( + valueListenable: AppThemeSchedule.current, + builder: (context, schedule, _) { + return Column( + children: [ + _TimeRow( + icon: Symbols.bedtime, + label: l10n.themeSettingsScheduleDarkFrom, + time: schedule.darkStart, + enabled: enabled, + onPick: (picked) { + KometApp.stateOf(context)?.applyThemeSchedule( + ThemeSchedule( + darkStart: picked, + darkEnd: schedule.darkEnd, + ), + ); + }, + ), + const SizedBox(height: 8), + _TimeRow( + icon: Symbols.wb_sunny, + label: l10n.themeSettingsScheduleLightFrom, + time: schedule.darkEnd, + enabled: enabled, + onPick: (picked) { + KometApp.stateOf(context)?.applyThemeSchedule( + ThemeSchedule( + darkStart: schedule.darkStart, + darkEnd: picked, + ), + ); + }, + ), + ], + ); + }, + ), + ], + ), ), ); }, @@ -347,28 +333,28 @@ class _TimeRow extends StatelessWidget { child: Row( children: [ Icon(icon, color: cs.onSurface, size: 22, weight: 500), - const SizedBox(width: 12), - Expanded( - child: Text( - label, - style: TextStyle( - color: cs.onSurface, - fontSize: 15, - fontWeight: FontWeight.w500, - ), - ), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500, ), - Text( - AppThemeSchedule.format(time), - style: TextStyle( - color: cs.primary, - fontSize: 16, - fontWeight: FontWeight.w700, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ], + ), ), + Text( + AppThemeSchedule.format(time), + style: TextStyle( + color: cs.primary, + fontSize: 16, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), ); } diff --git a/lib/frontend/screens/profile/traffic_monitor_screen.dart b/lib/frontend/screens/profile/traffic_monitor_screen.dart index e748c8a..3a4ea6b 100644 --- a/lib/frontend/screens/profile/traffic_monitor_screen.dart +++ b/lib/frontend/screens/profile/traffic_monitor_screen.dart @@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; +import '../../../core/config/app_colors.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/transport/traffic_monitor.dart'; import '../../../core/utils/format.dart'; @@ -62,7 +63,7 @@ class _TrafficMonitorScreenState extends State { try { final json = _monitor.buildExport(); final dir = await getTemporaryDirectory(); - final stamp = _fileStamp(DateTime.now()); + final stamp = formatFileStamp(DateTime.now()); final file = File('${dir.path}/komet_traffic_$stamp.json'); await file.writeAsString(json); await Share.shareXFiles( @@ -79,12 +80,6 @@ class _TrafficMonitorScreenState extends State { } } - String _fileStamp(DateTime t) { - String two(int n) => n.toString().padLeft(2, '0'); - return '${t.year}${two(t.month)}${two(t.day)}_' - '${two(t.hour)}${two(t.minute)}${two(t.second)}'; - } - @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -221,7 +216,7 @@ class _TrafficMonitorScreenState extends State { height: 10, decoration: BoxDecoration( shape: BoxShape.circle, - color: on ? const Color(0xFF34C759) : cs.outline, + color: on ? kOnlineGreen : cs.outline, ), ), const SizedBox(width: 12), @@ -253,10 +248,7 @@ class _TrafficMonitorScreenState extends State { ], ), ), - Switch( - value: on, - onChanged: (v) => _monitor.setEnabled(v), - ), + Switch(value: on, onChanged: (v) => _monitor.setEnabled(v)), ], ); }, @@ -476,8 +468,7 @@ class _TrafficRow extends StatelessWidget { } String _formatTime(DateTime t) { - String two(int n) => n.toString().padLeft(2, '0'); final ms = t.millisecond.toString().padLeft(3, '0'); - return '${two(t.hour)}:${two(t.minute)}:${two(t.second)}.$ms'; + return '${pad2(t.hour)}:${pad2(t.minute)}:${pad2(t.second)}.$ms'; } } diff --git a/lib/frontend/screens/profile/web_qr_scan_screen.dart b/lib/frontend/screens/profile/web_qr_scan_screen.dart index 1d1822e..a8d8b4b 100644 --- a/lib/frontend/screens/profile/web_qr_scan_screen.dart +++ b/lib/frontend/screens/profile/web_qr_scan_screen.dart @@ -71,7 +71,8 @@ class _WebQrScanScreenState extends State { ), title: Text( 'QR для веба и ПК', - style: TextStyle(fontFamily: 'Outfit', + style: TextStyle( + fontFamily: 'Outfit', fontSize: 20, fontWeight: FontWeight.w600, color: Colors.white, @@ -133,15 +134,13 @@ class _WebQrScanScreenState extends State { child: Text( 'Наведите камеру на QR-код на экране компьютера', textAlign: TextAlign.center, - style: TextStyle(fontFamily: 'Outfit', + style: TextStyle( + fontFamily: 'Outfit', fontSize: 15, fontWeight: FontWeight.w500, color: Colors.white, shadows: const [ - Shadow( - blurRadius: 8, - color: Colors.black54, - ), + Shadow(blurRadius: 8, color: Colors.black54), ], ), ), @@ -169,7 +168,8 @@ class _TelegramStyleFinderOverlay extends StatefulWidget { _TelegramStyleFinderOverlayState(); } -class _TelegramStyleFinderOverlayState extends State<_TelegramStyleFinderOverlay> +class _TelegramStyleFinderOverlayState + extends State<_TelegramStyleFinderOverlay> with SingleTickerProviderStateMixin { static const _animDuration = Duration(milliseconds: 320); static const _snapPx = 14.0; @@ -331,16 +331,18 @@ class _TelegramStyleFinderOverlayState extends State<_TelegramStyleFinderOverlay var r = Rect.fromCenter(center: Offset(cx, cy), width: side, height: side); r = r.intersect(Rect.fromLTWH(0, 0, layout.width, layout.height)); if (r.isEmpty) { - return Rect.fromLTWH(0, 0, layout.shortestSide * 0.5, layout.shortestSide * 0.5); + return Rect.fromLTWH( + 0, + 0, + layout.shortestSide * 0.5, + layout.shortestSide * 0.5, + ); } return r; } static RRect _finderRRect(Rect rect, double maxRadius) { - final r = math.min( - maxRadius, - math.min(rect.width, rect.height) * 0.14, - ); + final r = math.min(maxRadius, math.min(rect.width, rect.height) * 0.14); return RRect.fromRectAndRadius(rect, Radius.circular(r)); } @@ -355,8 +357,7 @@ class _TelegramStyleFinderOverlayState extends State<_TelegramStyleFinderOverlay final finderRect = _interpolatedFinderRect(); final rrect = _finderRRect(finderRect, _frameCornerRadius); - final frameColor = - _qrInView ? const Color(0xFF4ADE80) : Colors.white; + final frameColor = _qrInView ? const Color(0xFF4ADE80) : Colors.white; return IgnorePointer( child: Stack( fit: StackFit.expand, @@ -385,18 +386,14 @@ class _TelegramStyleFinderOverlayState extends State<_TelegramStyleFinderOverlay } class _ScannerDimOutsideRRectPainter extends CustomPainter { - _ScannerDimOutsideRRectPainter({ - required this.hole, - required this.dimColor, - }); + _ScannerDimOutsideRRectPainter({required this.hole, required this.dimColor}); final RRect hole; final Color dimColor; @override void paint(Canvas canvas, Size size) { - final outer = Path() - ..addRect(Rect.fromLTWH(0, 0, size.width, size.height)); + final outer = Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height)); final inner = Path()..addRRect(hole); final mask = Path.combine(PathOperation.difference, outer, inner); canvas.drawPath(mask, Paint()..color = dimColor); @@ -455,7 +452,8 @@ List _mapBarcodeCornersToLayout( return []; } - final isLandscape = deviceOrientation == DeviceOrientation.landscapeLeft || + final isLandscape = + deviceOrientation == DeviceOrientation.landscapeLeft || deviceOrientation == DeviceOrientation.landscapeRight; final cam = isLandscape ? cameraPreviewSize.flipped : cameraPreviewSize; @@ -467,10 +465,6 @@ List _mapBarcodeCornersToLayout( return [ for (final o in barcodeCorners) - Offset( - o.dx * ratio - hPad, - o.dy * ratio - vPad, - ), + Offset(o.dx * ratio - hPad, o.dy * ratio - vPad), ]; } - diff --git a/lib/frontend/screens/webapp/web_app_screen.dart b/lib/frontend/screens/webapp/web_app_screen.dart index f62ee31..114db51 100644 --- a/lib/frontend/screens/webapp/web_app_screen.dart +++ b/lib/frontend/screens/webapp/web_app_screen.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -5,16 +7,37 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/webapp.dart'; import '../../../core/storage/spoofing_service.dart'; import '../../widgets/connection_status.dart'; +import '../../widgets/error_view.dart'; import '../../widgets/webview_permission_prompt.dart'; class WebAppScreen extends StatefulWidget { final String title; final Future Function() loader; + final List? extraUserScripts; + final void Function(InAppWebViewController controller)? onWebViewCreated; + final void Function( + InAppWebViewController controller, + ConsoleMessage consoleMessage, + )? + onConsoleMessage; + final void Function(InAppWebViewController controller, WebUri? url)? + onLoadStart; + final Future Function( + InAppWebViewController controller, + NavigationAction navigationAction, + String? currentUrl, + )? + shouldOverrideUrlLoading; const WebAppScreen({ super.key, required this.title, required this.loader, + this.extraUserScripts, + this.onWebViewCreated, + this.onConsoleMessage, + this.onLoadStart, + this.shouldOverrideUrlLoading, }); @override @@ -84,9 +107,7 @@ class _WebAppScreenState extends State { actions: [ IconButton( icon: const Icon(Symbols.refresh), - onPressed: _launch == null - ? null - : () => _controller?.reload(), + onPressed: _launch == null ? null : () => _controller?.reload(), ), ], bottom: _progress > 0 && _progress < 1 @@ -107,7 +128,7 @@ class _WebAppScreenState extends State { Widget _buildBody(ColorScheme cs) { if (_loadError != null) { - return _ErrorView(message: _loadError!, onRetry: _load); + return ErrorView(message: _loadError!, onRetry: _load); } final launch = _launch; if (launch == null) { @@ -115,6 +136,9 @@ class _WebAppScreenState extends State { } return InAppWebView( initialUrlRequest: URLRequest(url: WebUri(launch.url)), + initialUserScripts: widget.extraUserScripts == null + ? null + : UnmodifiableListView(widget.extraUserScripts!), initialSettings: InAppWebViewSettings( javaScriptEnabled: true, domStorageEnabled: true, @@ -123,11 +147,24 @@ class _WebAppScreenState extends State { transparentBackground: true, mediaPlaybackRequiresUserGesture: false, useHybridComposition: true, + useShouldOverrideUrlLoading: widget.shouldOverrideUrlLoading != null, userAgent: _userAgent, ), - onWebViewCreated: (controller) => _controller = controller, + onWebViewCreated: (controller) { + _controller = controller; + widget.onWebViewCreated?.call(controller); + }, onPermissionRequest: (controller, request) => askWebViewPermission(context, request), + onConsoleMessage: widget.onConsoleMessage, + onLoadStart: widget.onLoadStart, + shouldOverrideUrlLoading: widget.shouldOverrideUrlLoading == null + ? null + : (controller, action) => widget.shouldOverrideUrlLoading!( + controller, + action, + launch.url, + ), onProgressChanged: (controller, progress) { if (!mounted) return; setState(() => _progress = progress / 100); @@ -141,37 +178,3 @@ class _WebAppScreenState extends State { ); } } - -class _ErrorView extends StatelessWidget { - final String message; - final VoidCallback onRetry; - - const _ErrorView({required this.message, required this.onRetry}); - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Symbols.cloud_off, size: 48, color: cs.onSurfaceVariant), - const SizedBox(height: 16), - Text( - message, - textAlign: TextAlign.center, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), - ), - const SizedBox(height: 24), - FilledButton( - onPressed: onRetry, - child: const Text('Повторить'), - ), - ], - ), - ), - ); - } -} diff --git a/lib/frontend/widgets/account_switcher_overlay.dart b/lib/frontend/widgets/account_switcher_overlay.dart index 090c18c..983f672 100644 --- a/lib/frontend/widgets/account_switcher_overlay.dart +++ b/lib/frontend/widgets/account_switcher_overlay.dart @@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../core/storage/app_database.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/haptics.dart'; +import 'animated_overlay_popup.dart'; import 'komet_avatar.dart'; class AccountSwitcherController extends ChangeNotifier { @@ -96,17 +97,15 @@ class _AccountSwitcherLayer extends StatefulWidget { } class _AccountSwitcherLayerState extends State<_AccountSwitcherLayer> - with SingleTickerProviderStateMixin { + with + SingleTickerProviderStateMixin, + AnimatedOverlayPopup<_AccountSwitcherLayer> { static const double _menuWidth = 280.0; static const double _itemHeight = 60.0; static const double _addItemHeight = 54.0; static const double _vPad = 8.0; static const double _hMargin = 12.0; - late final AnimationController _animController; - late final Animation _animation; - bool _closing = false; - List _accounts = const []; int? _activeId; bool _loaded = false; @@ -116,20 +115,18 @@ class _AccountSwitcherLayerState extends State<_AccountSwitcherLayer> List _itemHitRects = const []; bool _committedFired = false; + @override + Duration get overlayForwardDuration => const Duration(milliseconds: 240); + + @override + Duration get overlayReverseDuration => const Duration(milliseconds: 180); + + @override + VoidCallback get onOverlayDismiss => widget.onDismiss; + @override void initState() { super.initState(); - _animController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 240), - reverseDuration: const Duration(milliseconds: 180), - ); - _animation = CurvedAnimation( - parent: _animController, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - ); - _animController.forward(); widget.controller.addListener(_onControllerUpdate); _loadAccounts(); } @@ -216,46 +213,35 @@ class _AccountSwitcherLayerState extends State<_AccountSwitcherLayer> void _onCommit() { if (_hoveredIndex == -1) { - _close(); + closeOverlay(); return; } Haptics.medium(); final isAddItem = _hoveredIndex == _accounts.length; final id = isAddItem ? null : _accounts[_hoveredIndex].id; if (!isAddItem && id == _activeId) { - _close(); + closeOverlay(); return; } final selected = id; - _close().then((_) => widget.onSelected(selected)); - } - - Future _close() async { - if (!mounted || _closing) return; - _closing = true; - try { - await _animController.reverse(); - } catch (_) {} - if (!mounted) return; - widget.onDismiss(); + closeOverlay().then((_) => widget.onSelected(selected)); } @override void dispose() { widget.controller.removeListener(_onControllerUpdate); - _animController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( - animation: _animation, + animation: overlayAnimation, builder: (ctx, _) { - final t = _animation.value.clamp(0.0, 1.0); + final t = overlayAnimation.value.clamp(0.0, 1.0); final blurSigma = 14.0 * t; return GestureDetector( - onTap: _close, + onTap: closeOverlay, behavior: HitTestBehavior.opaque, child: Stack( children: [ diff --git a/lib/frontend/widgets/adaptive_shell.dart b/lib/frontend/widgets/adaptive_shell.dart index 1305c6d..8e2ac57 100644 --- a/lib/frontend/widgets/adaptive_shell.dart +++ b/lib/frontend/widgets/adaptive_shell.dart @@ -64,10 +64,16 @@ class _AdaptiveShellState extends State { void _onChatSelected(DesktopChatSelection chat) { if (chat.imageUrl.isNotEmpty) { - unawaited(precacheImage( - CachedNetworkImageProvider(chat.imageUrl, maxWidth: 144, maxHeight: 144), - context, - )); + unawaited( + precacheImage( + CachedNetworkImageProvider( + chat.imageUrl, + maxWidth: 144, + maxHeight: 144, + ), + context, + ), + ); } setState(() => _selected = chat); } @@ -77,8 +83,7 @@ class _AdaptiveShellState extends State { } void _onDrag(double dx, double totalWidth) { - final maxAllowedByPane = - totalWidth - _minChatPaneWidth - _dividerHitWidth; + final maxAllowedByPane = totalWidth - _minChatPaneWidth - _dividerHitWidth; final upperBound = maxAllowedByPane < _maxListWidth ? maxAllowedByPane : _maxListWidth; @@ -98,8 +103,10 @@ class _AdaptiveShellState extends State { final totalWidth = constraints.maxWidth; final effectiveListWidth = _listWidth.clamp( _minListWidth, - (totalWidth - _minChatPaneWidth - _dividerHitWidth) - .clamp(_minListWidth, _maxListWidth), + (totalWidth - _minChatPaneWidth - _dividerHitWidth).clamp( + _minListWidth, + _maxListWidth, + ), ); final cs = Theme.of(context).colorScheme; return Scaffold( @@ -184,7 +191,9 @@ class _ResizeDividerState extends State<_ResizeDivider> { child: AnimatedContainer( duration: const Duration(milliseconds: 140), width: widget.lineWidth, - color: highlight ? cs.primary.withValues(alpha: 0.6) : widget.color, + color: highlight + ? cs.primary.withValues(alpha: 0.6) + : widget.color, ), ), ), diff --git a/lib/frontend/widgets/animated_overlay_popup.dart b/lib/frontend/widgets/animated_overlay_popup.dart new file mode 100644 index 0000000..a411440 --- /dev/null +++ b/lib/frontend/widgets/animated_overlay_popup.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +mixin AnimatedOverlayPopup + on State, TickerProvider { + Duration get overlayForwardDuration; + Duration get overlayReverseDuration; + VoidCallback get onOverlayDismiss; + + late final AnimationController _overlayController; + late final Animation overlayAnimation; + + bool _overlayClosing = false; + + @override + void initState() { + super.initState(); + _overlayController = AnimationController( + vsync: this, + duration: overlayForwardDuration, + reverseDuration: overlayReverseDuration, + ); + overlayAnimation = CurvedAnimation( + parent: _overlayController, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + _overlayController.forward(); + } + + Future closeOverlay() async { + if (!mounted || _overlayClosing) return; + _overlayClosing = true; + try { + await _overlayController.reverse(); + } catch (_) {} + if (!mounted) return; + onOverlayDismiss(); + } + + @override + void dispose() { + _overlayController.dispose(); + super.dispose(); + } +} diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart index 625c116..9142cfe 100644 --- a/lib/frontend/widgets/attachment/attachment_sheet.dart +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -11,13 +11,16 @@ import 'package:komet/frontend/widgets/attachment/photo_editor.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:komet/frontend/widgets/sheet_helpers.dart'; import 'package:komet/frontend/widgets/sliding_pill_nav.dart'; +import 'package:komet/l10n/app_localizations.dart'; -const List _navItems = [ - PillNavItem(icon: Symbols.image, label: 'Галерея'), - PillNavItem(icon: Symbols.description, label: 'Файл'), - PillNavItem(icon: Symbols.location_on, label: 'Геопозиция'), - PillNavItem(icon: Symbols.bar_chart, label: 'Опрос'), - PillNavItem(icon: Symbols.person, label: 'Контакт'), +const int _navItemCount = 5; + +List _buildNavItems(AppLocalizations l10n) => [ + PillNavItem(icon: Symbols.image, label: l10n.attachSheetGallery), + PillNavItem(icon: Symbols.description, label: l10n.scheduledAttachFile), + PillNavItem(icon: Symbols.location_on, label: l10n.scheduledAttachLocation), + PillNavItem(icon: Symbols.bar_chart, label: l10n.attachSheetPoll), + PillNavItem(icon: Symbols.person, label: l10n.nfcPeerFirstNameFallback), ]; Future showAttachmentSheet( @@ -71,6 +74,8 @@ class _AttachmentSheetState extends State { final GallerySource _source = GallerySource.create(); final ValueNotifier> _selected = ValueNotifier({}); final Map _edits = {}; + final Set _tempFiles = {}; + final Set _sentFiles = {}; final TextEditingController _captionCtrl = TextEditingController(); final PageController _pageController = PageController(); @@ -101,6 +106,10 @@ class _AttachmentSheetState extends State { _pageController.dispose(); _selected.dispose(); _captionCtrl.dispose(); + for (final path in _tempFiles) { + if (_sentFiles.contains(path)) continue; + File(path).delete().then((_) {}, onError: (_) {}); + } super.dispose(); } @@ -149,6 +158,7 @@ class _AttachmentSheetState extends State { }, initialCaption: _captionCtrl.text, onCaptionChanged: (text) => _captionCtrl.text = text, + tempFiles: _tempFiles, ), ), ); @@ -163,7 +173,10 @@ class _AttachmentSheetState extends State { } void _onCameraTap() { - showCustomNotification(context, 'Камера скоро появится'); + showCustomNotification( + context, + AppLocalizations.of(context)!.attachSheetCameraComingSoon, + ); } void _sendSelection({GalleryItem? fallback}) { @@ -175,6 +188,12 @@ class _AttachmentSheetState extends State { .map((it) => PickedPhoto(item: it, editedFile: _edits[it.id]?.working)) .toList(); final callback = widget.onSend; + if (callback != null) { + for (final photo in picked) { + final path = photo.editedFile?.path; + if (path != null) _sentFiles.add(path); + } + } final caption = _captionCtrl.text.trim(); Navigator.of(context).pop(); callback?.call(picked, caption); @@ -215,7 +234,9 @@ class _AttachmentSheetState extends State { Positioned( right: 16, bottom: - barReserve + 8 + MediaQuery.viewInsetsOf(context).bottom, + barReserve + + 8 + + MediaQuery.viewInsetsOf(context).bottom, child: AnimatedBuilder( animation: Listenable.merge([ _selected, @@ -267,6 +288,7 @@ class _AttachmentSheetState extends State { ColorScheme cs, double bottomReserve, ) { + final l10n = AppLocalizations.of(context)!; return PageView( controller: _pageController, children: [ @@ -277,27 +299,27 @@ class _AttachmentSheetState extends State { cs, bottomReserve, icon: Symbols.description, - title: 'Отправить файл', - subtitle: 'Документ, архив или любой другой файл', - buttonLabel: 'Выбрать файл', + title: l10n.attachSheetSendFileTitle, + subtitle: l10n.attachSheetSendFileSubtitle, + buttonLabel: l10n.attachSheetChooseFileButton, onTap: widget.onPickFile, ), _buildActionPage( cs, bottomReserve, icon: Symbols.location_on, - title: 'Поделиться геопозицией', - subtitle: 'Отправить ваше текущее местоположение', - buttonLabel: 'Отправить геопозицию', + title: l10n.attachSheetShareLocationTitle, + subtitle: l10n.attachSheetShareLocationSubtitle, + buttonLabel: l10n.attachSheetSendLocationButton, onTap: widget.onShareLocation, ), _buildActionPage( cs, bottomReserve, icon: Symbols.bar_chart, - title: 'Создать опрос', - subtitle: 'Вопрос с вариантами ответа', - buttonLabel: 'Создать опрос', + title: l10n.attachSheetCreatePoll, + subtitle: l10n.attachSheetCreatePollSubtitle, + buttonLabel: l10n.attachSheetCreatePoll, onTap: widget.onCreatePoll, ), _buildPlaceholderPage(cs, bottomReserve), @@ -379,7 +401,7 @@ class _AttachmentSheetState extends State { return _buildMessage( scrollController, cs, - 'Изображений не найдено', + AppLocalizations.of(context)!.attachSheetNoImagesFound, bottomReserve, ); } @@ -420,7 +442,12 @@ class _AttachmentSheetState extends State { ), ), SliverPadding( - padding: EdgeInsets.fromLTRB(hpad, spacing, hpad, bottomReserve + 6), + padding: EdgeInsets.fromLTRB( + hpad, + spacing, + hpad, + bottomReserve + 6, + ), sliver: SliverGrid( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, @@ -489,6 +516,7 @@ class _AttachmentSheetState extends State { } Widget _buildLimitedBanner(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return InkWell( onTap: () => _source.manageAccess().then((_) => _loadGallery()), child: Container( @@ -500,12 +528,12 @@ class _AttachmentSheetState extends State { const SizedBox(width: 10), Expanded( child: Text( - 'Доступны не все фото', + l10n.attachSheetLimitedAccessInfo, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), ), Text( - 'Изменить', + l10n.loginEdit, style: TextStyle( color: cs.primary, fontSize: 13, @@ -528,7 +556,7 @@ class _AttachmentSheetState extends State { Icon(Symbols.construction, size: 48, color: cs.onSurfaceVariant), const SizedBox(height: 12), Text( - 'Раздел в разработке', + AppLocalizations.of(context)!.attachSheetSectionInProgress, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15), ), ], @@ -542,6 +570,7 @@ class _AttachmentSheetState extends State { ColorScheme cs, double bottomReserve, ) { + final l10n = AppLocalizations.of(context)!; return _scrollableCenter( scrollController, bottomReserve, @@ -553,13 +582,13 @@ class _AttachmentSheetState extends State { Icon(Symbols.no_photography, size: 48, color: cs.onSurfaceVariant), const SizedBox(height: 12), Text( - 'Нет доступа к галерее', + l10n.attachSheetNoGalleryAccessTitle, textAlign: TextAlign.center, style: TextStyle(color: cs.onSurface, fontSize: 16), ), const SizedBox(height: 4), Text( - 'Разрешите доступ к фото, чтобы выбрать их отсюда', + l10n.attachSheetNoGalleryAccessSubtitle, textAlign: TextAlign.center, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), @@ -569,12 +598,12 @@ class _AttachmentSheetState extends State { children: [ TextButton( onPressed: _loadGallery, - child: const Text('Разрешить'), + child: Text(l10n.attachSheetAllow), ), const SizedBox(width: 8), TextButton( onPressed: () => _source.openSettings(), - child: const Text('Настройки'), + child: Text(l10n.attachSheetSettings), ), ], ), @@ -648,7 +677,7 @@ class _AttachmentSheetState extends State { _navDragAccumDx += dx; final pageT = (_navDragBasePageT + _navDragAccumDx / inactiveWidth).clamp( 0.0, - (_navItems.length - 1).toDouble(), + (_navItemCount - 1).toDouble(), ); _pageController.jumpTo(pageT * _pageController.position.viewportDimension); } @@ -656,7 +685,7 @@ class _AttachmentSheetState extends State { void _onPillDragEnd() { if (!_navDragging) return; _navDragging = false; - final target = _currentPageT().round().clamp(0, _navItems.length - 1); + final target = _currentPageT().round().clamp(0, _navItemCount - 1); _pageController.animateToPage( target, duration: _navAnim, @@ -691,12 +720,13 @@ class _AttachmentSheetState extends State { } Widget _buildPillNav() { + final navItems = _buildNavItems(AppLocalizations.of(context)!); return LayoutBuilder( key: const ValueKey('nav'), builder: (context, constraints) { final geometry = PillNavGeometry.fromInnerWidth( constraints.maxWidth - 4, - _navItems.length, + navItems.length, ); return GestureDetector( behavior: HitTestBehavior.opaque, @@ -710,7 +740,7 @@ class _AttachmentSheetState extends State { builder: (context, _) { final cs = Theme.of(context).colorScheme; return SlidingPillNav( - items: _navItems, + items: navItems, position: _currentPageT(), geometry: geometry, onTap: _onSectionTap, @@ -725,6 +755,7 @@ class _AttachmentSheetState extends State { } Widget _buildCaptionBar(ColorScheme cs) { + final l10n = AppLocalizations.of(context)!; return SizedBox( key: const ValueKey('caption'), height: SlidingPillNav.height, @@ -747,7 +778,7 @@ class _AttachmentSheetState extends State { decoration: InputDecoration( isCollapsed: true, border: InputBorder.none, - hintText: 'Добавить подпись...', + hintText: l10n.attachSheetAddCaptionHint, hintStyle: TextStyle( color: cs.onSurfaceVariant, fontSize: 15, @@ -808,7 +839,7 @@ class _CameraTile extends StatelessWidget { ), const SizedBox(height: 6), Text( - 'Камера', + AppLocalizations.of(context)!.attachSheetCamera, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), ), ], diff --git a/lib/frontend/widgets/attachment/bubbles/bubble_context.dart b/lib/frontend/widgets/attachment/bubbles/bubble_context.dart new file mode 100644 index 0000000..d4a6e0a --- /dev/null +++ b/lib/frontend/widgets/attachment/bubbles/bubble_context.dart @@ -0,0 +1,188 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../backend/modules/messages.dart'; +import '../../../../core/config/app_colors.dart'; +import '../../../../core/config/komet_settings.dart'; +import '../../../../core/utils/format.dart'; +import '../../../../models/attachment.dart'; +import '../../formatted_message_text.dart'; + +enum MessageType { text, attachment, voice, control } + +enum BubbleShape { singleTop, singleBottom, singleMiddle, groupedMiddle } + +final Expando<({bool full, String text})> _clockTextCache = Expando(); + +({IconData icon, Color color}) messageStatusVisual( + String? status, { + required Color dimColor, + Color readColor = kReadReceiptBlue, + Color errorColor = Colors.redAccent, +}) { + switch (status) { + case 'sending': + case 'pending': + return (icon: Symbols.schedule, color: dimColor); + case null: + case 'sent': + return (icon: Symbols.check, color: dimColor); + case 'delivered': + return (icon: Symbols.done_all, color: dimColor); + case 'read': + return (icon: Symbols.done_all, color: readColor); + case 'error': + return (icon: Symbols.error, color: errorColor); + default: + return (icon: Symbols.check, color: dimColor); + } +} + +class BubbleContext { + static const double photoMaxSize = 280.0; + static const double photoMinSize = 100.0; + static const double photoBorderRadius = 12.0; + static const double bubbleBorderRadius = 20.0; + static const double captionPaddingHorizontal = 6.0; + static const double captionPaddingRight = 4.0; + static const double compactTimePadding = 8.0; + + final BuildContext context; + final ColorScheme cs; + final Color text; + final Color dim; + final BubbleShape shape; + final MessageType contentType; + final bool hasPhotoWithCaption; + final bool hasMultiplePhotosNoCaption; + final Map? reactionInfo; + + final CachedMessage message; + final bool isMe; + final int myId; + final String chatType; + final String? overrideStatus; + final ValueListenable? otherReadTime; + final ValueListenable>? uploadProgress; + final void Function(StickerAttachment sticker)? onStickerTap; + + BubbleContext({ + required this.context, + required this.cs, + required this.text, + required this.shape, + required this.contentType, + required this.hasPhotoWithCaption, + required this.hasMultiplePhotosNoCaption, + required this.message, + required this.isMe, + required this.myId, + required this.chatType, + this.overrideStatus, + this.otherReadTime, + this.uploadProgress, + this.onStickerTap, + this.reactionInfo, + }) : dim = text.withValues(alpha: 0.7); + + String get clockText { + final full = KometSettings.fullTimestamp.value; + final cached = _clockTextCache[message]; + if (cached != null && cached.full == full) return cached.text; + final t = formatClock( + DateTime.fromMillisecondsSinceEpoch(message.time), + withSeconds: full, + ); + _clockTextCache[message] = (full: full, text: t); + return t; + } + + Color get systemTint => cs.onPrimaryContainer.withValues(alpha: 0.12); + + Widget caption() { + final style = TextStyle(color: text, fontSize: 16, height: 1.3); + final ranges = message.formatRanges; + if (FormattedMessageText.isFormatted(message.text, ranges)) { + return FormattedMessageText( + text: message.text!, + ranges: ranges, + style: style, + ); + } + return Text(message.text ?? '', style: style); + } + + Widget meta() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text(clockText, style: TextStyle(color: dim, fontSize: 11)), + if (isMe) ...[const SizedBox(width: 4), statusIcon()], + if (message.deleted) ...[const SizedBox(width: 4), deletedIcon()], + ], + ), + ); + } + + Widget compactTime() { + final bgColor = isMe + ? Colors.black.withValues(alpha: 0.4) + : Colors.black.withValues(alpha: 0.5); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + clockText, + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + if (message.deleted) ...[ + const SizedBox(width: 3), + const Icon(Symbols.delete, size: 11, color: Colors.white), + ], + ], + ), + ); + } + + Widget deletedIcon() => Icon(Symbols.delete, size: 13, color: dim); + + Widget statusIcon() { + final base = overrideStatus ?? message.status; + final rt = otherReadTime; + if (rt == null) return _statusIconFor(base); + return ValueListenableBuilder( + valueListenable: rt, + builder: (context, readTime, _) => + _statusIconFor(_readUpgradedStatus(base, readTime)), + ); + } + + String? _readUpgradedStatus(String? base, int readTime) { + if ((base == null || base == 'sent') && + readTime > 0 && + readTime >= message.time) { + return 'read'; + } + return base; + } + + Widget _statusIconFor(String? status) { + final v = messageStatusVisual(status, dimColor: dim); + return Icon(v.icon, size: 14, color: v.color); + } +} diff --git a/lib/frontend/widgets/attachment/bubbles/call_bubble.dart b/lib/frontend/widgets/attachment/bubbles/call_bubble.dart new file mode 100644 index 0000000..9b29faf --- /dev/null +++ b/lib/frontend/widgets/attachment/bubbles/call_bubble.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../core/utils/format.dart'; +import '../../../../models/attachment.dart'; +import 'bubble_context.dart'; + +class CallBubble extends StatelessWidget { + final BubbleContext ctx; + final CallAttachment call; + + const CallBubble({super.key, required this.ctx, required this.call}); + + @override + Widget build(BuildContext context) { + final isMe = ctx.isMe; + final missed = call.isMissedOrFailed; + final accent = isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary; + final iconColor = missed ? ctx.cs.error : accent; + + final IconData icon; + final String label; + if (call.isGroup) { + icon = call.isVideo ? Symbols.videocam : Symbols.groups; + label = call.isVideo ? 'Групповой видеозвонок' : 'Групповой звонок'; + } else if (call.isVideo) { + icon = Symbols.videocam; + label = missed + ? (isMe ? 'Отменённый видеозвонок' : 'Пропущенный видеозвонок') + : (isMe ? 'Исходящий видеозвонок' : 'Входящий видеозвонок'); + } else { + icon = Symbols.call; + label = missed + ? (isMe ? 'Отменённый звонок' : 'Пропущенный звонок') + : (isMe ? 'Исходящий звонок' : 'Входящий звонок'); + } + + final directionIcon = isMe ? Symbols.call_made : Symbols.call_received; + + final subtitle = missed + ? ctx.clockText + : '${ctx.clockText} · ${formatSecondsMmSs((call.durationMs / 1000).round())}'; + + return Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 16, 10), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 38, + height: 38, + alignment: Alignment.center, + decoration: BoxDecoration( + color: missed + ? ctx.cs.error.withValues(alpha: 0.12) + : (isMe ? ctx.systemTint : ctx.cs.primaryContainer), + shape: BoxShape.circle, + ), + child: Icon(icon, color: iconColor, size: 20), + ), + const SizedBox(width: 10), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + color: ctx.text, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + directionIcon, + size: 13, + color: missed ? ctx.cs.error : ctx.dim, + ), + const SizedBox(width: 3), + Text( + subtitle, + style: TextStyle( + color: ctx.dim, + fontSize: 12, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/frontend/widgets/attachment/bubbles/contact_bubble.dart b/lib/frontend/widgets/attachment/bubbles/contact_bubble.dart new file mode 100644 index 0000000..5392da9 --- /dev/null +++ b/lib/frontend/widgets/attachment/bubbles/contact_bubble.dart @@ -0,0 +1,114 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../core/config/app_colors.dart'; +import '../../../../models/attachment.dart'; +import 'bubble_context.dart'; + +Widget buildContactCard( + BubbleContext ctx, { + String? firstName, + String? lastName, + String? name, + String? photoUrl, + String? phoneNumber, +}) { + final isMe = ctx.isMe; + + final first = firstName ?? ''; + final last = lastName ?? ''; + final hasFirstName = first.isNotEmpty; + final hasLastName = last.isNotEmpty; + + final resolvedName = (hasFirstName || hasLastName) + ? '${hasFirstName ? first : ''}${hasLastName ? ' $last' : ''}'.trim() + : (name ?? 'Contact'); + + final bgColor = isMe ? ctx.systemTint : ctx.cs.surfaceContainerHighest; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(24), + ), + child: photoUrl != null && photoUrl.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(24), + child: CachedNetworkImage( + imageUrl: photoUrl, + fit: BoxFit.cover, + memCacheWidth: kAvatarThumbSize, + memCacheHeight: kAvatarThumbSize, + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, _, _) => Icon( + Symbols.person, + color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, + size: 24, + ), + ), + ) + : Icon( + Symbols.person, + color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, + size: 24, + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + resolvedName.isNotEmpty ? resolvedName : 'Contact', + style: TextStyle( + color: ctx.text, + fontSize: 15, + fontWeight: FontWeight.w500, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (phoneNumber != null) ...[ + const SizedBox(height: 2), + Text( + phoneNumber, + style: TextStyle(color: ctx.dim, fontSize: 12, height: 1.2), + ), + ], + ], + ), + ), + ], + ), + ); +} + +class ContactBubble extends StatelessWidget { + final BubbleContext ctx; + final ContactAttachment contact; + + const ContactBubble({super.key, required this.ctx, required this.contact}); + + @override + Widget build(BuildContext context) { + return buildContactCard( + ctx, + firstName: contact.firstName, + lastName: contact.lastName, + name: contact.name, + photoUrl: contact.photoUrl ?? contact.baseUrl, + phoneNumber: contact.phoneNumber, + ); + } +} diff --git a/lib/frontend/widgets/attachment/bubbles/file_bubble.dart b/lib/frontend/widgets/attachment/bubbles/file_bubble.dart new file mode 100644 index 0000000..0ee86af --- /dev/null +++ b/lib/frontend/widgets/attachment/bubbles/file_bubble.dart @@ -0,0 +1,194 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:komet/main.dart'; + +import '../../../../core/utils/download_progress.dart'; +import '../../../../core/utils/file_download.dart'; +import '../../../../core/utils/format.dart'; +import '../../../../core/utils/haptics.dart'; +import '../../../../models/attachment.dart'; +import '../../custom_notification.dart'; +import 'bubble_context.dart'; + +class FileBubble extends StatelessWidget { + final BubbleContext ctx; + final FileAttachment file; + final bool fill; + + const FileBubble({ + super.key, + required this.ctx, + required this.file, + this.fill = false, + }); + + @override + Widget build(BuildContext context) { + final isMe = ctx.isMe; + final name = file.name ?? 'File'; + final size = file.size ?? 0; + final sizeStr = formatBytes(size); + final fileId = file.fileId; + final cacheName = '${fileId}_$name'; + + final preview = file.preview; + final previewUrl = preview?.baseUrl ?? preview?.previewData ?? ''; + + final inner = Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (previewUrl.isNotEmpty) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: CachedNetworkImage( + imageUrl: previewUrl, + width: 240, + height: 160, + fit: BoxFit.cover, + memCacheWidth: 480, + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ), + const SizedBox(height: 8), + ], + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: isMe ? ctx.systemTint : ctx.cs.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Symbols.description, + color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, + size: 20, + ), + ), + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name, + style: TextStyle( + color: ctx.text, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.2, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + ValueListenableBuilder( + valueListenable: MediaDownloadProgress.notifier( + cacheName, + ), + builder: (context, progress, _) => Text( + progress != null + ? '${(progress * 100).round()}% · $sizeStr' + : sizeStr, + style: TextStyle( + color: ctx.dim, + fontSize: 12, + height: 1.2, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 12), + ValueListenableBuilder( + valueListenable: MediaDownloadProgress.notifier(cacheName), + builder: (context, progress, _) { + final downloading = progress != null; + return GestureDetector( + onTap: downloading + ? null + : () => _downloadFile(ctx.context, file, name), + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: isMe + ? ctx.systemTint + : ctx.cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: downloading + ? Padding( + padding: const EdgeInsets.all(8), + child: CircularProgressIndicator( + strokeWidth: 2, + value: progress > 0 ? progress : null, + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, + ), + ) + : Icon( + Symbols.download, + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, + size: 18, + ), + ), + ); + }, + ), + ], + ), + ctx.meta(), + ], + ), + ); + return fill ? inner : IntrinsicWidth(child: inner); + } + + Future _downloadFile( + BuildContext context, + FileAttachment file, + String name, + ) async { + final fileId = file.fileId; + if (fileId == null) { + showCustomNotification(context, 'Не удалось определить файл'); + return; + } + Haptics.tap(); + + final cacheName = '${fileId}_$name'; + + MediaDownloadProgress.set(cacheName, 0); + final result = await openCachedFile( + cacheName, + () => messagesModule.getFileUrl( + messageId: ctx.message.id, + chatId: ctx.message.chatId, + fileId: fileId, + ), + onProgress: (p) => MediaDownloadProgress.set(cacheName, p), + ); + MediaDownloadProgress.set(cacheName, null); + if (!context.mounted) return; + if (!result.ok) { + showCustomNotification( + context, + 'Ошибка загрузки: ${result.error ?? 'не удалось открыть'}', + ); + } + } +} diff --git a/lib/frontend/widgets/attachment/bubbles/forwarded_bubble.dart b/lib/frontend/widgets/attachment/bubbles/forwarded_bubble.dart new file mode 100644 index 0000000..10b73d5 --- /dev/null +++ b/lib/frontend/widgets/attachment/bubbles/forwarded_bubble.dart @@ -0,0 +1,201 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../backend/modules/messages.dart'; +import '../../../../models/attachment.dart'; +import 'bubble_context.dart'; +import 'contact_bubble.dart'; +import 'file_bubble.dart'; +import 'photo_bubble.dart'; +import 'sticker_bubble.dart'; + +Widget _forwardedHeader( + BubbleContext ctx, + ForwardedMessageAttachment forwarded, +) { + final headerColor = ctx.dim; + final displaySender = + forwarded.originalSenderName ?? + ContactCache.get(forwarded.originalSenderId) ?? + forwarded.originalSenderId.toString(); + final senderAvatar = + forwarded.originalSenderAvatar ?? + ContactCache.getAvatar(forwarded.originalSenderId); + return Padding( + padding: const EdgeInsets.only(left: 8, top: 8, right: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: CachedNetworkImageProvider( + senderAvatar, + maxWidth: 96, + maxHeight: 96, + ), + backgroundColor: ctx.cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: ctx.cs.primaryContainer, + child: Text( + displaySender.isNotEmpty ? displaySender[0].toUpperCase() : '?', + style: TextStyle(fontSize: 9, color: ctx.cs.onPrimaryContainer), + ), + ), + const SizedBox(width: 6), + Flexible( + child: Text( + displaySender, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); +} + +class ForwardedPhotoBubble extends StatelessWidget { + final BubbleContext ctx; + final ForwardedMessageAttachment forwarded; + final List photos; + + const ForwardedPhotoBubble({ + super.key, + required this.ctx, + required this.forwarded, + required this.photos, + }); + + @override + Widget build(BuildContext context) { + final message = ctx.message; + final hasCaption = message.text != null && message.text!.isNotEmpty; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _forwardedHeader(ctx, forwarded), + const SizedBox(height: 4), + if (hasCaption) ...[ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + message.text ?? '', + style: TextStyle(color: ctx.text, fontSize: 16, height: 1.3), + ), + ), + const SizedBox(height: 6), + ], + PhotoBubble(ctx: ctx, photos: photos), + ], + ); + } +} + +class ForwardedGenericBubble extends StatelessWidget { + final BubbleContext ctx; + final ForwardedMessageAttachment forwarded; + final List attachments; + + const ForwardedGenericBubble({ + super.key, + required this.ctx, + required this.forwarded, + required this.attachments, + }); + + @override + Widget build(BuildContext context) { + return IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + _forwardedHeader(ctx, forwarded), + const SizedBox(height: 4), + ...attachments.map((a) { + if (a is FileAttachment) { + return FileBubble(ctx: ctx, file: a, fill: true); + } + if (a is StickerAttachment) { + return StickerBubble(ctx: ctx, sticker: a); + } + return const SizedBox.shrink(); + }), + ], + ), + ); + } +} + +class ForwardedStickerBubble extends StatelessWidget { + final BubbleContext ctx; + final ForwardedMessageAttachment forwarded; + final MessageAttachment sticker; + + const ForwardedStickerBubble({ + super.key, + required this.ctx, + required this.forwarded, + required this.sticker, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _forwardedHeader(ctx, forwarded), + const SizedBox(height: 4), + StickerBubble(ctx: ctx, sticker: sticker), + ], + ); + } +} + +class ForwardedContactBubble extends StatelessWidget { + final BubbleContext ctx; + final ForwardedMessageAttachment forwarded; + + const ForwardedContactBubble({ + super.key, + required this.ctx, + required this.forwarded, + }); + + @override + Widget build(BuildContext context) { + final contact = forwarded.originalContact!; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _forwardedHeader(ctx, forwarded), + const SizedBox(height: 4), + buildContactCard( + ctx, + firstName: contact.firstName, + lastName: contact.lastName, + name: contact.name, + photoUrl: contact.photoUrl ?? contact.baseUrl, + phoneNumber: contact.phoneNumber, + ), + ], + ); + } +} diff --git a/lib/frontend/widgets/attachment/bubbles/location_bubble.dart b/lib/frontend/widgets/attachment/bubbles/location_bubble.dart new file mode 100644 index 0000000..52ac244 --- /dev/null +++ b/lib/frontend/widgets/attachment/bubbles/location_bubble.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../core/utils/haptics.dart'; +import '../../../../core/utils/link_opener.dart'; +import '../../../../models/attachment.dart'; +import 'bubble_context.dart'; + +class LocationBubble extends StatelessWidget { + final BubbleContext ctx; + final LocationAttachment location; + + const LocationBubble({super.key, required this.ctx, required this.location}); + + @override + Widget build(BuildContext context) { + final isMe = ctx.isMe; + final lat = location.latitude; + final lon = location.longitude; + final coords = lat != null && lon != null + ? '${lat.toStringAsFixed(6)}, ${lon.toStringAsFixed(6)}' + : null; + + return Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), + child: IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: lat == null || lon == null + ? null + : () { + Haptics.tap(); + openLocationOnMap( + ctx.context, + lat, + lon, + zoom: location.zoom, + ); + }, + child: Container( + width: 240, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isMe + ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.08) + : ctx.cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: isMe ? ctx.systemTint : ctx.cs.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Symbols.location_on, + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, + size: 22, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + location.title ?? 'Геопозиция', + style: TextStyle( + color: ctx.text, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + location.address ?? coords ?? 'Открыть на карте', + style: TextStyle(color: ctx.dim, fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ), + ctx.meta(), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart b/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart new file mode 100644 index 0000000..0c0826a --- /dev/null +++ b/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart @@ -0,0 +1,420 @@ +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../models/attachment.dart'; +import '../../photo_viewer.dart'; +import 'bubble_context.dart'; + +class PhotoBubble extends StatelessWidget { + static const Radius _bigRadius = Radius.circular( + BubbleContext.bubbleBorderRadius, + ); + static const Radius _smallRadius = Radius.circular(4); + static const Radius _photoRadius = Radius.circular( + BubbleContext.photoBorderRadius, + ); + + final BubbleContext ctx; + final List photos; + + const PhotoBubble({super.key, required this.ctx, required this.photos}); + + @override + Widget build(BuildContext context) { + final message = ctx.message; + final hasCaption = message.text != null && message.text!.isNotEmpty; + final count = photos.length; + + Widget photosWidget; + if (count == 1) { + photosWidget = _buildSinglePhoto(ctx, photos[0]); + } else if (count == 2) { + photosWidget = _buildTwoPhotos(ctx, photos[0], photos[1]); + } else { + photosWidget = _buildPhotoGrid(ctx, photos); + } + + if (!hasCaption) { + return Stack( + children: [ + photosWidget, + Positioned( + bottom: BubbleContext.compactTimePadding, + right: BubbleContext.compactTimePadding, + child: ctx.compactTime(), + ), + ], + ); + } + + if (count == 1) { + final photo = photos[0]; + final pw = photo.width?.toDouble() ?? 200; + final photoWidth = pw.clamp( + BubbleContext.photoMinSize, + BubbleContext.photoMaxSize, + ); + + return SizedBox( + width: photoWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + photosWidget, + Padding( + padding: const EdgeInsets.only( + left: BubbleContext.captionPaddingHorizontal, + right: BubbleContext.captionPaddingRight, + bottom: 6, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded(child: ctx.caption()), + ctx.meta(), + ], + ), + ), + ], + ), + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + photosWidget, + Padding( + padding: const EdgeInsets.only( + left: BubbleContext.captionPaddingHorizontal, + right: BubbleContext.captionPaddingRight, + bottom: 6, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded(child: ctx.caption()), + ctx.meta(), + ], + ), + ), + ], + ); + } + + Widget _buildSinglePhoto(BubbleContext ctx, PhotoAttachment photo) { + final width = photo.width?.toDouble() ?? 200; + final height = photo.height?.toDouble() ?? 200; + + final constrainedWidth = width.clamp( + BubbleContext.photoMinSize, + BubbleContext.photoMaxSize, + ); + final constrainedHeight = height.clamp( + BubbleContext.photoMinSize, + BubbleContext.photoMaxSize, + ); + final dpr = MediaQuery.of(ctx.context).devicePixelRatio; + + final matchTop = ctx.hasPhotoWithCaption; + final matchBottom = !ctx.hasPhotoWithCaption; + + final topR = matchTop ? _bigRadius : _photoRadius; + final bottomL = matchBottom + ? (ctx.isMe ? _bigRadius : _smallRadius) + : _smallRadius; + final bottomR = matchBottom + ? (ctx.isMe ? _smallRadius : _bigRadius) + : _smallRadius; + + return ClipRRect( + borderRadius: BorderRadius.only( + topLeft: topR, + topRight: topR, + bottomLeft: bottomL, + bottomRight: bottomR, + ), + child: Stack( + children: [ + _buildPhotoImage( + ctx, + photo, + constrainedWidth, + constrainedHeight, + memWidth: (constrainedWidth * dpr).round(), + memHeight: (constrainedHeight * dpr).round(), + ), + if (ctx.uploadProgress != null) + _buildUploadOverlay(ctx.uploadProgress!, 0), + if (ctx.uploadProgress == null) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _openPhotoViewer(ctx.context, photo), + ), + ), + ], + ), + ); + } + + Widget _buildPhotoImage( + BubbleContext ctx, + PhotoAttachment photo, + double width, + double height, { + required int memWidth, + required int memHeight, + }) { + final localPath = photo.localPath; + if (localPath != null) { + return Image.file( + File(localPath), + width: width, + height: height, + fit: BoxFit.cover, + cacheWidth: memWidth, + gaplessPlayback: true, + errorBuilder: (_, _, _) => + _buildPhotoPlaceholder(ctx.cs, width, height), + ); + } + final imageUrl = photo.baseUrl ?? ''; + if (imageUrl.isNotEmpty) { + return CachedNetworkImage( + imageUrl: imageUrl, + width: width, + height: height, + fit: BoxFit.cover, + memCacheWidth: memWidth, + memCacheHeight: memHeight, + fadeInDuration: Duration.zero, + placeholderFadeInDuration: Duration.zero, + errorWidget: (_, _, _) => _buildPhotoPlaceholder(ctx.cs, width, height), + ); + } + return _buildPhotoPlaceholder(ctx.cs, width, height); + } + + Widget _buildUploadOverlay( + ValueListenable> progress, + int index, + ) { + return Positioned.fill( + child: ValueListenableBuilder>( + valueListenable: progress, + builder: (context, values, _) { + final value = index < values.length ? values[index] : 1.0; + final indeterminate = value <= 0 || value >= 1.0; + return Container( + color: Colors.black.withValues(alpha: 0.4), + alignment: Alignment.center, + child: SizedBox( + width: 34, + height: 34, + child: CircularProgressIndicator( + strokeWidth: 2.5, + value: indeterminate ? null : value, + color: Colors.white, + ), + ), + ); + }, + ), + ); + } + + BorderRadius _multiPhotoCornerRadius({ + required bool matchTop, + required bool matchBottom, + required bool isMe, + }) { + final topR = matchTop ? _bigRadius : _photoRadius; + final bottomL = matchBottom ? _smallRadius : _photoRadius; + final bottomR = matchBottom + ? (isMe ? _smallRadius : _bigRadius) + : _photoRadius; + return BorderRadius.only( + topLeft: topR, + topRight: topR, + bottomLeft: bottomL, + bottomRight: bottomR, + ); + } + + Widget _buildTwoPhotos( + BubbleContext ctx, + PhotoAttachment p1, + PhotoAttachment p2, + ) { + final matchTop = + ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleTop; + final matchBottom = + ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleBottom; + + return ClipRRect( + borderRadius: _multiPhotoCornerRadius( + matchTop: matchTop, + matchBottom: matchBottom, + isMe: ctx.isMe, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded(child: _buildPhotoTile(ctx, p1, 0)), + const SizedBox(width: 2), + Expanded(child: _buildPhotoTile(ctx, p2, 1)), + ], + ), + ); + } + + Widget _buildPhotoGrid(BubbleContext ctx, List photos) { + final displayCount = photos.length > 4 ? 4 : photos.length; + final remaining = photos.length - 4; + + final matchTop = + ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleTop; + final matchBottom = + ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleBottom; + + return ClipRRect( + borderRadius: _multiPhotoCornerRadius( + matchTop: matchTop, + matchBottom: matchBottom, + isMe: ctx.isMe, + ), + child: GridView.count( + crossAxisCount: 2, + mainAxisSpacing: 2, + crossAxisSpacing: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: List.generate(displayCount, (i) { + if (i == 3 && remaining > 0) { + return _buildPhotoTileWithOverlay(ctx, photos[i], '+$remaining', i); + } + return _buildPhotoTile(ctx, photos[i], i); + }), + ), + ); + } + + Widget _buildPhotoTile(BubbleContext ctx, PhotoAttachment photo, int index) { + final cachePx = + (BubbleContext.photoMaxSize / + 2 * + MediaQuery.of(ctx.context).devicePixelRatio) + .round(); + return AspectRatio( + aspectRatio: 1, + child: Stack( + children: [ + _buildPhotoImage( + ctx, + photo, + double.infinity, + double.infinity, + memWidth: cachePx, + memHeight: cachePx, + ), + if (ctx.uploadProgress != null) + _buildUploadOverlay(ctx.uploadProgress!, index), + if (ctx.uploadProgress == null) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _openPhotoViewer(ctx.context, photo), + ), + ), + ], + ), + ); + } + + Widget _buildPhotoTileWithOverlay( + BubbleContext ctx, + PhotoAttachment photo, + String overlay, + int index, + ) { + final cachePx = + (BubbleContext.photoMaxSize / + 2 * + MediaQuery.of(ctx.context).devicePixelRatio) + .round(); + return AspectRatio( + aspectRatio: 1, + child: Stack( + children: [ + _buildPhotoImage( + ctx, + photo, + double.infinity, + double.infinity, + memWidth: cachePx, + memHeight: cachePx, + ), + Positioned.fill( + child: Container( + color: Colors.black45, + child: Center( + child: Text( + overlay, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + if (ctx.uploadProgress != null) + _buildUploadOverlay(ctx.uploadProgress!, index), + ], + ), + ); + } + + Widget _buildPhotoPlaceholder( + ColorScheme cs, + double w, + double h, { + VoidCallback? onRetry, + }) { + return Container( + width: w, + height: h, + color: cs.surfaceContainerHighest, + child: onRetry != null + ? Center( + child: IconButton( + icon: Icon(Symbols.refresh, color: cs.onSurfaceVariant), + onPressed: onRetry, + tooltip: 'Retry', + ), + ) + : Center( + child: Icon(Symbols.image, size: 48, color: cs.onSurfaceVariant), + ), + ); + } + + void _openPhotoViewer(BuildContext context, PhotoAttachment photo) { + final url = photo.baseUrl ?? ''; + if (url.isEmpty) return; + Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => PhotoViewerScreen(baseUrl: url), + ), + ); + } +} diff --git a/lib/frontend/widgets/attachment/bubbles/poll_bubble.dart b/lib/frontend/widgets/attachment/bubbles/poll_bubble.dart new file mode 100644 index 0000000..ca67681 --- /dev/null +++ b/lib/frontend/widgets/attachment/bubbles/poll_bubble.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import '../../../../models/attachment.dart'; +import '../../poll_view.dart'; +import 'bubble_context.dart'; + +class PollBubble extends StatelessWidget { + final BubbleContext ctx; + final PollAttachment poll; + + const PollBubble({super.key, required this.ctx, required this.poll}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + child: IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + PollView( + chatId: ctx.message.chatId, + messageId: ctx.message.id, + pollId: poll.pollId, + myId: ctx.myId, + fallbackTitle: poll.title ?? ctx.message.text, + textColor: ctx.text, + dimColor: ctx.dim, + accentColor: ctx.isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, + ), + ctx.meta(), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/attachment/bubbles/share_bubble.dart b/lib/frontend/widgets/attachment/bubbles/share_bubble.dart new file mode 100644 index 0000000..f8b510b --- /dev/null +++ b/lib/frontend/widgets/attachment/bubbles/share_bubble.dart @@ -0,0 +1,145 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../../../../core/utils/haptics.dart'; +import '../../../../core/utils/link_opener.dart'; +import '../../../../models/attachment.dart'; +import '../../formatted_message_text.dart'; +import 'bubble_context.dart'; + +class ShareBubble extends StatelessWidget { + final BubbleContext ctx; + final ShareAttachment share; + + const ShareBubble({super.key, required this.ctx, required this.share}); + + @override + Widget build(BuildContext context) { + final isMe = ctx.isMe; + final message = ctx.message; + final hasText = message.text != null && message.text!.isNotEmpty; + final image = share.image; + final imageUrl = image?.baseUrl ?? image?.previewData ?? ''; + final cardColor = isMe + ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.08) + : ctx.cs.surfaceContainerHigh; + final host = + share.host ?? + (share.url != null ? Uri.tryParse(share.url!)?.host : null); + + final card = GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: share.url == null + ? null + : () { + Haptics.tap(); + openExternalUrl(ctx.context, share.url!); + }, + child: Container( + decoration: BoxDecoration( + color: cardColor, + borderRadius: BorderRadius.circular(12), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (imageUrl.isNotEmpty) + CachedNetworkImage( + imageUrl: imageUrl, + width: 280, + height: 140, + fit: BoxFit.cover, + memCacheWidth: 560, + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + Padding( + padding: const EdgeInsets.fromLTRB(10, 8, 10, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (host != null && host.isNotEmpty) ...[ + Text( + host, + style: TextStyle( + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + ], + if (share.title != null && share.title!.isNotEmpty) + Text( + share.title!, + style: TextStyle( + color: ctx.text, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.25, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (share.description != null && + share.description!.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + share.description!, + style: TextStyle( + color: ctx.dim, + fontSize: 13, + height: 1.25, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), + ), + ); + + return Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (hasText) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: FormattedMessageText( + text: message.text!, + ranges: message.formatRanges, + style: TextStyle( + color: ctx.text, + fontSize: 16, + height: 1.3, + ), + ), + ), + const SizedBox(height: 6), + ], + card, + ctx.meta(), + ], + ), + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/attachment/bubbles/sticker_bubble.dart b/lib/frontend/widgets/attachment/bubbles/sticker_bubble.dart new file mode 100644 index 0000000..1cd38f0 --- /dev/null +++ b/lib/frontend/widgets/attachment/bubbles/sticker_bubble.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../models/attachment.dart'; +import '../../sticker_image.dart'; +import 'bubble_context.dart'; + +class StickerBubble extends StatelessWidget { + final BubbleContext ctx; + final MessageAttachment sticker; + + const StickerBubble({super.key, required this.ctx, required this.sticker}); + + @override + Widget build(BuildContext context) { + final url = sticker.baseUrl ?? ''; + final preview = sticker.previewData ?? ''; + final staticUrl = url.isNotEmpty ? url : preview; + final lottieUrl = sticker is StickerAttachment + ? (sticker as StickerAttachment).lottieUrl + : null; + + Widget content = Stack( + children: [ + SizedBox( + width: 150, + height: 150, + child: StickerImage( + url: staticUrl, + lottieUrl: lottieUrl, + size: 150, + memCacheWidth: 300, + ), + ), + Positioned( + bottom: BubbleContext.compactTimePadding, + right: BubbleContext.compactTimePadding, + child: _buildStickerMeta(), + ), + ], + ); + + final onTap = ctx.onStickerTap; + if (onTap != null && sticker is StickerAttachment) { + final s = sticker as StickerAttachment; + content = GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onTap(s), + child: content, + ); + } + return content; + } + + Widget _buildStickerMeta() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + ctx.clockText, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + if (ctx.isMe) ...[ + const SizedBox(width: 3), + _buildStickerStatusIcon(), + ], + if (ctx.message.deleted) ...[ + const SizedBox(width: 3), + const Icon(Symbols.delete, size: 12, color: Colors.white), + ], + ], + ), + ); + } + + Widget _buildStickerStatusIcon() { + final status = ctx.overrideStatus ?? ctx.message.status; + final v = messageStatusVisual(status, dimColor: Colors.white); + return Icon(v.icon, size: 13, color: v.color); + } +} diff --git a/lib/frontend/widgets/attachment/bubbles/video_bubble.dart b/lib/frontend/widgets/attachment/bubbles/video_bubble.dart new file mode 100644 index 0000000..0d8e9f7 --- /dev/null +++ b/lib/frontend/widgets/attachment/bubbles/video_bubble.dart @@ -0,0 +1,192 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:komet/main.dart'; + +import '../../../../core/utils/format.dart'; +import '../../../../core/utils/haptics.dart'; +import '../../../../models/attachment.dart'; +import '../../custom_notification.dart'; +import '../../video_player_screen.dart'; +import 'bubble_context.dart'; +import 'video_note_bubble.dart'; + +class VideoBubble extends StatelessWidget { + final BubbleContext ctx; + final VideoAttachment video; + + const VideoBubble({super.key, required this.ctx, required this.video}); + + @override + Widget build(BuildContext context) { + final message = ctx.message; + if (video.isNote) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + VideoNoteBubble( + attachment: video, + messageId: message.id, + chatId: message.chatId, + cs: ctx.cs, + ), + const SizedBox(height: 6), + ctx.meta(), + ], + ); + } + final hasCaption = message.text != null && message.text!.isNotEmpty; + final thumb = video.thumbnail; + final durationMs = video.duration; + final previewUrl = (thumb != null && thumb.isNotEmpty) + ? thumb + : (video.baseUrl != null && video.baseUrl!.isNotEmpty) + ? video.baseUrl! + : (video.previewData ?? ''); + + final w = video.width; + final h = video.height; + final width = (w?.toDouble() ?? 200.0).clamp( + BubbleContext.photoMinSize, + BubbleContext.photoMaxSize, + ); + final height = (h?.toDouble() ?? 150.0).clamp( + BubbleContext.photoMinSize, + BubbleContext.photoMaxSize, + ); + final dpr = MediaQuery.of(ctx.context).devicePixelRatio; + + Widget placeholder() => Container( + width: width, + height: height, + color: ctx.cs.surfaceContainerHighest, + child: Icon(Symbols.videocam, size: 48, color: ctx.cs.onSurfaceVariant), + ); + + final preview = ClipRRect( + borderRadius: BorderRadius.circular(BubbleContext.photoBorderRadius), + child: Stack( + children: [ + previewUrl.isEmpty + ? placeholder() + : CachedNetworkImage( + imageUrl: previewUrl, + width: width, + height: height, + fit: BoxFit.cover, + memCacheWidth: (width * dpr).round(), + fadeInDuration: Duration.zero, + placeholderFadeInDuration: Duration.zero, + errorWidget: (_, _, _) => placeholder(), + ), + Positioned.fill( + child: Center( + child: Container( + width: 48, + height: 48, + decoration: const BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: const Icon( + Symbols.play_arrow, + color: Colors.white, + size: 30, + ), + ), + ), + ), + if (durationMs != null && durationMs > 0) + Positioned( + left: 6, + bottom: 6, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + formatSecondsMmSs((durationMs / 1000).round()), + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ), + ), + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _playVideo(ctx.context, video), + ), + ), + ], + ), + ); + + if (!hasCaption) { + return Stack( + children: [ + preview, + Positioned( + bottom: BubbleContext.compactTimePadding, + right: BubbleContext.compactTimePadding, + child: ctx.compactTime(), + ), + ], + ); + } + + return SizedBox( + width: width, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + preview, + Padding( + padding: const EdgeInsets.only( + left: BubbleContext.captionPaddingHorizontal, + right: BubbleContext.captionPaddingRight, + bottom: 6, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded(child: ctx.caption()), + ctx.meta(), + ], + ), + ), + ], + ), + ); + } + + Future _playVideo(BuildContext context, VideoAttachment video) async { + final videoId = video.videoId; + final token = video.videoToken; + if (videoId == null || token == null) { + showCustomNotification(context, 'Не удалось открыть видео'); + return; + } + Haptics.tap(); + + final sources = await messagesModule.getVideoSources( + messageId: ctx.message.id, + chatId: ctx.message.chatId, + token: token, + videoId: videoId, + ); + if (!context.mounted) return; + if (sources.isEmpty) { + showCustomNotification(context, 'Не удалось получить видео'); + return; + } + + Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => VideoPlayerScreen(sources: sources), + ), + ); + } +} diff --git a/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart b/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart new file mode 100644 index 0000000..30b59e4 --- /dev/null +++ b/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart @@ -0,0 +1,201 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:video_player/video_player.dart'; +import 'package:komet/main.dart'; + +import '../../../../core/utils/haptics.dart'; +import '../../../../core/utils/logger.dart'; +import '../../../../core/utils/media_cache.dart'; +import '../../../../models/attachment.dart'; + +class VideoNoteBubble extends StatefulWidget { + final VideoAttachment attachment; + final String messageId; + final int chatId; + final ColorScheme cs; + + const VideoNoteBubble({ + super.key, + required this.attachment, + required this.messageId, + required this.chatId, + required this.cs, + }); + + @override + State createState() => _VideoNoteBubbleState(); +} + +class _VideoNoteBubbleState extends State { + static const double _size = 210; + VideoPlayerController? _controller; + bool _loading = false; + bool _error = false; + + @override + void dispose() { + _controller?.removeListener(_onTick); + _controller?.dispose(); + super.dispose(); + } + + void _onTick() { + if (mounted) setState(() {}); + } + + static Uint8List? _previewBytes(String? data) { + if (data == null) return null; + const marker = 'base64,'; + final idx = data.indexOf(marker); + if (idx < 0) return null; + try { + return base64Decode(data.substring(idx + marker.length)); + } catch (_) { + return null; + } + } + + Future _toggle() async { + final existing = _controller; + if (existing != null) { + setState( + () => existing.value.isPlaying ? existing.pause() : existing.play(), + ); + return; + } + if (_loading) return; + + final a = widget.attachment; + final videoId = a.videoId; + final token = a.videoToken; + if (videoId == null || token == null) { + setState(() => _error = true); + return; + } + + setState(() => _loading = true); + Haptics.tap(); + try { + final cacheName = 'videonote_$videoId.mp4'; + var file = await MediaCache.existing(cacheName); + if (file == null) { + final url = await messagesModule.getVideoUrl( + messageId: widget.messageId, + chatId: widget.chatId, + token: token, + videoId: videoId, + ); + if (url == null) throw Exception('no_url'); + file = await MediaCache.getOrDownload(cacheName, url); + if (file == null) throw Exception('download'); + } + if (!mounted) return; + final c = VideoPlayerController.file(file); + _controller = c; + await c.initialize(); + if (!mounted) { + c.dispose(); + return; + } + await c.setLooping(true); + c.addListener(_onTick); + c.play(); + setState(() => _loading = false); + } catch (e) { + logger.w('VideoNoteBubble._toggle: $e'); + if (mounted) { + setState(() { + _loading = false; + _error = true; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final a = widget.attachment; + final c = _controller; + final ready = c != null && c.value.isInitialized; + final playing = ready && c.value.isPlaying; + final preview = _previewBytes(a.previewData); + + double progress = 0; + if (ready && c.value.duration.inMilliseconds > 0) { + progress = + c.value.position.inMilliseconds / c.value.duration.inMilliseconds; + } + + return GestureDetector( + onTap: _toggle, + child: SizedBox( + width: _size, + height: _size, + child: Stack( + alignment: Alignment.center, + children: [ + ClipOval( + child: SizedBox( + width: _size, + height: _size, + child: ready + ? FittedBox( + fit: BoxFit.cover, + clipBehavior: Clip.hardEdge, + child: SizedBox( + width: c.value.size.width, + height: c.value.size.height, + child: VideoPlayer(c), + ), + ) + : preview != null + ? Image.memory( + preview, + fit: BoxFit.cover, + gaplessPlayback: true, + ) + : Container(color: widget.cs.surfaceContainerHighest), + ), + ), + if (ready) + SizedBox( + width: _size - 2, + height: _size - 2, + child: CircularProgressIndicator( + value: progress.clamp(0.0, 1.0), + strokeWidth: 3, + color: widget.cs.primary, + backgroundColor: Colors.white24, + ), + ), + if (!playing) + Container( + width: 52, + height: 52, + decoration: const BoxDecoration( + color: Colors.black45, + shape: BoxShape.circle, + ), + child: _loading + ? const Padding( + padding: EdgeInsets.all(14), + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Icon( + _error ? Symbols.error : Symbols.play_arrow, + color: Colors.white, + size: 30, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart b/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart new file mode 100644 index 0000000..db11091 --- /dev/null +++ b/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart @@ -0,0 +1,515 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:ogg_opus_player/ogg_opus_player.dart'; +import 'package:komet/main.dart'; + +import '../../../../backend/modules/messages.dart'; +import '../../../../core/config/app_colors.dart'; +import '../../../../core/config/komet_settings.dart'; +import '../../../../core/utils/format.dart'; +import '../../../../core/utils/logger.dart'; +import '../../../../core/utils/media_cache.dart'; +import '../../custom_notification.dart'; + +class VoiceMessageBubble extends StatefulWidget { + final int duration; + final String url; + final Color textColor; + final bool isMe; + final bool deleted; + final String? status; + final ValueListenable? otherReadTime; + final int time; + final ColorScheme cs; + final String? waveData; + final int chatId; + final String messageId; + final int? audioId; + final String? preloadedText; + + const VoiceMessageBubble({ + super.key, + required this.duration, + required this.url, + required this.textColor, + required this.isMe, + this.deleted = false, + this.status, + this.otherReadTime, + required this.time, + required this.cs, + this.waveData, + required this.chatId, + required this.messageId, + this.audioId, + this.preloadedText, + }); + + @override + State createState() => _VoiceMessageBubbleState(); +} + +class _VoiceMessageBubbleState extends State { + bool _isPlaying = false; + final ValueNotifier _progress = ValueNotifier(0.0); + bool _transcriptionVisible = false; + String? _transcriptionText; + bool _transcriptionLoading = false; + + OggOpusPlayer? _player; + bool _loadingAudio = false; + Timer? _ticker; + late final List _amps = _parseWave(widget.waveData); + + static List _parseWave(String? data) { + if (data == null || data.isEmpty) return const []; + return data.codeUnits; + } + + @override + void initState() { + super.initState(); + _transcriptionText = widget.preloadedText; + } + + @override + void dispose() { + _ticker?.cancel(); + _player?.state.removeListener(_onPlayerState); + _player?.dispose(); + _progress.dispose(); + super.dispose(); + } + + Future _togglePlay() async { + if (_loadingAudio) return; + + if (_player != null) { + if (_isPlaying) { + _player!.pause(); + } else { + if (widget.duration > 0 && + _player!.currentPosition >= widget.duration - 0.05) { + _progress.value = 0; + } + _player!.play(); + } + return; + } + + final url = widget.url; + if (url.isEmpty) return; + + setState(() => _loadingAudio = true); + try { + final name = '${widget.audioId ?? widget.messageId}.ogg'; + final file = await MediaCache.getOrDownload(name, url); + if (!mounted) return; + if (file == null) { + showCustomNotification(context, 'Не удалось загрузить аудио'); + return; + } + final player = OggOpusPlayer(file.path); + _player = player; + player.state.addListener(_onPlayerState); + _ticker = Timer.periodic( + const Duration(milliseconds: 60), + (_) => _onTick(), + ); + player.play(); + } catch (e) { + logger.w('VoiceBubble._togglePlay: $e'); + if (mounted) showCustomNotification(context, 'Ошибка воспроизведения'); + } finally { + if (mounted) setState(() => _loadingAudio = false); + } + } + + void _onTick() { + final player = _player; + if (player == null || widget.duration <= 0) return; + final pos = player.currentPosition; + _progress.value = (pos / widget.duration).clamp(0.0, 1.0); + } + + void _onPlayerState() { + final state = _player?.state.value; + if (!mounted) return; + final playing = state == PlayerState.playing; + if (playing != _isPlaying) setState(() => _isPlaying = playing); + if (state == PlayerState.ended) { + _progress.value = 1.0; + } + } + + Widget _buildStatusIcon() { + final rt = widget.otherReadTime; + if (rt == null) return _statusIconFor(widget.status); + return ValueListenableBuilder( + valueListenable: rt, + builder: (context, readTime, _) => + _statusIconFor(_upgradedStatus(readTime)), + ); + } + + String? _upgradedStatus(int readTime) { + final base = widget.status; + if ((base == null || base == 'sent') && + readTime > 0 && + readTime >= widget.time) { + return 'read'; + } + return base; + } + + Widget _statusIconFor(String? status) { + IconData icon; + Color color; + + if (status == null || status == 'sent') { + icon = Symbols.check; + color = Colors.white54; + } else { + switch (status) { + case 'sending': + case 'pending': + icon = Symbols.schedule; + color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55); + case 'sent': + icon = Symbols.check; + color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55); + case 'delivered': + icon = Symbols.done_all; + color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55); + case 'read': + icon = Symbols.done_all; + color = kReadReceiptBlue; + case 'error': + icon = Symbols.error; + color = Colors.redAccent; + default: + icon = Symbols.check; + color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55); + } + } + + return Icon(icon, size: 14, color: color); + } + + @override + Widget build(BuildContext context) { + final waveInactiveColor = widget.isMe + ? widget.cs.onPrimaryContainer.withValues(alpha: 0.35) + : widget.cs.surfaceContainerHighest; + final waveActiveColor = widget.isMe + ? widget.cs.onPrimaryContainer.withValues(alpha: 0.7) + : widget.cs.primary; + + return SizedBox( + width: 240, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + GestureDetector( + onTap: _togglePlay, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: widget.isMe + ? widget.cs.onPrimaryContainer.withValues(alpha: 0.12) + : widget.cs.primaryContainer, + shape: BoxShape.circle, + ), + child: _loadingAudio + ? Padding( + padding: const EdgeInsets.all(8), + child: CircularProgressIndicator( + strokeWidth: 2, + color: widget.isMe + ? widget.cs.onPrimaryContainer + : widget.cs.primary, + ), + ) + : Icon( + _isPlaying ? Symbols.pause : Symbols.play_arrow, + color: widget.isMe + ? widget.cs.onPrimaryContainer + : widget.cs.primary, + size: 18, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: SizedBox( + height: 26, + child: ValueListenableBuilder( + valueListenable: _progress, + builder: (context, progress, _) => CustomPaint( + size: Size.infinite, + painter: _WaveformPainter( + amps: _amps, + progress: progress, + active: waveActiveColor, + inactive: waveInactiveColor, + ), + ), + ), + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: _requestTranscription, + child: SizedBox( + width: 20, + height: 32, + child: Center( + child: _transcriptionLoading + ? SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: widget.textColor.withValues(alpha: 0.6), + ), + ) + : Text( + 'Т', + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.6), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 32, + child: Center( + child: Text( + formatSecondsMmSs(widget.duration), + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.7), + fontSize: 11, + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + alignment: Alignment.topLeft, + child: _transcriptionVisible + ? Text( + _transcriptionText ?? '', + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.8), + fontSize: 12, + height: 1.3, + ), + maxLines: 10, + overflow: TextOverflow.ellipsis, + ) + : const SizedBox.shrink(), + ), + ), + if (!_transcriptionVisible) ...[ + Text( + formatClock( + DateTime.fromMillisecondsSinceEpoch(widget.time), + withSeconds: KometSettings.fullTimestamp.value, + ), + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.6), + fontSize: 10, + ), + ), + if (widget.isMe) ...[ + const SizedBox(width: 2), + _buildStatusIcon(), + ], + if (widget.deleted) ...[ + const SizedBox(width: 2), + Icon( + Symbols.delete, + size: 13, + color: widget.textColor.withValues(alpha: 0.6), + ), + ], + ], + ], + ), + if (_transcriptionVisible) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + formatClock( + DateTime.fromMillisecondsSinceEpoch(widget.time), + withSeconds: KometSettings.fullTimestamp.value, + ), + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.6), + fontSize: 10, + ), + ), + if (widget.isMe) ...[ + const SizedBox(width: 2), + _buildStatusIcon(), + ], + if (widget.deleted) ...[ + const SizedBox(width: 2), + Icon( + Symbols.delete, + size: 13, + color: widget.textColor.withValues(alpha: 0.6), + ), + ], + ], + ), + ], + ], + ), + ); + } + + Future _requestTranscription() async { + if (widget.audioId == null) return; + + if (_transcriptionVisible && _transcriptionText != null) { + setState(() { + _transcriptionVisible = false; + }); + return; + } + + if (TranscriptionCache.has(widget.messageId)) { + final cached = TranscriptionCache.get(widget.messageId)!; + setState(() { + _transcriptionText = cached.text ?? 'не удалось распознать текст'; + _transcriptionVisible = true; + }); + return; + } + + setState(() { + _transcriptionLoading = true; + }); + + try { + final result = await messagesModule.requestTranscription( + widget.chatId, + int.tryParse(widget.messageId) ?? 0, + widget.audioId!, + ); + + TranscriptionCache.put(widget.messageId, result); + + if (!mounted) return; + setState(() { + _transcriptionLoading = false; + if (result.status == 1) { + _transcriptionText = (result.text == null || result.text!.isEmpty) + ? 'не удалось распознать текст' + : result.text; + _transcriptionVisible = true; + } else if (result.status == 0) { + _transcriptionText = 'транскрибация...'; + _transcriptionVisible = true; + } + }); + } catch (e) { + logger.w('VoiceBubble._requestTranscription: $e'); + if (!mounted) return; + setState(() { + _transcriptionLoading = false; + _transcriptionText = 'ошибка транскрибации'; + _transcriptionVisible = true; + }); + } + } +} + +class _WaveformPainter extends CustomPainter { + final List amps; + final double progress; + final Color active; + final Color inactive; + + const _WaveformPainter({ + required this.amps, + required this.progress, + required this.active, + required this.inactive, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = size.height / 2; + + if (amps.isEmpty) { + final track = Paint() + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round; + canvas.drawLine( + Offset(0, center), + Offset(size.width, center), + track..color = inactive, + ); + if (progress > 0) { + canvas.drawLine( + Offset(0, center), + Offset(size.width * progress.clamp(0.0, 1.0), center), + track..color = active, + ); + } + return; + } + + final n = amps.length; + var maxAmp = 1; + for (final a in amps) { + if (a > maxAmp) maxAmp = a; + } + final slot = size.width / n; + final barW = (slot * 0.55).clamp(1.0, 3.0); + final paint = Paint(); + + for (var i = 0; i < n; i++) { + final h = ((amps[i] / maxAmp) * size.height).clamp(2.0, size.height); + final x = i * slot + (slot - barW) / 2; + paint.color = ((i + 0.5) / n) <= progress ? active : inactive; + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(x, center - h / 2, barW, h), + Radius.circular(barW / 2), + ), + paint, + ); + } + } + + @override + bool shouldRepaint(_WaveformPainter old) => + old.progress != progress || + old.active != active || + old.inactive != inactive || + !identical(old.amps, amps); +} diff --git a/lib/frontend/widgets/attachment/media_preview_screen.dart b/lib/frontend/widgets/attachment/media_preview_screen.dart index 423c062..bc8dfe9 100644 --- a/lib/frontend/widgets/attachment/media_preview_screen.dart +++ b/lib/frontend/widgets/attachment/media_preview_screen.dart @@ -9,7 +9,8 @@ import 'package:komet/core/media/gallery_source.dart'; import 'package:komet/frontend/widgets/attachment/photo_editor.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; -const Color _kAccent = Color(0xFF2F8FFF); +import '../../../core/config/app_colors.dart'; + const Color _kBar = Color(0xFF1E1E1E); class MediaPreviewScreen extends StatefulWidget { @@ -22,6 +23,7 @@ class MediaPreviewScreen extends StatefulWidget { final ValueChanged? onEditChanged; final String initialCaption; final ValueChanged? onCaptionChanged; + final Set tempFiles; const MediaPreviewScreen({ super.key, @@ -29,6 +31,7 @@ class MediaPreviewScreen extends StatefulWidget { required this.selectedIds, required this.onToggleSelection, required this.onSend, + required this.tempFiles, this.title, this.editState, this.onEditChanged, @@ -51,9 +54,7 @@ class _MediaPreviewScreenState extends State { @override void initState() { super.initState(); - _caption.addListener( - () => widget.onCaptionChanged?.call(_caption.text), - ); + _caption.addListener(() => widget.onCaptionChanged?.call(_caption.text)); _cropState = widget.editState?.cropState; _cropSource = widget.editState?.cropSource; _resolveWorkingFile(); @@ -104,14 +105,14 @@ class _MediaPreviewScreenState extends State { void _disposeTemp(File? file, Set keep) { if (file == null || keep.contains(file.path)) return; - if (!file.uri.pathSegments.last.startsWith('komet_')) return; + if (!widget.tempFiles.remove(file.path)) return; file.delete().then((_) {}, onError: (_) {}); } Future _openCrop() async { if (_workingFile == null) return; - final source = - _cropSource ??= widget.item.localFile ?? await widget.item.originFile(); + final source = _cropSource ??= + widget.item.localFile ?? await widget.item.originFile(); if (source == null || !mounted) return; final result = await _pushEditor( PhotoCropEditor(source: source, initialState: _cropState), @@ -119,6 +120,7 @@ class _MediaPreviewScreenState extends State { if (result != null && mounted) { final old = _workingFile; _cropState = result.state; + widget.tempFiles.add(result.file.path); setState(() => _workingFile = result.file); _reportEdit(); _disposeTemp(old, {result.file.path, _cropSource?.path ?? ''}); @@ -135,17 +137,14 @@ class _MediaPreviewScreenState extends State { return; } final result = await _pushEditor( - PhotoDrawEditor( - source: file, - imageWidth: dims.$1, - imageHeight: dims.$2, - ), + PhotoDrawEditor(source: file, imageWidth: dims.$1, imageHeight: dims.$2), ); if (result != null && mounted) { final oldWorking = _workingFile; final oldCropSource = _cropSource; _cropSource = result; _cropState = null; + widget.tempFiles.add(result.path); setState(() => _workingFile = result); _reportEdit(); _disposeTemp(oldWorking, {result.path}); @@ -162,6 +161,7 @@ class _MediaPreviewScreenState extends State { final oldCropSource = _cropSource; _cropSource = result; _cropState = null; + widget.tempFiles.add(result.path); setState(() => _workingFile = result); _reportEdit(); _disposeTemp(oldWorking, {result.path}); @@ -334,7 +334,7 @@ class _SelectionToggle extends StatelessWidget { alignment: Alignment.center, decoration: BoxDecoration( shape: BoxShape.circle, - color: isSelected ? _kAccent : Colors.transparent, + color: isSelected ? kEditorAccent : Colors.transparent, border: Border.all(color: Colors.white, width: 2), ), child: isSelected @@ -444,7 +444,7 @@ class _FileToggleState extends State<_FileToggle> { builder: (context, t, _) { final color = Color.lerp( Colors.white54, - Color.lerp(Colors.white, _kAccent, 0.4), + Color.lerp(Colors.white, kEditorAccent, 0.4), t, ); return Icon(Symbols.description, color: color, size: 24); @@ -462,7 +462,7 @@ class _SendButton extends StatelessWidget { @override Widget build(BuildContext context) { return Material( - color: _kAccent, + color: kEditorAccent, shape: const CircleBorder(), child: InkWell( customBorder: const CircleBorder(), diff --git a/lib/frontend/widgets/attachment/photo_editor.dart b/lib/frontend/widgets/attachment/photo_editor.dart index 5c9bb01..d5a4fb8 100644 --- a/lib/frontend/widgets/attachment/photo_editor.dart +++ b/lib/frontend/widgets/attachment/photo_editor.dart @@ -6,12 +6,14 @@ import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; -import 'package:komet/core/utils/image_utils.dart'; +import 'package:komet/core/media/raster.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; +import '../../../core/config/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; +import '../small_spinner.dart'; + const Color _kPanel = Color(0xFF0A0A0A); class CropState { @@ -329,7 +331,10 @@ class _PhotoCropEditorState extends State { if (!mounted) return; if (file == null) { setState(() => _baking = false); - showCustomNotification(context, 'Не удалось применить'); + showCustomNotification( + context, + AppLocalizations.of(context)!.photoEditorApplyFailed, + ); return; } Navigator.of(context).pop(CropResult(file, state)); @@ -374,23 +379,7 @@ class _PhotoCropEditorState extends State { Paint()..filterQuality = FilterQuality.high, ); final picture = recorder.endRecording(); - final rendered = await picture.toImage(pxW, pxH); - picture.dispose(); - final bd = await rendered.toByteData(format: ui.ImageByteFormat.rawRgba); - rendered.dispose(); - if (bd == null) return null; - - final jpeg = await encodeRgbaToJpeg(bd.buffer.asUint8List(), pxW, pxH); - if (jpeg == null) return null; - final dir = await getTemporaryDirectory(); - final out = File( - p.join( - dir.path, - 'komet_crop_${DateTime.now().microsecondsSinceEpoch}.jpg', - ), - ); - await out.writeAsBytes(jpeg); - return out; + return await rasterPictureToJpegFile(picture, pxW, pxH, prefix: 'crop'); } catch (_) { return null; } @@ -447,6 +436,7 @@ class _PhotoCropEditorState extends State { } Widget _buildTools() { + final l10n = AppLocalizations.of(context)!; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: Row( @@ -455,9 +445,9 @@ class _PhotoCropEditorState extends State { onPressed: _flip, icon: Icon( Symbols.flip, - color: _flipH ? const Color(0xFF2F8FFF) : Colors.white, + color: _flipH ? kEditorAccent : Colors.white, ), - tooltip: 'Отразить', + tooltip: l10n.photoEditorFlipTooltip, ), Expanded( child: ValueListenableBuilder( @@ -477,7 +467,7 @@ class _PhotoCropEditorState extends State { Symbols.rotate_90_degrees_ccw, color: Colors.white, ), - tooltip: 'Повернуть', + tooltip: l10n.photoEditorRotateTooltip, ), ], ), @@ -485,6 +475,7 @@ class _PhotoCropEditorState extends State { } Widget _buildActions() { + final l10n = AppLocalizations.of(context)!; return Container( color: _kPanel, padding: const EdgeInsets.symmetric(vertical: 6), @@ -493,24 +484,24 @@ class _PhotoCropEditorState extends State { children: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: const Text( - 'ОТМЕНА', - style: TextStyle(color: Colors.white, fontSize: 15), + child: Text( + l10n.photoEditorCancel, + style: const TextStyle(color: Colors.white, fontSize: 15), ), ), TextButton( onPressed: _reset, - child: const Text( - 'СБРОС', - style: TextStyle(color: Colors.white, fontSize: 15), + child: Text( + l10n.photoEditorReset, + style: const TextStyle(color: Colors.white, fontSize: 15), ), ), TextButton( onPressed: _baking ? null : _done, child: Text( - 'ГОТОВО', + l10n.photoEditorDone, style: TextStyle( - color: _baking ? Colors.white38 : const Color(0xFF2F8FFF), + color: _baking ? Colors.white38 : kEditorAccent, fontSize: 15, fontWeight: FontWeight.w600, ), @@ -647,7 +638,7 @@ class _RulerPainter extends CustomPainter { Offset(cx, baseY - 18), Offset(cx, baseY + 2), Paint() - ..color = const Color(0xFF2F8FFF) + ..color = kEditorAccent ..strokeWidth = 2 ..strokeCap = StrokeCap.round, ); @@ -898,6 +889,7 @@ class _PhotoDrawEditorState extends State { } Future _addText() async { + final l10n = AppLocalizations.of(context)!; final controller = TextEditingController(); final String? text; try { @@ -905,26 +897,29 @@ class _PhotoDrawEditorState extends State { context: context, builder: (ctx) => AlertDialog( backgroundColor: const Color(0xFF1E1E1E), - title: const Text('Текст', style: TextStyle(color: Colors.white)), + title: Text( + l10n.photoEditorTextDialogTitle, + style: const TextStyle(color: Colors.white), + ), content: TextField( controller: controller, autofocus: true, style: const TextStyle(color: Colors.white), cursorColor: Colors.white, - decoration: const InputDecoration( - hintText: 'Введите текст', - hintStyle: TextStyle(color: Colors.white38), + decoration: InputDecoration( + hintText: l10n.photoEditorTextDialogHint, + hintStyle: const TextStyle(color: Colors.white38), ), onSubmitted: (v) => Navigator.pop(ctx, v), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('Отмена'), + child: Text(l10n.spoofDialogCancel), ), TextButton( onPressed: () => Navigator.pop(ctx, controller.text), - child: const Text('ОК'), + child: Text(l10n.photoEditorOk), ), ], ), @@ -958,7 +953,10 @@ class _PhotoDrawEditorState extends State { if (!mounted) return; if (file == null) { setState(() => _baking = false); - showCustomNotification(context, 'Не удалось применить изменения'); + showCustomNotification( + context, + AppLocalizations.of(context)!.photoEditorApplyChangesFailed, + ); return; } Navigator.of(context).pop(file); @@ -995,29 +993,7 @@ class _PhotoDrawEditorState extends State { image.dispose(); codec.dispose(); - final rendered = await picture.toImage(outW, outH); - picture.dispose(); - final byteData = await rendered.toByteData( - format: ui.ImageByteFormat.rawRgba, - ); - rendered.dispose(); - if (byteData == null) return null; - - final jpeg = await encodeRgbaToJpeg( - byteData.buffer.asUint8List(), - outW, - outH, - ); - if (jpeg == null) return null; - final dir = await getTemporaryDirectory(); - final out = File( - p.join( - dir.path, - 'komet_edit_${DateTime.now().microsecondsSinceEpoch}.jpg', - ), - ); - await out.writeAsBytes(jpeg); - return out; + return await rasterPictureToJpegFile(picture, outW, outH, prefix: 'edit'); } catch (_) { return null; } @@ -1037,15 +1013,7 @@ class _PhotoDrawEditorState extends State { ], ), if (_tab == _EditTab.draw) _buildSideSlider(), - if (_baking) - const Positioned.fill( - child: ColoredBox( - color: Colors.black54, - child: Center( - child: CircularProgressIndicator(color: Colors.white), - ), - ), - ), + if (_baking) const BusyOverlay(), ], ), ); @@ -1068,7 +1036,7 @@ class _PhotoDrawEditorState extends State { TextButton( onPressed: _marks.isEmpty ? null : _clearAll, child: Text( - 'Очистить всё', + AppLocalizations.of(context)!.photoEditorClearAll, style: TextStyle( color: _marks.isEmpty ? Colors.white24 : Colors.white, fontSize: 15, @@ -1244,9 +1212,9 @@ class _PhotoDrawEditorState extends State { TextButton.icon( onPressed: _addText, icon: const Icon(Symbols.add, color: Colors.white), - label: const Text( - 'Добавить текст', - style: TextStyle(color: Colors.white, fontSize: 15), + label: Text( + AppLocalizations.of(context)!.photoEditorAddText, + style: const TextStyle(color: Colors.white, fontSize: 15), ), ), const Spacer(), @@ -1271,9 +1239,9 @@ class _PhotoDrawEditorState extends State { colors: [ Color(0xFFFF3B30), Color(0xFFFFCC00), - Color(0xFF34C759), + kOnlineGreen, Color(0xFF00C7BE), - Color(0xFF2F8FFF), + kEditorAccent, Color(0xFFAF52DE), Color(0xFFFF3B30), ], @@ -1356,6 +1324,7 @@ class _PhotoDrawEditorState extends State { } Widget _buildTabs() { + final l10n = AppLocalizations.of(context)!; return SizedBox( height: 48, child: Row( @@ -1368,9 +1337,13 @@ class _PhotoDrawEditorState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _buildTab('РИСУНОК', _EditTab.draw), - _buildTab('СТИКЕРЫ', _EditTab.stickers, disabled: true), - _buildTab('ТЕКСТ', _EditTab.text), + _buildTab(l10n.photoEditorTabDraw, _EditTab.draw), + _buildTab( + l10n.photoEditorTabStickers, + _EditTab.stickers, + disabled: true, + ), + _buildTab(l10n.photoEditorTabText, _EditTab.text), ], ), ), @@ -1703,7 +1676,7 @@ class _SelectionPainter extends CustomPainter { _dashedLine(canvas, bl, tl, border); final fill = Paint() - ..color = const Color(0xFF2F8FFF) + ..color = kEditorAccent ..style = PaintingStyle.fill; final ring = Paint() ..color = Colors.white @@ -1888,8 +1861,6 @@ class _ColorPickerState extends State<_ColorPicker> { } } -const Color _kAccent = Color(0xFF2F8FFF); - enum BlurMode { off, radial, linear } enum _Tab { adjust, blur, curves } @@ -2320,24 +2291,15 @@ class _PhotoAdjustEditorState extends State { } final picture = recorder.endRecording(); - final rendered = await picture.toImage(outW, outH); - picture.dispose(); - if (curved != img) curved.dispose(); - final bd = await rendered.toByteData(format: ui.ImageByteFormat.rawRgba); - rendered.dispose(); - if (bd == null) return null; - - final jpeg = await encodeRgbaToJpeg(bd.buffer.asUint8List(), outW, outH); - if (jpeg == null) return null; - final dir = await getTemporaryDirectory(); - final out = File( - p.join( - dir.path, - 'komet_adj_${DateTime.now().microsecondsSinceEpoch}.jpg', - ), + return await rasterPictureToJpegFile( + picture, + outW, + outH, + prefix: 'adj', + onPictureDisposed: () { + if (curved != img) curved.dispose(); + }, ); - await out.writeAsBytes(jpeg); - return out; } catch (_) { return null; } @@ -2354,7 +2316,10 @@ class _PhotoAdjustEditorState extends State { if (!mounted) return; if (file == null) { setState(() => _baking = false); - showCustomNotification(context, 'Не удалось применить'); + showCustomNotification( + context, + AppLocalizations.of(context)!.photoEditorApplyFailed, + ); return; } Navigator.of(context).pop(file); @@ -2374,15 +2339,7 @@ class _PhotoAdjustEditorState extends State { _buildBottomBar(), ], ), - if (_baking) - const Positioned.fill( - child: ColoredBox( - color: Colors.black54, - child: Center( - child: CircularProgressIndicator(color: Colors.white), - ), - ), - ), + if (_baking) const BusyOverlay(), ], ), ), @@ -2516,7 +2473,13 @@ class _PhotoAdjustEditorState extends State { } Widget _buildCurves() { - const labels = ['Все', 'Красный', 'Зелёный', 'Синий']; + final l10n = AppLocalizations.of(context)!; + final labels = [ + l10n.photoEditorChannelAll, + l10n.photoEditorChannelRed, + l10n.photoEditorChannelGreen, + l10n.photoEditorChannelBlue, + ]; return SizedBox( height: 110, child: Row( @@ -2573,23 +2536,54 @@ class _PhotoAdjustEditorState extends State { return ValueListenableBuilder( valueListenable: _rev, builder: (context, _, _) { + final l10n = AppLocalizations.of(context)!; return Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: Column( mainAxisSize: MainAxisSize.min, children: [ - _slider('Улучшение', _enhance, 0, 1, (v) => _enhance = v), - _slider('Экспозиция', _exposure, -1, 1, (v) => _exposure = v), - _slider('Контраст', _contrast, -1, 1, (v) => _contrast = v), _slider( - 'Насыщенность', + l10n.photoEditorEnhance, + _enhance, + 0, + 1, + (v) => _enhance = v, + ), + _slider( + l10n.photoEditorExposure, + _exposure, + -1, + 1, + (v) => _exposure = v, + ), + _slider( + l10n.photoEditorContrast, + _contrast, + -1, + 1, + (v) => _contrast = v, + ), + _slider( + l10n.photoEditorSaturation, _saturation, -1, 1, (v) => _saturation = v, ), - _slider('Тёплость', _warmth, -1, 1, (v) => _warmth = v), - _slider('Виньетка', _vignette, 0, 1, (v) => _vignette = v), + _slider( + l10n.photoEditorWarmth, + _warmth, + -1, + 1, + (v) => _warmth = v, + ), + _slider( + l10n.photoEditorVignette, + _vignette, + 0, + 1, + (v) => _vignette = v, + ), ], ), ); @@ -2640,14 +2634,23 @@ class _PhotoAdjustEditorState extends State { } Widget _buildBlurOptions() { + final l10n = AppLocalizations.of(context)!; return SizedBox( height: 110, child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _blurOption('Откл.', Symbols.block, BlurMode.off), - _blurOption('Радиальное', Symbols.blur_circular, BlurMode.radial), - _blurOption('Линейное', Symbols.blur_linear, BlurMode.linear), + _blurOption(l10n.photoEditorBlurOff, Symbols.block, BlurMode.off), + _blurOption( + l10n.photoEditorBlurRadial, + Symbols.blur_circular, + BlurMode.radial, + ), + _blurOption( + l10n.photoEditorBlurLinear, + Symbols.blur_linear, + BlurMode.linear, + ), ], ), ); @@ -2661,12 +2664,12 @@ class _PhotoAdjustEditorState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, color: selected ? _kAccent : Colors.white, size: 30), + Icon(icon, color: selected ? kEditorAccent : Colors.white, size: 30), const SizedBox(height: 6), Text( label, style: TextStyle( - color: selected ? _kAccent : Colors.white70, + color: selected ? kEditorAccent : Colors.white70, fontSize: 12, ), ), @@ -2676,6 +2679,7 @@ class _PhotoAdjustEditorState extends State { } Widget _buildBottomBar() { + final l10n = AppLocalizations.of(context)!; return Container( color: _kPanel, padding: const EdgeInsets.symmetric(vertical: 8), @@ -2683,9 +2687,9 @@ class _PhotoAdjustEditorState extends State { children: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: const Text( - 'ОТМЕНА', - style: TextStyle(color: Colors.white, fontSize: 15), + child: Text( + l10n.photoEditorCancel, + style: const TextStyle(color: Colors.white, fontSize: 15), ), ), const Spacer(), @@ -2698,9 +2702,9 @@ class _PhotoAdjustEditorState extends State { TextButton( onPressed: _baking ? null : _done, child: Text( - 'ГОТОВО', + l10n.photoEditorDone, style: TextStyle( - color: _baking ? Colors.white38 : _kAccent, + color: _baking ? Colors.white38 : kEditorAccent, fontSize: 15, fontWeight: FontWeight.w600, ), @@ -2716,7 +2720,7 @@ class _PhotoAdjustEditorState extends State { return IconButton( onPressed: disabled ? null : () => setState(() => _tab = tab), icon: Icon(icon), - color: selected ? _kAccent : Colors.white, + color: selected ? kEditorAccent : Colors.white, disabledColor: Colors.white24, ); } diff --git a/lib/frontend/widgets/attachment_panel.dart b/lib/frontend/widgets/attachment_panel.dart index afed185..c3cbe83 100644 --- a/lib/frontend/widgets/attachment_panel.dart +++ b/lib/frontend/widgets/attachment_panel.dart @@ -52,61 +52,76 @@ class _AttachmentPanelState extends State { borderRadius: BorderRadius.circular(20), border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)), ), - child: Stack(children: [ - Column(mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(height: 40), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), - child: Row(children: [ - Expanded(child: _buildButton( - label: 'Выбрать из файла', - icon: Symbols.folder_open, - filled: true, - onTap: _sendingById ? null : widget.onPickFile, - cs: cs, - )), - const SizedBox(width: 8), - Expanded(child: _buildButton( - label: 'Отправить по id', - icon: null, - filled: false, - onTap: _sendingById ? null : _sendById, - cs: cs, - )), - ]), + child: Stack( + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 40), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), + child: Row( + children: [ + Expanded( + child: _buildButton( + label: 'Выбрать из файла', + icon: Symbols.folder_open, + filled: true, + onTap: _sendingById ? null : widget.onPickFile, + cs: cs, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildButton( + label: 'Отправить по id', + icon: null, + filled: false, + onTap: _sendingById ? null : _sendById, + cs: cs, + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextField( + controller: _fileIdController, + style: TextStyle(color: cs.onSurface, fontSize: 14), + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: 'fileId...', + hintStyle: TextStyle(color: cs.onSurfaceVariant), + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 8), + ), + ), + ), + const SizedBox(height: 12), + ], ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: TextField( - controller: _fileIdController, - style: TextStyle(color: cs.onSurface, fontSize: 14), - keyboardType: TextInputType.number, - decoration: InputDecoration( - hintText: 'fileId...', - hintStyle: TextStyle(color: cs.onSurfaceVariant), - border: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.symmetric(vertical: 8), + Positioned( + left: 6, + top: 6, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onClose, + child: Container( + width: 32, + height: 32, + alignment: Alignment.center, + child: Icon( + Symbols.close, + color: cs.onSurfaceVariant, + size: 22, + ), ), ), ), - const SizedBox(height: 12), - ]), - Positioned( - left: 6, - top: 6, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: widget.onClose, - child: Container( - width: 32, - height: 32, - alignment: Alignment.center, - child: Icon(Symbols.close, color: cs.onSurfaceVariant, size: 22), - ), - ), - ), - ]), + ], + ), ); } @@ -124,20 +139,29 @@ class _AttachmentPanelState extends State { decoration: BoxDecoration( color: filled ? cs.primaryContainer : cs.surfaceContainerLow, borderRadius: BorderRadius.circular(10), - border: filled ? null : Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)), + border: filled + ? null + : Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (icon != null) ...[ - Icon(icon, size: 18, color: filled ? cs.onPrimaryContainer : cs.onSurface), + Icon( + icon, + size: 18, + color: filled ? cs.onPrimaryContainer : cs.onSurface, + ), const SizedBox(width: 6), ], - Text(label, style: TextStyle( - color: filled ? cs.onPrimaryContainer : cs.onSurface, - fontWeight: FontWeight.w500, - fontSize: 13, - )), + Text( + label, + style: TextStyle( + color: filled ? cs.onPrimaryContainer : cs.onSurface, + fontWeight: FontWeight.w500, + fontSize: 13, + ), + ), ], ), ), diff --git a/lib/frontend/widgets/call_link_handler.dart b/lib/frontend/widgets/call_link_handler.dart index 263e928..615d640 100644 --- a/lib/frontend/widgets/call_link_handler.dart +++ b/lib/frontend/widgets/call_link_handler.dart @@ -37,8 +37,10 @@ Future tryHandleCallLink(BuildContext context, String url) async { if (!confirmed || !context.mounted) return true; try { - final session = - await controller.joinByLink(token, isVideo: preview?.isVideo ?? false); + final session = await controller.joinByLink( + token, + isVideo: preview?.isVideo ?? false, + ); navigator.push( MaterialPageRoute( builder: (_) => CallScreen(name: name, session: session, isGroup: true), diff --git a/lib/frontend/widgets/chat_menu_overlay.dart b/lib/frontend/widgets/chat_menu_overlay.dart index d8c254b..656ca06 100644 --- a/lib/frontend/widgets/chat_menu_overlay.dart +++ b/lib/frontend/widgets/chat_menu_overlay.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../core/utils/haptics.dart'; +import 'animated_overlay_popup.dart'; class ChatMenuItem { final IconData icon; @@ -57,56 +58,31 @@ class _ChatMenuLayer extends StatefulWidget { } class _ChatMenuLayerState extends State<_ChatMenuLayer> - with SingleTickerProviderStateMixin { + with SingleTickerProviderStateMixin, AnimatedOverlayPopup<_ChatMenuLayer> { static const double _menuWidth = 290.0; static const double _hMargin = 8.0; static const double _vMargin = 8.0; static const double _gap = 6.0; - late final AnimationController _animController; - late final Animation _animation; - bool _closing = false; + @override + Duration get overlayForwardDuration => const Duration(milliseconds: 220); @override - void initState() { - super.initState(); - _animController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 220), - reverseDuration: const Duration(milliseconds: 160), - ); - _animation = CurvedAnimation( - parent: _animController, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - ); - _animController.forward(); - } + Duration get overlayReverseDuration => const Duration(milliseconds: 160); @override - void dispose() { - _animController.dispose(); - super.dispose(); - } - - Future _close() async { - if (!mounted || _closing) return; - _closing = true; - try { - await _animController.reverse(); - } catch (_) {} - if (!mounted) return; - widget.onDismiss(); - } + VoidCallback get onOverlayDismiss => widget.onDismiss; void _onItemTap(ChatMenuItem item) { Haptics.tap(); - _close().then((_) => item.onTap?.call()); + closeOverlay().then((_) => item.onTap?.call()); } Rect _resolveRect(Size screen) { final maxWidth = screen.width - 2 * _hMargin; - final width = maxWidth <= 0 ? screen.width : (_menuWidth.clamp(0.0, maxWidth)); + final width = maxWidth <= 0 + ? screen.width + : (_menuWidth.clamp(0.0, maxWidth)); final maxLeft = screen.width - width - _hMargin; double left = widget.anchorRect.right - width; if (left > maxLeft) left = maxLeft; @@ -121,18 +97,20 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer> final screen = MediaQuery.sizeOf(context); final bottomInset = MediaQuery.paddingOf(context).bottom; final rect = _resolveRect(screen); - final maxHeight = (screen.height - rect.top - bottomInset - _vMargin) - .clamp(120.0, double.infinity); + final maxHeight = (screen.height - rect.top - bottomInset - _vMargin).clamp( + 120.0, + double.infinity, + ); return AnimatedBuilder( - animation: _animation, + animation: overlayAnimation, builder: (ctx, child) { - final t = _animation.value.clamp(0.0, 1.0); + final t = overlayAnimation.value.clamp(0.0, 1.0); final scale = 0.9 + 0.1 * t; return Stack( children: [ Positioned.fill( child: GestureDetector( - onTap: _close, + onTap: closeOverlay, behavior: HitTestBehavior.opaque, child: const SizedBox.expand(), ), diff --git a/lib/frontend/widgets/chat_wallpaper_view.dart b/lib/frontend/widgets/chat_wallpaper_view.dart index 079a122..62f4f98 100644 --- a/lib/frontend/widgets/chat_wallpaper_view.dart +++ b/lib/frontend/widgets/chat_wallpaper_view.dart @@ -76,8 +76,9 @@ class _WallpaperImageLayerState extends State { void _startMotion() { _offset.value = Offset.zero; - _sub ??= accelerometerEventStream(samplingPeriod: SensorInterval.gameInterval) - .listen(_onAccelerometer, onError: (_) {}, cancelOnError: false); + _sub ??= accelerometerEventStream( + samplingPeriod: SensorInterval.gameInterval, + ).listen(_onAccelerometer, onError: (_) {}, cancelOnError: false); } void _stopMotion() { diff --git a/lib/frontend/widgets/command_suggestions_panel.dart b/lib/frontend/widgets/command_suggestions_panel.dart index 520a597..71cfa98 100644 --- a/lib/frontend/widgets/command_suggestions_panel.dart +++ b/lib/frontend/widgets/command_suggestions_panel.dart @@ -45,8 +45,10 @@ class CommandSuggestionsPanel extends StatelessWidget { return InkWell( onTap: onSelected == null ? null : () => onSelected!(c), child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 11, + ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/frontend/widgets/confirm_dialog.dart b/lib/frontend/widgets/confirm_dialog.dart index e158c4d..36c2ec6 100644 --- a/lib/frontend/widgets/confirm_dialog.dart +++ b/lib/frontend/widgets/confirm_dialog.dart @@ -25,7 +25,10 @@ Future showConfirmDialog( actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), - child: Text(cancelLabel, style: TextStyle(color: cs.onSurfaceVariant)), + child: Text( + cancelLabel, + style: TextStyle(color: cs.onSurfaceVariant), + ), ), FilledButton.tonal( onPressed: () => Navigator.of(context).pop(true), diff --git a/lib/frontend/widgets/custom_notification.dart b/lib/frontend/widgets/custom_notification.dart index 9e1e760..b868581 100644 --- a/lib/frontend/widgets/custom_notification.dart +++ b/lib/frontend/widgets/custom_notification.dart @@ -58,9 +58,12 @@ class _CustomNotificationState extends State _opacity = Tween(begin: 0.0, end: 1.0).animate(_controller); _controller.forward(); final fadeOutDelay = widget.duration - const Duration(milliseconds: 300); - Future.delayed(fadeOutDelay > Duration.zero ? fadeOutDelay : Duration.zero, () { - if (mounted) _controller.reverse(); - }); + Future.delayed( + fadeOutDelay > Duration.zero ? fadeOutDelay : Duration.zero, + () { + if (mounted) _controller.reverse(); + }, + ); } @override diff --git a/lib/frontend/widgets/error_view.dart b/lib/frontend/widgets/error_view.dart new file mode 100644 index 0000000..f47a021 --- /dev/null +++ b/lib/frontend/widgets/error_view.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class ErrorView extends StatelessWidget { + final String message; + final VoidCallback onRetry; + final String retryLabel; + + const ErrorView({ + super.key, + required this.message, + required this.onRetry, + this.retryLabel = 'Повторить', + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.cloud_off, size: 48, color: cs.onSurfaceVariant), + const SizedBox(height: 16), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 24), + FilledButton(onPressed: onRetry, child: Text(retryLabel)), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/glossy_pill.dart b/lib/frontend/widgets/glossy_pill.dart index 2d00d16..6a1b513 100644 --- a/lib/frontend/widgets/glossy_pill.dart +++ b/lib/frontend/widgets/glossy_pill.dart @@ -103,7 +103,7 @@ class GlossyPill extends StatelessWidget { this.elevated = false, this.borderSide, }) : borderRadius = - borderRadius ?? const BorderRadius.all(Radius.circular(100)); + borderRadius ?? const BorderRadius.all(Radius.circular(100)); @override Widget build(BuildContext context) { @@ -162,8 +162,9 @@ class GlossyPill extends StatelessWidget { Positioned.fill( child: IgnorePointer( child: DecoratedBox( - decoration: - BoxDecoration(gradient: GlossyDecor.topSheen(base)), + decoration: BoxDecoration( + gradient: GlossyDecor.topSheen(base), + ), ), ), ), diff --git a/lib/frontend/widgets/info_action_sheet.dart b/lib/frontend/widgets/info_action_sheet.dart index 494cf02..d19a2e6 100644 --- a/lib/frontend/widgets/info_action_sheet.dart +++ b/lib/frontend/widgets/info_action_sheet.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'sheet_helpers.dart'; + class InfoActionSheetItem { final IconData icon; final String title; @@ -47,9 +49,7 @@ Future showInfoActionSheet( context: context, isScrollControlled: true, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (ctx) => _InfoActionSheet( headerEmoji: headerEmoji, headerIcon: headerIcon, @@ -194,10 +194,7 @@ class _InfoActionSheetState extends State<_InfoActionSheet> { ), child: Text( buttonText, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), ), ), ], @@ -216,12 +213,7 @@ class _InfoActionSheetState extends State<_InfoActionSheet> { ); } return Center( - child: Icon( - widget.headerIcon, - size: 72, - color: cs.primary, - weight: 400, - ), + child: Icon(widget.headerIcon, size: 72, color: cs.primary, weight: 400), ); } @@ -233,12 +225,7 @@ class _InfoActionSheetState extends State<_InfoActionSheet> { children: [ Padding( padding: const EdgeInsets.only(top: 2), - child: Icon( - item.icon, - size: 26, - color: iconColor, - weight: 400, - ), + child: Icon(item.icon, size: 26, color: iconColor, weight: 400), ), const SizedBox(width: 16), Expanded( @@ -271,4 +258,3 @@ class _InfoActionSheetState extends State<_InfoActionSheet> { ); } } - diff --git a/lib/frontend/widgets/labeled_settings_field.dart b/lib/frontend/widgets/labeled_settings_field.dart new file mode 100644 index 0000000..0228acc --- /dev/null +++ b/lib/frontend/widgets/labeled_settings_field.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../core/config/app_colors.dart'; + +class LabeledSettingsField extends StatelessWidget { + const LabeledSettingsField({ + super.key, + required this.controller, + required this.label, + this.hintText, + this.keyboardType, + this.inputFormatters, + this.obscureText = false, + this.enabled = true, + }); + + final TextEditingController controller; + final String label; + final String? hintText; + final TextInputType? keyboardType; + final List? inputFormatters; + final bool obscureText; + final bool enabled; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + color: cs.onSurfaceVariant, + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + enabled: enabled, + obscureText: obscureText, + style: TextStyle(color: cs.onSurface, fontSize: 15), + decoration: InputDecoration( + hintText: hintText, + hintStyle: TextStyle(color: cs.mutedText, fontSize: 15), + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + ), + ], + ); + } +} diff --git a/lib/frontend/widgets/login_success_screen.dart b/lib/frontend/widgets/login_success_screen.dart index e8d8675..58bc3c7 100644 --- a/lib/frontend/widgets/login_success_screen.dart +++ b/lib/frontend/widgets/login_success_screen.dart @@ -247,10 +247,7 @@ class _LoginSuccessScreenState extends State return IgnorePointer( child: CustomPaint( size: const Size(140, 140), - painter: _RingPainter( - progress: _ringSweep.value, - color: cs.primary, - ), + painter: _RingPainter(progress: _ringSweep.value, color: cs.primary), ), ); } diff --git a/lib/frontend/widgets/max_link_handler.dart b/lib/frontend/widgets/max_link_handler.dart index 2392529..4fe7346 100644 --- a/lib/frontend/widgets/max_link_handler.dart +++ b/lib/frontend/widgets/max_link_handler.dart @@ -106,11 +106,12 @@ Future _openResolvedChat( final profile = await AppDatabase.loadActiveProfile(); final myId = profile?.id ?? 0; final participants = chat['participants']; - final isMember = myId != 0 && + final isMember = + myId != 0 && participants is Map && participants.containsKey(myId.toString()); - await ChatsModule.cacheServerChat(chat, myId, inList: isMember); + await chats.cacheServerChat(chat, myId, inList: isMember); if (!context.mounted) return; if (link.kind == MaxLinkKind.invite && access == 'PRIVATE' && !isMember) { @@ -133,12 +134,7 @@ Future _openResolvedChat( pushSwipeable( context, - (_) => ChatScreen( - chatId: id, - name: title, - imageUrl: icon, - chatType: type, - ), + (_) => ChatScreen(chatId: id, name: title, imageUrl: icon, chatType: type), ); } diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 46fe066..3e24a79 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -9,6 +9,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../core/config/app_message_actions_style.dart'; import '../../core/utils/format.dart'; import '../../core/utils/haptics.dart'; +import '../../l10n/app_localizations.dart'; import 'custom_notification.dart'; enum MessageActionsInteraction { dragAndRelease, click, tap } @@ -84,7 +85,8 @@ void showMessageActions({ VoidCallback? onReply, VoidCallback? onForward, VoidCallback? onMarkUnread, - MessageActionsInteraction interaction = MessageActionsInteraction.dragAndRelease, + MessageActionsInteraction interaction = + MessageActionsInteraction.dragAndRelease, }) { final overlay = Overlay.of(context, rootOverlay: true); late OverlayEntry entry; @@ -281,7 +283,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> anchorY = widget.tapPoint.dy.clamp(rect.top, rect.bottom).toDouble(); } - _showBelow = side == _RadialSide.below || + _showBelow = + side == _RadialSide.below || (side != _RadialSide.above && spaceBelow >= spaceAbove); final start = base - _arcSpan / 2; @@ -380,32 +383,33 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> } List<_Action> _buildActions() { - final hasText = widget.messageText != null && widget.messageText!.isNotEmpty; + final l10n = AppLocalizations.of(context)!; + final hasText = + widget.messageText != null && widget.messageText!.isNotEmpty; return <_Action>[ - if (hasText) _Action(Symbols.content_copy, 'Копировать', _copy), + if (hasText) _Action(Symbols.content_copy, l10n.msgActionsCopy, _copy), if (widget.isMe && widget.onEdit != null) - _Action(Symbols.edit, 'Изменить', _edit), + _Action(Symbols.edit, l10n.msgActionsEdit, _edit), if (widget.onReply != null) - _Action(Symbols.reply, 'Ответить', _reply), + _Action(Symbols.reply, l10n.msgActionsReply, _reply), if (widget.onForward != null) - _Action(Symbols.forward, 'Переслать', _forward), + _Action(Symbols.forward, l10n.msgActionsForward, _forward), if (widget.onMarkUnread != null) - _Action(Symbols.mark_chat_unread, 'Непрочитанное', _markUnread), + _Action( + Symbols.mark_chat_unread, + l10n.msgActionsMarkUnread, + _markUnread, + ), if (widget.editHistory != null && widget.editHistory!.isNotEmpty) - _Action(Symbols.history, 'История изменений', _showHistoryView), + _Action(Symbols.history, l10n.msgActionsEditHistory, _showHistoryView), if (widget.onReport != null && widget.loadReportReasons != null) _Action( Symbols.flag, - 'Пожаловаться', + l10n.msgActionsReport, _showReportView, destructive: true, ), - _Action( - Symbols.delete, - 'Удалить', - _delete, - destructive: true, - ), + _Action(Symbols.delete, l10n.msgActionsDelete, _delete, destructive: true), ]; } @@ -499,7 +503,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> if (text != null && text.isNotEmpty) { await Clipboard.setData(ClipboardData(text: text)); if (!mounted) return; - showCustomNotification(context, 'Скопировано'); + showCustomNotification(context, AppLocalizations.of(context)!.msgActionsCopied); } await _close(); } @@ -732,7 +736,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> rows.add(_historyRow(cs, widget.messageText, currentTime, current: true)); return _buildAnchoredPanel( - title: 'История изменений', + title: AppLocalizations.of(context)!.msgActionsEditHistory, body: SingleChildScrollView( child: Column(mainAxisSize: MainAxisSize.min, children: rows), ), @@ -741,6 +745,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> Widget _buildReportMenu() { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; final Widget body; if (_reportLoading) { body = const Padding( @@ -759,7 +764,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> body = Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), child: Text( - 'Не удалось загрузить причины', + l10n.msgActionsLoadReasonsFailed, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), ); @@ -774,7 +779,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ); } } - return _buildAnchoredPanel(title: 'Пожаловаться', body: body); + return _buildAnchoredPanel(title: l10n.msgActionsReport, body: body); } Widget _reasonRow(ColorScheme cs, ({int id, String title}) reason) => @@ -792,10 +797,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ), ); - Widget _historyDivider(ColorScheme cs) => Divider( - height: 1, - color: cs.outlineVariant.withValues(alpha: 0.25), - ); + Widget _historyDivider(ColorScheme cs) => + Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.25)); Widget _historyRow( ColorScheme cs, @@ -803,12 +806,15 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> dynamic time, { required bool current, }) { + final l10n = AppLocalizations.of(context)!; final ms = time is int ? time : int.tryParse(time?.toString() ?? ''); final dateStr = ms != null ? formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(ms)) : ''; final label = current - ? (dateStr.isEmpty ? 'текущая версия' : 'текущая версия · $dateStr') + ? (dateStr.isEmpty + ? l10n.msgActionsCurrentVersion + : l10n.msgActionsCurrentVersionWithDate(dateStr)) : dateStr; return Container( width: double.infinity, @@ -817,7 +823,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - text == null || text.isEmpty ? '(без текста)' : text, + text == null || text.isEmpty ? l10n.msgActionsNoText : text, style: TextStyle( color: current ? cs.primary : cs.onSurface, fontSize: 15, @@ -851,10 +857,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> scale: scale, alignment: tapAnchored ? Alignment(-1.0, _showBelow ? -1.0 : 1.0) - : Alignment( - widget.isMe ? 1.0 : -1.0, - _showBelow ? -1.0 : 1.0, - ), + : Alignment(widget.isMe ? 1.0 : -1.0, _showBelow ? -1.0 : 1.0), child: Material( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(16), @@ -903,8 +906,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> final hoverScale = isHovered ? 1.18 : 1.0; final entryScale = 0.4 + 0.6 * eased; final centerAtFull = _buttonCenters[i]; - final centerAtT = _anchor + - (centerAtFull - _anchor) * eased; + final centerAtT = _anchor + (centerAtFull - _anchor) * eased; return Positioned( left: centerAtT.dx - _btnSize / 2, @@ -933,8 +935,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> } Widget _buildLabelBanner(Size size, double t) { - final label = - _hoveredIndex == -1 ? null : _actions[_hoveredIndex].label; + final label = _hoveredIndex == -1 ? null : _actions[_hoveredIndex].label; final bottomInset = math.max( MediaQuery.paddingOf(context).bottom, MediaQuery.viewInsetsOf(context).bottom, @@ -987,12 +988,7 @@ class _Action { final String label; final VoidCallback onTap; final bool destructive; - const _Action( - this.icon, - this.label, - this.onTap, { - this.destructive = false, - }); + const _Action(this.icon, this.label, this.onTap, {this.destructive = false}); } class _ListMenuItem extends StatelessWidget { @@ -1041,8 +1037,9 @@ class _ListMenuItem extends StatelessWidget { style: TextStyle( color: fg, fontSize: 14, - fontWeight: - highlighted ? FontWeight.w600 : FontWeight.w500, + fontWeight: highlighted + ? FontWeight.w600 + : FontWeight.w500, ), ), ), @@ -1065,10 +1062,7 @@ class _ListMenuItem extends StatelessWidget { class _ActionButton extends StatelessWidget { final _Action action; final bool highlighted; - const _ActionButton({ - required this.action, - required this.highlighted, - }); + const _ActionButton({required this.action, required this.highlighted}); @override Widget build(BuildContext context) { @@ -1098,9 +1092,7 @@ class _ActionButton extends StatelessWidget { Haptics.tap(); action.onTap(); }, - child: Center( - child: Icon(action.icon, color: iconColor, size: 24), - ), + child: Center(child: Icon(action.icon, color: iconColor, size: 24)), ), ), ); diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 2a52a90..470aaa0 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1,10 +1,6 @@ import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:ogg_opus_player/ogg_opus_player.dart'; -import 'package:video_player/video_player.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -14,53 +10,28 @@ import '../../backend/modules/messages.dart'; import '../screens/webapp/web_app_screen.dart'; import '../../core/config/app_bubble_behavior.dart'; import '../../core/config/app_bubble_shape.dart'; -import '../../core/config/komet_settings.dart'; import '../../core/utils/bubble_radius.dart'; -import '../../core/utils/format.dart'; -import '../../core/utils/haptics.dart'; -import '../../core/utils/file_download.dart'; -import '../../core/utils/media_cache.dart'; -import '../../core/utils/download_progress.dart'; import '../../core/utils/link_opener.dart'; import '../../core/utils/webview_support.dart'; import '../../core/config/app_link_preview.dart'; import 'custom_notification.dart'; import 'formatted_message_text.dart'; -import 'sticker_image.dart'; import '../../models/attachment.dart'; -import 'poll_view.dart'; -import 'photo_viewer.dart'; -import 'video_player_screen.dart'; - -enum MessageType { text, attachment, voice, control } - -enum BubbleShape { singleTop, singleBottom, singleMiddle, groupedMiddle } - -class _BubbleCtx { - final BuildContext context; - final ColorScheme cs; - final Color text; - final Color dim; - final BubbleShape shape; - final MessageType contentType; - final bool hasPhotoWithCaption; - final bool hasMultiplePhotosNoCaption; - final Map? reactionInfo; - - _BubbleCtx({ - required this.context, - required this.cs, - required this.text, - required this.shape, - required this.contentType, - required this.hasPhotoWithCaption, - required this.hasMultiplePhotosNoCaption, - this.reactionInfo, - }) : dim = text.withValues(alpha: 0.7); -} +import '../../models/reaction_info.dart'; +import 'attachment/bubbles/voice_bubble.dart'; +import 'attachment/bubbles/bubble_context.dart'; +import 'attachment/bubbles/poll_bubble.dart'; +import 'attachment/bubbles/share_bubble.dart'; +import 'attachment/bubbles/call_bubble.dart'; +import 'attachment/bubbles/location_bubble.dart'; +import 'attachment/bubbles/contact_bubble.dart'; +import 'attachment/bubbles/sticker_bubble.dart'; +import 'attachment/bubbles/photo_bubble.dart'; +import 'attachment/bubbles/video_bubble.dart'; +import 'attachment/bubbles/file_bubble.dart'; +import 'attachment/bubbles/forwarded_bubble.dart'; final Expando _contentTypeCache = Expando(); -final Expando<({bool full, String text})> _clockTextCache = Expando(); class _ZeroIntrinsicWidth extends SingleChildRenderObjectWidget { const _ZeroIntrinsicWidth({required Widget super.child}); @@ -79,18 +50,6 @@ class _RenderZeroIntrinsicWidth extends RenderProxyBox { } class MessageBubble extends StatelessWidget { - static const double photoMaxSize = 280.0; - static const double photoMinSize = 100.0; - static const double photoBorderRadius = 12.0; - static const double bubbleBorderRadius = 20.0; - static const double captionPaddingHorizontal = 6.0; - static const double captionPaddingRight = 4.0; - static const double compactTimePadding = 8.0; - - static const Radius _bigRadius = Radius.circular(bubbleBorderRadius); - static const Radius _smallRadius = Radius.circular(4); - static const Radius _photoRadius = Radius.circular(photoBorderRadius); - static final Color _reactionChipBg = Colors.black.withValues(alpha: 0.18); static const BorderRadius _reactionChipRadius = BorderRadius.all( Radius.circular(10), @@ -108,6 +67,7 @@ class MessageBubble extends StatelessWidget { final CachedMessage? nextMessage; final String chatType; final String? overrideStatus; + final ValueListenable? otherReadTime; final ValueListenable?>? reactionsListenable; final ValueListenable>? uploadProgress; final void Function(String messageId)? onReplyTap; @@ -123,6 +83,7 @@ class MessageBubble extends StatelessWidget { this.nextMessage, required this.chatType, this.overrideStatus, + this.otherReadTime, this.reactionsListenable, this.uploadProgress, this.onReplyTap, @@ -193,18 +154,6 @@ class MessageBubble extends StatelessWidget { return _contentTypeCache[message] ??= _computeContentType(); } - String get _clockText { - final full = KometSettings.fullTimestamp.value; - final cached = _clockTextCache[message]; - if (cached != null && cached.full == full) return cached.text; - final text = formatClock( - DateTime.fromMillisecondsSinceEpoch(message.time), - withSeconds: full, - ); - _clockTextCache[message] = (full: full, text: text); - return text; - } - InlineKeyboardAttachment? get _inlineKeyboard { final attachments = message.attachments; if (attachments == null) return null; @@ -343,7 +292,8 @@ class MessageBubble extends StatelessWidget { Color(0xFFA1887F), ]; - Color _senderColor(int id) => _senderPalette[id.abs() % _senderPalette.length]; + Color _senderColor(int id) => + _senderPalette[id.abs() % _senderPalette.length]; Widget _buildSenderHeader(ColorScheme cs, bool needsInset) { final name = ContactCache.get(message.senderId); @@ -462,7 +412,7 @@ class MessageBubble extends StatelessWidget { ? Colors.transparent : (isMe ? cs.primaryContainer : cs.surfaceContainerHighest); - _BubbleCtx makeCtx() => _BubbleCtx( + BubbleContext makeCtx() => BubbleContext( context: context, cs: cs, text: textColor, @@ -470,6 +420,14 @@ class MessageBubble extends StatelessWidget { contentType: contentType, hasPhotoWithCaption: hasPhotoCap, hasMultiplePhotosNoCaption: hasMultiPhotos, + message: message, + isMe: isMe, + myId: myId, + chatType: chatType, + overrideStatus: overrideStatus, + otherReadTime: otherReadTime, + uploadProgress: uploadProgress, + onStickerTap: onStickerTap, reactionInfo: _resolveReactionInfo(), ); @@ -509,86 +467,86 @@ class MessageBubble extends StatelessWidget { } return Padding( - padding: EdgeInsets.only( - left: 12, - right: 12, - top: topMargin, - bottom: bottomMargin, - ), - child: Align( - child: Row( - mainAxisAlignment: isMe - ? MainAxisAlignment.end - : MainAxisAlignment.start, - spacing: 8, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (showAvatar) - _buildLeadingAvatar(cs) - else if (showAvatarSlot && chatType != "CHAT") - const SizedBox(width: 0) - else if (showAvatarSlot) - const CircleAvatar( - radius: 15, - backgroundColor: Color(0x00000000), - ), - Column( - crossAxisAlignment: isMe - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - ListenableBuilder( - listenable: Listenable.merge([ - AppBubbleShape.current, - AppBubbleBehavior.current, - ]), - builder: (context, child) => Container( - constraints: BoxConstraints(maxWidth: maxBubbleWidth), - decoration: BoxDecoration( - color: bubbleColor, - borderRadius: noBubbleBackground - ? null - : _borderRadiusFor( - AppBubbleShape.current.value, - AppBubbleBehavior.current.value, - shape, - hasPhotoCap, - hasMultiPhotos, - ), - ), - padding: padding, - child: child, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showSenderName) - _buildSenderHeader(cs, padding == EdgeInsets.zero), - withReply( - reactionsInside - ? Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [bubbleContent, _reactionsBar(cs)], - ) - : bubbleContent, - ), - ], - ), - ), - if (keyboard != null) - ConstrainedBox( - constraints: BoxConstraints(maxWidth: maxBubbleWidth), - child: _buildInlineKeyboard(context, cs, keyboard), - ), - if (reactionsUnder) _reactionsBar(cs), - ], + padding: EdgeInsets.only( + left: 12, + right: 12, + top: topMargin, + bottom: bottomMargin, + ), + child: Align( + child: Row( + mainAxisAlignment: isMe + ? MainAxisAlignment.end + : MainAxisAlignment.start, + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (showAvatar) + _buildLeadingAvatar(cs) + else if (showAvatarSlot && chatType != "CHAT") + const SizedBox(width: 0) + else if (showAvatarSlot) + const CircleAvatar( + radius: 15, + backgroundColor: Color(0x00000000), ), - ], - ), + Column( + crossAxisAlignment: isMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + ListenableBuilder( + listenable: Listenable.merge([ + AppBubbleShape.current, + AppBubbleBehavior.current, + ]), + builder: (context, child) => Container( + constraints: BoxConstraints(maxWidth: maxBubbleWidth), + decoration: BoxDecoration( + color: bubbleColor, + borderRadius: noBubbleBackground + ? null + : _borderRadiusFor( + AppBubbleShape.current.value, + AppBubbleBehavior.current.value, + shape, + hasPhotoCap, + hasMultiPhotos, + ), + ), + padding: padding, + child: child, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showSenderName) + _buildSenderHeader(cs, padding == EdgeInsets.zero), + withReply( + reactionsInside + ? Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [bubbleContent, _reactionsBar(cs)], + ) + : bubbleContent, + ), + ], + ), + ), + if (keyboard != null) + ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxBubbleWidth), + child: _buildInlineKeyboard(context, cs, keyboard), + ), + if (reactionsUnder) _reactionsBar(cs), + ], + ), + ], ), - ); + ), + ); } Widget _buildInlineKeyboard( @@ -790,7 +748,7 @@ class MessageBubble extends StatelessWidget { return _buildReactionsBar(cs); } - Widget _buildContent(_BubbleCtx ctx) { + Widget _buildContent(BubbleContext ctx) { switch (ctx.contentType) { case MessageType.control: return _buildControlContent(ctx.cs); @@ -809,7 +767,7 @@ class MessageBubble extends StatelessWidget { } Widget _buildReactionsBarFor(ColorScheme cs, Map? info) { - final chips = _buildReactionChipsFor(cs, info); + final chips = _buildReactionChipsFor(cs, ReactionInfo.fromMap(info)); if (chips.isEmpty) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.only(top: 4), @@ -817,19 +775,13 @@ class MessageBubble extends StatelessWidget { ); } - List _buildReactionChipsFor(ColorScheme cs, Map? info) { + List _buildReactionChipsFor(ColorScheme cs, ReactionInfo? info) { if (info == null) return const []; - final counters = info['counters']; - if (counters is! List || counters.isEmpty) return const []; - final yourReaction = info['yourReaction']?.toString(); + final yourReaction = info.yourReaction; final chips = []; - for (final c in counters) { - if (c is! Map) continue; - final reaction = c['reaction']?.toString(); - final count = c['count']; - if (reaction == null || reaction.isEmpty) continue; - final isYours = yourReaction == reaction; + for (final c in info.counters) { + final isYours = yourReaction == c.reaction; chips.add( Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), @@ -842,11 +794,11 @@ class MessageBubble extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(reaction, style: const TextStyle(fontSize: 13)), - if (count is int && count > 1) ...[ + Text(c.reaction, style: const TextStyle(fontSize: 13)), + if (c.count > 1) ...[ const SizedBox(width: 3), Text( - count.toString(), + c.count.toString(), style: TextStyle( color: isYours ? cs.primary : cs.onSurfaceVariant, fontSize: 11, @@ -919,7 +871,7 @@ class MessageBubble extends StatelessWidget { ); } - Widget _buildTextContent(_BubbleCtx ctx) { + Widget _buildTextContent(BubbleContext ctx) { final attachments = message.attachments; final isForwardedContact = attachments != null && @@ -931,7 +883,10 @@ class MessageBubble extends StatelessWidget { final forwarded = _getForwardedAttachment(); final isForwarded = forwarded != null && !isForwardedContact; - final reactionChips = _buildReactionChipsFor(ctx.cs, ctx.reactionInfo); + final reactionChips = _buildReactionChipsFor( + ctx.cs, + ReactionInfo.fromMap(ctx.reactionInfo), + ); final hasReactions = reactionChips.isNotEmpty; final textStyle = TextStyle(color: ctx.text, fontSize: 16, height: 1.3); @@ -947,7 +902,7 @@ class MessageBubble extends StatelessWidget { : Text(message.text ?? '', style: textStyle)); final metaWidget = Text( - message.status == 'EDITED' ? '$_clockText ред.' : _clockText, + message.status == 'EDITED' ? '${ctx.clockText} ред.' : ctx.clockText, style: TextStyle(color: ctx.dim, fontSize: 10), ); @@ -973,10 +928,10 @@ class MessageBubble extends StatelessWidget { padding: const EdgeInsets.only(bottom: 2), child: metaWidget, ), - if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)], + if (isMe) ...[const SizedBox(width: 4), ctx.statusIcon()], if (message.deleted) ...[ const SizedBox(width: 4), - _buildDeletedIcon(ctx), + ctx.deletedIcon(), ], ], ), @@ -998,10 +953,10 @@ class MessageBubble extends StatelessWidget { padding: const EdgeInsets.only(bottom: 2), child: metaWidget, ), - if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)], + if (isMe) ...[const SizedBox(width: 4), ctx.statusIcon()], if (message.deleted) ...[ const SizedBox(width: 4), - _buildDeletedIcon(ctx), + ctx.deletedIcon(), ], ], ), @@ -1069,7 +1024,7 @@ class MessageBubble extends StatelessWidget { } Widget _buildForwardedInlineText( - _BubbleCtx ctx, + BubbleContext ctx, ForwardedMessageAttachment forwarded, ) { final headerColor = ctx.dim; @@ -1129,12 +1084,7 @@ class MessageBubble extends StatelessWidget { ), if (hasOrigText) ...[ const SizedBox(height: 2), - Text( - origText, - style: TextStyle(color: ctx.text, fontSize: 14), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), + Text(origText, style: TextStyle(color: ctx.text, fontSize: 14)), ] else ...[ const SizedBox(height: 2), Text( @@ -1146,62 +1096,6 @@ class MessageBubble extends StatelessWidget { ); } - Widget _buildForwardedHeader( - _BubbleCtx ctx, - ForwardedMessageAttachment forwarded, - ) { - final headerColor = ctx.dim; - final displaySender = - forwarded.originalSenderName ?? - ContactCache.get(forwarded.originalSenderId) ?? - forwarded.originalSenderId.toString(); - final senderAvatar = - forwarded.originalSenderAvatar ?? - ContactCache.getAvatar(forwarded.originalSenderId); - return Padding( - padding: const EdgeInsets.only(left: 8, top: 8, right: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Symbols.forward, size: 14, color: headerColor), - const SizedBox(width: 4), - if (senderAvatar != null && senderAvatar.isNotEmpty) - CircleAvatar( - radius: 10, - backgroundImage: CachedNetworkImageProvider( - senderAvatar, - maxWidth: 96, - maxHeight: 96, - ), - backgroundColor: ctx.cs.primaryContainer, - ) - else - CircleAvatar( - radius: 10, - backgroundColor: ctx.cs.primaryContainer, - child: Text( - displaySender.isNotEmpty ? displaySender[0].toUpperCase() : '?', - style: TextStyle(fontSize: 9, color: ctx.cs.onPrimaryContainer), - ), - ), - const SizedBox(width: 6), - Flexible( - child: Text( - displaySender, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: headerColor, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ); - } - ForwardedMessageAttachment? _getForwardedAttachment() { final attachments = message.attachments; if (attachments == null || attachments.isEmpty) return null; @@ -1211,7 +1105,7 @@ class MessageBubble extends StatelessWidget { return null; } - Widget _buildAttachmentContent(_BubbleCtx ctx) { + Widget _buildAttachmentContent(BubbleContext ctx) { final attachments = message.attachments; if (attachments == null || attachments.isEmpty) { return _buildTextContent(ctx); @@ -1221,34 +1115,48 @@ class MessageBubble extends StatelessWidget { if (first is ForwardedMessageAttachment) { final fwd = first; if (fwd.originalContact != null) { - return _buildForwardedContactContent(ctx, fwd); + return ForwardedContactBubble(ctx: ctx, forwarded: fwd); } final photos = fwd.originalAttachments ?.whereType() .toList(); if (photos != null && photos.isNotEmpty) { - return _buildForwardedPhotoContent(ctx, fwd, photos); + return ForwardedPhotoBubble(ctx: ctx, forwarded: fwd, photos: photos); + } + final stickers = fwd.originalAttachments + ?.whereType() + .toList(); + if (stickers != null && stickers.isNotEmpty) { + return ForwardedStickerBubble( + ctx: ctx, + forwarded: fwd, + sticker: stickers.first, + ); } final files = fwd.originalAttachments; if (files != null && files.isNotEmpty) { - return _buildForwardedGenericContent(ctx, fwd, files); + return ForwardedGenericBubble( + ctx: ctx, + forwarded: fwd, + attachments: files, + ); } return _buildTextContent(ctx); } final contacts = attachments.whereType().toList(); if (contacts.isNotEmpty) { - return _buildContactAttachment(ctx, contacts.first); + return ContactBubble(ctx: ctx, contact: contacts.first); } final polls = attachments.whereType().toList(); if (polls.isNotEmpty) { - return _buildPollAttachment(ctx, polls.first); + return PollBubble(ctx: ctx, poll: polls.first); } final shares = attachments.whereType().toList(); if (shares.isNotEmpty) { - return _buildShareContent(ctx, shares.first); + return ShareBubble(ctx: ctx, share: shares.first); } final photos = attachments.whereType().toList(); @@ -1256,1432 +1164,33 @@ class MessageBubble extends StatelessWidget { return _buildGenericAttachment(ctx, attachments.first); } - return _buildPhotoContent(ctx, photos); + return PhotoBubble(ctx: ctx, photos: photos); } - Widget _buildPollAttachment(_BubbleCtx ctx, PollAttachment poll) { - return Padding( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), - child: IntrinsicWidth( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - PollView( - chatId: message.chatId, - messageId: message.id, - pollId: poll.pollId, - myId: myId, - fallbackTitle: poll.title ?? message.text, - textColor: ctx.text, - dimColor: ctx.dim, - accentColor: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, - ), - _buildMeta(ctx), - ], - ), - ), - ); - } - - Widget _buildShareContent(_BubbleCtx ctx, ShareAttachment share) { - final hasText = message.text != null && message.text!.isNotEmpty; - final image = share.image; - final imageUrl = image?.baseUrl ?? image?.previewData ?? ''; - final cardColor = isMe - ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.08) - : ctx.cs.surfaceContainerHigh; - final host = - share.host ?? - (share.url != null ? Uri.tryParse(share.url!)?.host : null); - - final card = GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: share.url == null - ? null - : () { - Haptics.tap(); - openExternalUrl(ctx.context, share.url!); - }, - child: Container( - decoration: BoxDecoration( - color: cardColor, - borderRadius: BorderRadius.circular(12), - ), - clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (imageUrl.isNotEmpty) - CachedNetworkImage( - imageUrl: imageUrl, - width: 280, - height: 140, - fit: BoxFit.cover, - memCacheWidth: 560, - fadeInDuration: const Duration(milliseconds: 120), - errorWidget: (_, _, _) => const SizedBox.shrink(), - ), - Padding( - padding: const EdgeInsets.fromLTRB(10, 8, 10, 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (host != null && host.isNotEmpty) ...[ - Text( - host, - style: TextStyle( - color: isMe - ? ctx.cs.onPrimaryContainer - : ctx.cs.primary, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - ], - if (share.title != null && share.title!.isNotEmpty) - Text( - share.title!, - style: TextStyle( - color: ctx.text, - fontSize: 14, - fontWeight: FontWeight.w500, - height: 1.25, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - if (share.description != null && - share.description!.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - share.description!, - style: TextStyle( - color: ctx.dim, - fontSize: 13, - height: 1.25, - ), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - ], - ), - ), - ); - - return Padding( - padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 280), - child: IntrinsicWidth( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - if (hasText) ...[ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: FormattedMessageText( - text: message.text!, - ranges: message.formatRanges, - style: TextStyle( - color: ctx.text, - fontSize: 16, - height: 1.3, - ), - ), - ), - const SizedBox(height: 6), - ], - card, - _buildMeta(ctx), - ], - ), - ), - ), - ); - } - - Widget _buildPhotoContent(_BubbleCtx ctx, List photos) { - final hasCaption = message.text != null && message.text!.isNotEmpty; - final count = photos.length; - - Widget photosWidget; - if (count == 1) { - photosWidget = _buildSinglePhoto(ctx, photos[0]); - } else if (count == 2) { - photosWidget = _buildTwoPhotos(ctx, photos[0], photos[1]); - } else { - photosWidget = _buildPhotoGrid(ctx, photos); - } - - if (!hasCaption) { - return Stack( - children: [ - photosWidget, - Positioned( - bottom: compactTimePadding, - right: compactTimePadding, - child: _buildCompactTime(), - ), - ], - ); - } - - if (count == 1) { - final photo = photos[0]; - final pw = photo.width?.toDouble() ?? 200; - final photoWidth = pw.clamp(photoMinSize, photoMaxSize); - - return SizedBox( - width: photoWidth, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - photosWidget, - Padding( - padding: const EdgeInsets.only( - left: captionPaddingHorizontal, - right: captionPaddingRight, - bottom: 6, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded(child: _buildCaption(ctx)), - _buildMeta(ctx), - ], - ), - ), - ], - ), - ); - } - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - photosWidget, - Padding( - padding: const EdgeInsets.only( - left: captionPaddingHorizontal, - right: captionPaddingRight, - bottom: 6, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded(child: _buildCaption(ctx)), - _buildMeta(ctx), - ], - ), - ), - ], - ); - } - - Widget _buildForwardedPhotoContent( - _BubbleCtx ctx, - ForwardedMessageAttachment forwarded, - List photos, + Widget _buildGenericAttachment( + BubbleContext ctx, + MessageAttachment attachment, ) { - final hasCaption = message.text != null && message.text!.isNotEmpty; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _buildForwardedHeader(ctx, forwarded), - const SizedBox(height: 4), - if (hasCaption) ...[ - Padding( - padding: const EdgeInsets.only(left: 8), - child: Text( - message.text ?? '', - style: TextStyle(color: ctx.text, fontSize: 16, height: 1.3), - ), - ), - const SizedBox(height: 6), - ], - _buildPhotoContent(ctx, photos), - ], - ); - } - - Widget _buildForwardedGenericContent( - _BubbleCtx ctx, - ForwardedMessageAttachment forwarded, - List attachments, - ) { - return IntrinsicWidth( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - _buildForwardedHeader(ctx, forwarded), - const SizedBox(height: 4), - ...attachments.map((a) { - if (a is FileAttachment) { - return _buildFileAttachment(ctx, a, fill: true); - } - return const SizedBox.shrink(); - }), - ], - ), - ); - } - - Widget _buildSinglePhoto(_BubbleCtx ctx, PhotoAttachment photo) { - final width = photo.width?.toDouble() ?? 200; - final height = photo.height?.toDouble() ?? 200; - - final constrainedWidth = width.clamp(photoMinSize, photoMaxSize); - final constrainedHeight = height.clamp(photoMinSize, photoMaxSize); - final dpr = MediaQuery.of(ctx.context).devicePixelRatio; - - final matchTop = ctx.hasPhotoWithCaption; - final matchBottom = !ctx.hasPhotoWithCaption; - - final topR = matchTop ? _bigRadius : _photoRadius; - final bottomL = matchBottom - ? (isMe ? _bigRadius : _smallRadius) - : _smallRadius; - final bottomR = matchBottom - ? (isMe ? _smallRadius : _bigRadius) - : _smallRadius; - - return ClipRRect( - borderRadius: BorderRadius.only( - topLeft: topR, - topRight: topR, - bottomLeft: bottomL, - bottomRight: bottomR, - ), - child: Stack( - children: [ - _buildPhotoImage( - ctx, - photo, - constrainedWidth, - constrainedHeight, - memWidth: (constrainedWidth * dpr).round(), - memHeight: (constrainedHeight * dpr).round(), - ), - if (uploadProgress != null) _buildUploadOverlay(uploadProgress!, 0), - if (uploadProgress == null) - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => _openPhotoViewer(ctx.context, photo), - ), - ), - ], - ), - ); - } - - Widget _buildPhotoImage( - _BubbleCtx ctx, - PhotoAttachment photo, - double width, - double height, { - required int memWidth, - required int memHeight, - }) { - final localPath = photo.localPath; - if (localPath != null) { - return Image.file( - File(localPath), - width: width, - height: height, - fit: BoxFit.cover, - cacheWidth: memWidth, - gaplessPlayback: true, - errorBuilder: (_, _, _) => - _buildPhotoPlaceholder(ctx.cs, width, height), - ); - } - final imageUrl = photo.baseUrl ?? ''; - if (imageUrl.isNotEmpty) { - return CachedNetworkImage( - imageUrl: imageUrl, - width: width, - height: height, - fit: BoxFit.cover, - memCacheWidth: memWidth, - memCacheHeight: memHeight, - fadeInDuration: Duration.zero, - placeholderFadeInDuration: Duration.zero, - errorWidget: (_, _, _) => _buildPhotoPlaceholder(ctx.cs, width, height), - ); - } - return _buildPhotoPlaceholder(ctx.cs, width, height); - } - - Widget _buildUploadOverlay( - ValueListenable> progress, - int index, - ) { - return Positioned.fill( - child: ValueListenableBuilder>( - valueListenable: progress, - builder: (context, values, _) { - final value = index < values.length ? values[index] : 1.0; - final indeterminate = value <= 0 || value >= 1.0; - return Container( - color: Colors.black.withValues(alpha: 0.4), - alignment: Alignment.center, - child: SizedBox( - width: 34, - height: 34, - child: CircularProgressIndicator( - strokeWidth: 2.5, - value: indeterminate ? null : value, - color: Colors.white, - ), - ), - ); - }, - ), - ); - } - - Widget _buildTwoPhotos( - _BubbleCtx ctx, - PhotoAttachment p1, - PhotoAttachment p2, - ) { - final matchTop = - ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleTop; - final matchBottom = - ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleBottom; - - final topR = matchTop ? _bigRadius : _photoRadius; - final bottomL = matchBottom ? _smallRadius : _photoRadius; - final bottomR = matchBottom - ? (isMe ? _smallRadius : _bigRadius) - : _photoRadius; - - return ClipRRect( - borderRadius: BorderRadius.only( - topLeft: topR, - topRight: topR, - bottomLeft: bottomL, - bottomRight: bottomR, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded(child: _buildPhotoTile(ctx, p1, 0)), - const SizedBox(width: 2), - Expanded(child: _buildPhotoTile(ctx, p2, 1)), - ], - ), - ); - } - - Widget _buildPhotoGrid(_BubbleCtx ctx, List photos) { - final displayCount = photos.length > 4 ? 4 : photos.length; - final remaining = photos.length - 4; - - final matchTop = - ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleTop; - final matchBottom = - ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleBottom; - - final topR = matchTop ? _bigRadius : _photoRadius; - final bottomL = matchBottom ? _smallRadius : _photoRadius; - final bottomR = matchBottom - ? (isMe ? _smallRadius : _bigRadius) - : _photoRadius; - - return ClipRRect( - borderRadius: BorderRadius.only( - topLeft: topR, - topRight: topR, - bottomLeft: bottomL, - bottomRight: bottomR, - ), - child: GridView.count( - crossAxisCount: 2, - mainAxisSpacing: 2, - crossAxisSpacing: 2, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - children: List.generate(displayCount, (i) { - if (i == 3 && remaining > 0) { - return _buildPhotoTileWithOverlay(ctx, photos[i], '+$remaining', i); - } - return _buildPhotoTile(ctx, photos[i], i); - }), - ), - ); - } - - Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo, int index) { - final cachePx = - (photoMaxSize / 2 * MediaQuery.of(ctx.context).devicePixelRatio) - .round(); - return AspectRatio( - aspectRatio: 1, - child: Stack( - children: [ - _buildPhotoImage( - ctx, - photo, - double.infinity, - double.infinity, - memWidth: cachePx, - memHeight: cachePx, - ), - if (uploadProgress != null) - _buildUploadOverlay(uploadProgress!, index), - if (uploadProgress == null) - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => _openPhotoViewer(ctx.context, photo), - ), - ), - ], - ), - ); - } - - Widget _buildPhotoTileWithOverlay( - _BubbleCtx ctx, - PhotoAttachment photo, - String overlay, - int index, - ) { - final cachePx = - (photoMaxSize / 2 * MediaQuery.of(ctx.context).devicePixelRatio) - .round(); - return AspectRatio( - aspectRatio: 1, - child: Stack( - children: [ - _buildPhotoImage( - ctx, - photo, - double.infinity, - double.infinity, - memWidth: cachePx, - memHeight: cachePx, - ), - Positioned.fill( - child: Container( - color: Colors.black45, - child: Center( - child: Text( - overlay, - style: const TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - if (uploadProgress != null) - _buildUploadOverlay(uploadProgress!, index), - ], - ), - ); - } - - Widget _buildPhotoPlaceholder( - ColorScheme cs, - double w, - double h, { - VoidCallback? onRetry, - }) { - return Container( - width: w, - height: h, - color: cs.surfaceContainerHighest, - child: onRetry != null - ? Center( - child: IconButton( - icon: Icon(Symbols.refresh, color: cs.onSurfaceVariant), - onPressed: onRetry, - tooltip: 'Retry', - ), - ) - : Center( - child: Icon(Symbols.image, size: 48, color: cs.onSurfaceVariant), - ), - ); - } - - Widget _buildCaption(_BubbleCtx ctx) { - final style = TextStyle(color: ctx.text, fontSize: 16, height: 1.3); - final ranges = message.formatRanges; - if (FormattedMessageText.isFormatted(message.text, ranges)) { - return FormattedMessageText( - text: message.text!, - ranges: ranges, - style: style, - ); - } - return Text(message.text ?? '', style: style); - } - - Widget _buildGenericAttachment(_BubbleCtx ctx, MessageAttachment attachment) { switch (attachment.type) { case AttachmentType.video: - return _buildVideoAttachment(ctx, attachment); + return VideoBubble(ctx: ctx, video: attachment as VideoAttachment); case AttachmentType.file: - return _buildFileAttachment(ctx, attachment); + return FileBubble(ctx: ctx, file: attachment as FileAttachment); case AttachmentType.sticker: - return _buildStickerAttachment(ctx, attachment); + return StickerBubble(ctx: ctx, sticker: attachment); case AttachmentType.location: - return _buildLocationAttachment(ctx, attachment as LocationAttachment); + return LocationBubble( + ctx: ctx, + location: attachment as LocationAttachment, + ); case AttachmentType.call: - return _buildCallAttachment(ctx, attachment as CallAttachment); + return CallBubble(ctx: ctx, call: attachment as CallAttachment); default: return _buildTextContent(ctx); } } - Widget _buildCallAttachment(_BubbleCtx ctx, CallAttachment call) { - final missed = call.isMissedOrFailed; - final accent = isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary; - final iconColor = missed ? ctx.cs.error : accent; - - final IconData icon; - final String label; - if (call.isGroup) { - icon = call.isVideo ? Symbols.videocam : Symbols.groups; - label = call.isVideo ? 'Групповой видеозвонок' : 'Групповой звонок'; - } else if (call.isVideo) { - icon = Symbols.videocam; - label = missed - ? (isMe ? 'Отменённый видеозвонок' : 'Пропущенный видеозвонок') - : (isMe ? 'Исходящий видеозвонок' : 'Входящий видеозвонок'); - } else { - icon = Symbols.call; - label = missed - ? (isMe ? 'Отменённый звонок' : 'Пропущенный звонок') - : (isMe ? 'Исходящий звонок' : 'Входящий звонок'); - } - - final directionIcon = isMe ? Symbols.call_made : Symbols.call_received; - - final subtitle = missed - ? _clockText - : '$_clockText · ${formatSecondsMmSs((call.durationMs / 1000).round())}'; - - return Padding( - padding: const EdgeInsets.fromLTRB(12, 10, 16, 10), - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 38, - height: 38, - alignment: Alignment.center, - decoration: BoxDecoration( - color: missed - ? ctx.cs.error.withValues(alpha: 0.12) - : (isMe - ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) - : ctx.cs.primaryContainer), - shape: BoxShape.circle, - ), - child: Icon(icon, color: iconColor, size: 20), - ), - const SizedBox(width: 10), - Flexible( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle( - color: ctx.text, - fontSize: 14, - fontWeight: FontWeight.w500, - height: 1.2, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - directionIcon, - size: 13, - color: missed ? ctx.cs.error : ctx.dim, - ), - const SizedBox(width: 3), - Text( - subtitle, - style: TextStyle( - color: ctx.dim, - fontSize: 12, - height: 1.2, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildLocationAttachment(_BubbleCtx ctx, LocationAttachment location) { - final lat = location.latitude; - final lon = location.longitude; - final coords = lat != null && lon != null - ? '${lat.toStringAsFixed(6)}, ${lon.toStringAsFixed(6)}' - : null; - - return Padding( - padding: const EdgeInsets.fromLTRB(8, 6, 8, 4), - child: IntrinsicWidth( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: lat == null || lon == null - ? null - : () { - Haptics.tap(); - openLocationOnMap( - ctx.context, - lat, - lon, - zoom: location.zoom, - ); - }, - child: Container( - width: 240, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isMe - ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.08) - : ctx.cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Container( - width: 42, - height: 42, - decoration: BoxDecoration( - color: isMe - ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) - : ctx.cs.primaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - Symbols.location_on, - color: isMe - ? ctx.cs.onPrimaryContainer - : ctx.cs.primary, - size: 22, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - location.title ?? 'Геопозиция', - style: TextStyle( - color: ctx.text, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - location.address ?? coords ?? 'Открыть на карте', - style: TextStyle(color: ctx.dim, fontSize: 12), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - ), - ), - _buildMeta(ctx), - ], - ), - ), - ); - } - - Widget _buildVideoAttachment(_BubbleCtx ctx, MessageAttachment video) { - if (video is VideoAttachment && video.isNote) { - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - _VideoNoteBubble( - attachment: video, - messageId: message.id, - chatId: message.chatId, - cs: ctx.cs, - ), - const SizedBox(height: 6), - _buildMeta(ctx), - ], - ); - } - final hasCaption = message.text != null && message.text!.isNotEmpty; - final thumb = (video as dynamic).thumbnail as String?; - final durationMs = (video as dynamic).duration as int?; - final previewUrl = (thumb != null && thumb.isNotEmpty) - ? thumb - : (video.baseUrl != null && video.baseUrl!.isNotEmpty) - ? video.baseUrl! - : (video.previewData ?? ''); - - final w = (video as dynamic).width as int?; - final h = (video as dynamic).height as int?; - final width = (w?.toDouble() ?? 200.0).clamp(photoMinSize, photoMaxSize); - final height = (h?.toDouble() ?? 150.0).clamp(photoMinSize, photoMaxSize); - final dpr = MediaQuery.of(ctx.context).devicePixelRatio; - - Widget placeholder() => Container( - width: width, - height: height, - color: ctx.cs.surfaceContainerHighest, - child: Icon( - Symbols.videocam, - size: 48, - color: ctx.cs.onSurfaceVariant, - ), - ); - - final preview = ClipRRect( - borderRadius: BorderRadius.circular(photoBorderRadius), - child: Stack( - children: [ - previewUrl.isEmpty - ? placeholder() - : CachedNetworkImage( - imageUrl: previewUrl, - width: width, - height: height, - fit: BoxFit.cover, - memCacheWidth: (width * dpr).round(), - fadeInDuration: Duration.zero, - placeholderFadeInDuration: Duration.zero, - errorWidget: (_, _, _) => placeholder(), - ), - Positioned.fill( - child: Center( - child: Container( - width: 48, - height: 48, - decoration: const BoxDecoration( - color: Colors.black54, - shape: BoxShape.circle, - ), - child: const Icon( - Symbols.play_arrow, - color: Colors.white, - size: 30, - ), - ), - ), - ), - if (durationMs != null && durationMs > 0) - Positioned( - left: 6, - bottom: 6, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - formatSecondsMmSs((durationMs / 1000).round()), - style: const TextStyle(color: Colors.white, fontSize: 12), - ), - ), - ), - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => _playVideo(ctx.context, video), - ), - ), - ], - ), - ); - - if (!hasCaption) { - return Stack( - children: [ - preview, - Positioned( - bottom: compactTimePadding, - right: compactTimePadding, - child: _buildCompactTime(), - ), - ], - ); - } - - return SizedBox( - width: width, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - preview, - Padding( - padding: const EdgeInsets.only( - left: captionPaddingHorizontal, - right: captionPaddingRight, - bottom: 6, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded(child: _buildCaption(ctx)), - _buildMeta(ctx), - ], - ), - ), - ], - ), - ); - } - - Future _playVideo(BuildContext context, MessageAttachment video) async { - final videoId = (video as dynamic).videoId as int?; - final token = (video as dynamic).videoToken as String?; - if (videoId == null || token == null) { - showCustomNotification(context, 'Не удалось открыть видео'); - return; - } - Haptics.tap(); - - final sources = await messagesModule.getVideoSources( - messageId: message.id, - chatId: message.chatId, - token: token, - videoId: videoId, - ); - if (!context.mounted) return; - if (sources.isEmpty) { - showCustomNotification(context, 'Не удалось получить видео'); - return; - } - - Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (_) => VideoPlayerScreen(sources: sources), - ), - ); - } - - Widget _buildFileAttachment( - _BubbleCtx ctx, - MessageAttachment file, { - bool fill = false, - }) { - final name = (file as dynamic).name as String? ?? 'File'; - final size = (file as dynamic).size as int? ?? 0; - final sizeStr = formatBytes(size); - final fileId = (file as dynamic).fileId as int?; - final cacheName = '${fileId}_$name'; - - final preview = file is FileAttachment ? file.preview : null; - final previewUrl = preview?.baseUrl ?? preview?.previewData ?? ''; - - final inner = Padding( - padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - if (previewUrl.isNotEmpty) ...[ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: CachedNetworkImage( - imageUrl: previewUrl, - width: 240, - height: 160, - fit: BoxFit.cover, - memCacheWidth: 480, - fadeInDuration: const Duration(milliseconds: 120), - errorWidget: (_, _, _) => const SizedBox.shrink(), - ), - ), - const SizedBox(height: 8), - ], - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 38, - height: 38, - decoration: BoxDecoration( - color: isMe - ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) - : ctx.cs.primaryContainer, - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - Symbols.description, - color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, - size: 20, - ), - ), - const SizedBox(width: 10), - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - name, - style: TextStyle( - color: ctx.text, - fontSize: 14, - fontWeight: FontWeight.w500, - height: 1.2, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - ValueListenableBuilder( - valueListenable: MediaDownloadProgress.notifier( - cacheName, - ), - builder: (context, progress, _) => Text( - progress != null - ? '${(progress * 100).round()}% · $sizeStr' - : sizeStr, - style: TextStyle( - color: ctx.dim, - fontSize: 12, - height: 1.2, - ), - ), - ), - ], - ), - ), - const SizedBox(width: 12), - ValueListenableBuilder( - valueListenable: MediaDownloadProgress.notifier(cacheName), - builder: (context, progress, _) { - final downloading = progress != null; - return GestureDetector( - onTap: downloading - ? null - : () => _downloadFile(ctx.context, file, name), - child: Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: isMe - ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) - : ctx.cs.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: downloading - ? Padding( - padding: const EdgeInsets.all(8), - child: CircularProgressIndicator( - strokeWidth: 2, - value: progress > 0 ? progress : null, - color: isMe - ? ctx.cs.onPrimaryContainer - : ctx.cs.primary, - ), - ) - : Icon( - Symbols.download, - color: isMe - ? ctx.cs.onPrimaryContainer - : ctx.cs.primary, - size: 18, - ), - ), - ); - }, - ), - ], - ), - _buildMeta(ctx), - ], - ), - ); - return fill ? inner : IntrinsicWidth(child: inner); - } - - Widget _buildStickerAttachment(_BubbleCtx ctx, MessageAttachment sticker) { - final url = sticker.baseUrl ?? ''; - final preview = sticker.previewData ?? ''; - final staticUrl = url.isNotEmpty ? url : preview; - final lottieUrl = sticker is StickerAttachment ? sticker.lottieUrl : null; - - Widget content = Stack( - children: [ - SizedBox( - width: 150, - height: 150, - child: StickerImage( - url: staticUrl, - lottieUrl: lottieUrl, - size: 150, - memCacheWidth: 300, - ), - ), - Positioned( - bottom: compactTimePadding, - right: compactTimePadding, - child: _buildStickerMeta(ctx), - ), - ], - ); - - final onTap = onStickerTap; - if (onTap != null && sticker is StickerAttachment) { - content = GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => onTap(sticker), - child: content, - ); - } - return content; - } - - Widget _buildStickerMeta(_BubbleCtx ctx) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.35), - borderRadius: BorderRadius.circular(10), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _clockText, - style: const TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.w500, - ), - ), - if (isMe) ...[const SizedBox(width: 3), _buildStickerStatusIcon()], - if (message.deleted) ...[ - const SizedBox(width: 3), - const Icon(Symbols.delete, size: 12, color: Colors.white), - ], - ], - ), - ); - } - - Widget _buildStickerStatusIcon() { - final status = overrideStatus ?? message.status; - IconData icon; - Color color; - - switch (status) { - case 'sending': - case 'pending': - icon = Symbols.schedule; - color = Colors.white; - case null: - case 'sent': - icon = Symbols.check; - color = Colors.white; - case 'delivered': - icon = Symbols.done_all; - color = Colors.white; - case 'read': - icon = Symbols.done_all; - color = const Color(0xFF4FC3F7); - case 'error': - icon = Symbols.error; - color = Colors.redAccent; - default: - icon = Symbols.check; - color = Colors.white; - } - - return Icon(icon, size: 13, color: color); - } - - Widget _buildContactAttachment(_BubbleCtx ctx, MessageAttachment contact) { - final contactData = contact as ContactAttachment; - - final firstName = contactData.firstName ?? ''; - final lastName = contactData.lastName ?? ''; - final hasFirstName = firstName.isNotEmpty; - final hasLastName = lastName.isNotEmpty; - - final name = (hasFirstName || hasLastName) - ? '${hasFirstName ? firstName : ''}${hasLastName ? ' $lastName' : ''}' - .trim() - : (contactData.name ?? 'Contact'); - final photoUrl = contactData.photoUrl ?? contactData.baseUrl; - - final bgColor = isMe - ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) - : ctx.cs.surfaceContainerHighest; - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(24), - ), - child: photoUrl != null && photoUrl.isNotEmpty - ? ClipRRect( - borderRadius: BorderRadius.circular(24), - child: CachedNetworkImage( - imageUrl: photoUrl, - fit: BoxFit.cover, - memCacheWidth: 144, - memCacheHeight: 144, - fadeInDuration: const Duration(milliseconds: 120), - errorWidget: (_, _, _) => Icon( - Symbols.person, - color: isMe - ? ctx.cs.onPrimaryContainer - : ctx.cs.primary, - size: 24, - ), - ), - ) - : Icon( - Symbols.person, - color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, - size: 24, - ), - ), - const SizedBox(width: 12), - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - name.isNotEmpty ? name : 'Contact', - style: TextStyle( - color: ctx.text, - fontSize: 15, - fontWeight: FontWeight.w500, - height: 1.2, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (contactData.phoneNumber != null) ...[ - const SizedBox(height: 2), - Text( - contactData.phoneNumber!, - style: TextStyle(color: ctx.dim, fontSize: 12, height: 1.2), - ), - ], - ], - ), - ), - ], - ), - ); - } - - Widget _buildForwardedContactContent( - _BubbleCtx ctx, - ForwardedMessageAttachment forwarded, - ) { - final contact = forwarded.originalContact!; - - final firstName = contact.firstName ?? ''; - final lastName = contact.lastName ?? ''; - final hasFirstName = firstName.isNotEmpty; - final hasLastName = lastName.isNotEmpty; - - final name = (hasFirstName || hasLastName) - ? '${hasFirstName ? firstName : ''}${hasLastName ? ' $lastName' : ''}' - .trim() - : (contact.name ?? 'Contact'); - final photoUrl = contact.photoUrl ?? contact.baseUrl; - - final bgColor = isMe - ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) - : ctx.cs.surfaceContainerHighest; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _buildForwardedHeader(ctx, forwarded), - const SizedBox(height: 4), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(24), - ), - child: photoUrl != null && photoUrl.isNotEmpty - ? ClipRRect( - borderRadius: BorderRadius.circular(24), - child: CachedNetworkImage( - imageUrl: photoUrl, - fit: BoxFit.cover, - fadeInDuration: const Duration(milliseconds: 120), - errorWidget: (_, _, _) => Icon( - Symbols.person, - color: isMe - ? ctx.cs.onPrimaryContainer - : ctx.cs.primary, - size: 24, - ), - ), - ) - : Icon( - Symbols.person, - color: isMe - ? ctx.cs.onPrimaryContainer - : ctx.cs.primary, - size: 24, - ), - ), - const SizedBox(width: 12), - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - name.isNotEmpty ? name : 'Contact', - style: TextStyle( - color: ctx.text, - fontSize: 15, - fontWeight: FontWeight.w500, - height: 1.2, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (contact.phoneNumber != null) ...[ - const SizedBox(height: 2), - Text( - contact.phoneNumber!, - style: TextStyle( - color: ctx.dim, - fontSize: 12, - height: 1.2, - ), - ), - ], - ], - ), - ), - ], - ), - ), - ], - ); - } - - void _openPhotoViewer(BuildContext ctx, PhotoAttachment photo) { - final url = photo.baseUrl ?? ''; - if (url.isEmpty) return; - Navigator.of(ctx).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (_) => PhotoViewerScreen(baseUrl: url), - ), - ); - } - - Future _downloadFile( - BuildContext context, - MessageAttachment file, - String name, - ) async { - final fileId = (file as dynamic).fileId as int?; - if (fileId == null) { - showCustomNotification(context, 'Не удалось определить файл'); - return; - } - Haptics.tap(); - - final cacheName = '${fileId}_$name'; - - MediaDownloadProgress.set(cacheName, 0); - final result = await openCachedFile( - cacheName, - () => messagesModule.getFileUrl( - messageId: message.id, - chatId: message.chatId, - fileId: fileId, - ), - onProgress: (p) => MediaDownloadProgress.set(cacheName, p), - ); - MediaDownloadProgress.set(cacheName, null); - if (!context.mounted) return; - if (!result.ok) { - showCustomNotification( - context, - 'Ошибка загрузки: ${result.error ?? 'не удалось открыть'}', - ); - } - } - - Widget _buildVoiceContent(_BubbleCtx ctx) { + Widget _buildVoiceContent(BubbleContext ctx) { int duration = 0; String url = ''; String? waveData; @@ -2709,13 +1218,14 @@ class MessageBubble extends StatelessWidget { final cachedTranscription = TranscriptionCache.get(message.id); - return _VoiceMessageBubble( + return VoiceMessageBubble( duration: duration, url: url, textColor: ctx.text, isMe: isMe, deleted: message.deleted, status: overrideStatus ?? message.status, + otherReadTime: otherReadTime, time: message.time, cs: ctx.cs, waveData: waveData, @@ -2725,752 +1235,4 @@ class MessageBubble extends StatelessWidget { preloadedText: cachedTranscription?.text, ); } - - Widget _buildMeta(_BubbleCtx ctx) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Text(_clockText, style: TextStyle(color: ctx.dim, fontSize: 11)), - if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)], - if (message.deleted) ...[ - const SizedBox(width: 4), - _buildDeletedIcon(ctx), - ], - ], - ), - ); - } - - Widget _buildCompactTime() { - final bgColor = isMe - ? Colors.black.withValues(alpha: 0.4) - : Colors.black.withValues(alpha: 0.5); - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(4), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _clockText, - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.w500, - ), - ), - if (message.deleted) ...[ - const SizedBox(width: 3), - const Icon(Symbols.delete, size: 11, color: Colors.white), - ], - ], - ), - ); - } - - Widget _buildDeletedIcon(_BubbleCtx ctx) { - return Icon(Symbols.delete, size: 13, color: ctx.dim); - } - - Widget _buildStatusIcon(_BubbleCtx ctx) { - final status = overrideStatus ?? message.status; - IconData icon; - Color color; - - switch (status) { - case 'sending': - case 'pending': - icon = Symbols.schedule; - color = ctx.dim; - case null: - case 'sent': - icon = Symbols.check; - color = ctx.dim; - case 'delivered': - icon = Symbols.done_all; - color = ctx.dim; - case 'read': - icon = Symbols.done_all; - color = const Color(0xFF4FC3F7); - case 'error': - icon = Symbols.error; - color = Colors.redAccent; - default: - icon = Symbols.check; - color = ctx.dim; - } - - return Icon(icon, size: 14, color: color); - } -} - -class _VoiceMessageBubble extends StatefulWidget { - final int duration; - final String url; - final Color textColor; - final bool isMe; - final bool deleted; - final String? status; - final int time; - final ColorScheme cs; - final String? waveData; - final int chatId; - final String messageId; - final int? audioId; - final String? preloadedText; - - const _VoiceMessageBubble({ - required this.duration, - required this.url, - required this.textColor, - required this.isMe, - this.deleted = false, - this.status, - required this.time, - required this.cs, - this.waveData, - required this.chatId, - required this.messageId, - this.audioId, - this.preloadedText, - }); - - @override - State<_VoiceMessageBubble> createState() => _VoiceMessageBubbleState(); -} - -class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { - bool _isPlaying = false; - final ValueNotifier _progress = ValueNotifier(0.0); - bool _transcriptionVisible = false; - String? _transcriptionText; - bool _transcriptionLoading = false; - - OggOpusPlayer? _player; - bool _loadingAudio = false; - Timer? _ticker; - late final List _amps = _parseWave(widget.waveData); - - static List _parseWave(String? data) { - if (data == null || data.isEmpty) return const []; - return data.codeUnits; - } - - @override - void initState() { - super.initState(); - _transcriptionText = widget.preloadedText; - } - - @override - void dispose() { - _ticker?.cancel(); - _player?.state.removeListener(_onPlayerState); - _player?.dispose(); - _progress.dispose(); - super.dispose(); - } - - Future _togglePlay() async { - if (_loadingAudio) return; - - if (_player != null) { - if (_isPlaying) { - _player!.pause(); - } else { - if (widget.duration > 0 && - _player!.currentPosition >= widget.duration - 0.05) { - _progress.value = 0; - } - _player!.play(); - } - return; - } - - final url = widget.url; - if (url.isEmpty) return; - - setState(() => _loadingAudio = true); - try { - final name = '${widget.audioId ?? widget.messageId}.ogg'; - final file = await MediaCache.getOrDownload(name, url); - if (!mounted) return; - if (file == null) { - showCustomNotification(context, 'Не удалось загрузить аудио'); - return; - } - final player = OggOpusPlayer(file.path); - _player = player; - player.state.addListener(_onPlayerState); - _ticker = Timer.periodic( - const Duration(milliseconds: 60), - (_) => _onTick(), - ); - player.play(); - } catch (e) { - if (mounted) showCustomNotification(context, 'Ошибка воспроизведения'); - } finally { - if (mounted) setState(() => _loadingAudio = false); - } - } - - void _onTick() { - final player = _player; - if (player == null || widget.duration <= 0) return; - final pos = player.currentPosition; - _progress.value = (pos / widget.duration).clamp(0.0, 1.0); - } - - void _onPlayerState() { - final state = _player?.state.value; - if (!mounted) return; - final playing = state == PlayerState.playing; - if (playing != _isPlaying) setState(() => _isPlaying = playing); - if (state == PlayerState.ended) { - _progress.value = 1.0; - } - } - - Widget _buildStatusIcon() { - final status = widget.status; - IconData icon; - Color color; - - if (status == null || status == 'sent') { - icon = Symbols.check; - color = Colors.white54; - } else { - switch (status) { - case 'sending': - case 'pending': - icon = Symbols.schedule; - color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55); - case 'sent': - icon = Symbols.check; - color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55); - case 'delivered': - icon = Symbols.done_all; - color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55); - case 'read': - icon = Symbols.done_all; - color = const Color(0xFF4FC3F7); - case 'error': - icon = Symbols.error; - color = Colors.redAccent; - default: - icon = Symbols.check; - color = widget.cs.onPrimaryContainer.withValues(alpha: 0.55); - } - } - - return Icon(icon, size: 14, color: color); - } - - @override - Widget build(BuildContext context) { - final waveInactiveColor = widget.isMe - ? widget.cs.onPrimaryContainer.withValues(alpha: 0.35) - : widget.cs.surfaceContainerHighest; - final waveActiveColor = widget.isMe - ? widget.cs.onPrimaryContainer.withValues(alpha: 0.7) - : widget.cs.primary; - - return SizedBox( - width: 240, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - GestureDetector( - onTap: _togglePlay, - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: widget.isMe - ? widget.cs.onPrimaryContainer.withValues(alpha: 0.12) - : widget.cs.primaryContainer, - shape: BoxShape.circle, - ), - child: _loadingAudio - ? Padding( - padding: const EdgeInsets.all(8), - child: CircularProgressIndicator( - strokeWidth: 2, - color: widget.isMe - ? widget.cs.onPrimaryContainer - : widget.cs.primary, - ), - ) - : Icon( - _isPlaying ? Symbols.pause : Symbols.play_arrow, - color: widget.isMe - ? widget.cs.onPrimaryContainer - : widget.cs.primary, - size: 18, - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: SizedBox( - height: 26, - child: ValueListenableBuilder( - valueListenable: _progress, - builder: (context, progress, _) => CustomPaint( - size: Size.infinite, - painter: _WaveformPainter( - amps: _amps, - progress: progress, - active: waveActiveColor, - inactive: waveInactiveColor, - ), - ), - ), - ), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: _requestTranscription, - child: SizedBox( - width: 20, - height: 32, - child: Center( - child: _transcriptionLoading - ? SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator( - strokeWidth: 1.5, - color: widget.textColor.withValues(alpha: 0.6), - ), - ) - : Text( - 'Т', - style: TextStyle( - color: widget.textColor.withValues(alpha: 0.6), - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ), - ], - ), - const SizedBox(height: 2), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 32, - child: Center( - child: Text( - formatSecondsMmSs(widget.duration), - style: TextStyle( - color: widget.textColor.withValues(alpha: 0.7), - fontSize: 11, - ), - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: AnimatedSize( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - alignment: Alignment.topLeft, - child: _transcriptionVisible - ? Text( - _transcriptionText ?? '', - style: TextStyle( - color: widget.textColor.withValues(alpha: 0.8), - fontSize: 12, - height: 1.3, - ), - maxLines: 10, - overflow: TextOverflow.ellipsis, - ) - : const SizedBox.shrink(), - ), - ), - if (!_transcriptionVisible) ...[ - Text( - formatClock( - DateTime.fromMillisecondsSinceEpoch(widget.time), - withSeconds: KometSettings.fullTimestamp.value, - ), - style: TextStyle( - color: widget.textColor.withValues(alpha: 0.6), - fontSize: 10, - ), - ), - if (widget.isMe) ...[ - const SizedBox(width: 2), - _buildStatusIcon(), - ], - if (widget.deleted) ...[ - const SizedBox(width: 2), - Icon( - Symbols.delete, - size: 13, - color: widget.textColor.withValues(alpha: 0.6), - ), - ], - ], - ], - ), - if (_transcriptionVisible) ...[ - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Text( - formatClock( - DateTime.fromMillisecondsSinceEpoch(widget.time), - withSeconds: KometSettings.fullTimestamp.value, - ), - style: TextStyle( - color: widget.textColor.withValues(alpha: 0.6), - fontSize: 10, - ), - ), - if (widget.isMe) ...[ - const SizedBox(width: 2), - _buildStatusIcon(), - ], - if (widget.deleted) ...[ - const SizedBox(width: 2), - Icon( - Symbols.delete, - size: 13, - color: widget.textColor.withValues(alpha: 0.6), - ), - ], - ], - ), - ], - ], - ), - ); - } - - - Future _requestTranscription() async { - if (widget.audioId == null) return; - - if (_transcriptionVisible && _transcriptionText != null) { - setState(() { - _transcriptionVisible = false; - }); - return; - } - - if (TranscriptionCache.has(widget.messageId)) { - final cached = TranscriptionCache.get(widget.messageId)!; - setState(() { - _transcriptionText = cached.text ?? 'не удалось распознать текст'; - _transcriptionVisible = true; - }); - return; - } - - setState(() { - _transcriptionLoading = true; - }); - - try { - final result = await messagesModule.requestTranscription( - widget.chatId, - int.tryParse(widget.messageId) ?? 0, - widget.audioId!, - ); - - TranscriptionCache.put(widget.messageId, result); - - if (!mounted) return; - setState(() { - _transcriptionLoading = false; - if (result.status == 1) { - _transcriptionText = (result.text == null || result.text!.isEmpty) - ? 'не удалось распознать текст' - : result.text; - _transcriptionVisible = true; - } else if (result.status == 0) { - _transcriptionText = 'транскрибация...'; - _transcriptionVisible = true; - } - }); - } catch (e) { - if (!mounted) return; - setState(() { - _transcriptionLoading = false; - _transcriptionText = 'ошибка транскрибации'; - _transcriptionVisible = true; - }); - } - } -} - -class _WaveformPainter extends CustomPainter { - final List amps; - final double progress; - final Color active; - final Color inactive; - - const _WaveformPainter({ - required this.amps, - required this.progress, - required this.active, - required this.inactive, - }); - - @override - void paint(Canvas canvas, Size size) { - final center = size.height / 2; - - if (amps.isEmpty) { - final track = Paint() - ..strokeWidth = 3 - ..strokeCap = StrokeCap.round; - canvas.drawLine( - Offset(0, center), - Offset(size.width, center), - track..color = inactive, - ); - if (progress > 0) { - canvas.drawLine( - Offset(0, center), - Offset(size.width * progress.clamp(0.0, 1.0), center), - track..color = active, - ); - } - return; - } - - final n = amps.length; - var maxAmp = 1; - for (final a in amps) { - if (a > maxAmp) maxAmp = a; - } - final slot = size.width / n; - final barW = (slot * 0.55).clamp(1.0, 3.0); - final paint = Paint(); - - for (var i = 0; i < n; i++) { - final h = ((amps[i] / maxAmp) * size.height).clamp(2.0, size.height); - final x = i * slot + (slot - barW) / 2; - paint.color = ((i + 0.5) / n) <= progress ? active : inactive; - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH(x, center - h / 2, barW, h), - Radius.circular(barW / 2), - ), - paint, - ); - } - } - - @override - bool shouldRepaint(_WaveformPainter old) => - old.progress != progress || - old.active != active || - old.inactive != inactive || - !identical(old.amps, amps); -} - -class _VideoNoteBubble extends StatefulWidget { - final VideoAttachment attachment; - final String messageId; - final int chatId; - final ColorScheme cs; - - const _VideoNoteBubble({ - required this.attachment, - required this.messageId, - required this.chatId, - required this.cs, - }); - - @override - State<_VideoNoteBubble> createState() => _VideoNoteBubbleState(); -} - -class _VideoNoteBubbleState extends State<_VideoNoteBubble> { - static const double _size = 210; - VideoPlayerController? _controller; - bool _loading = false; - bool _error = false; - - @override - void dispose() { - _controller?.removeListener(_onTick); - _controller?.dispose(); - super.dispose(); - } - - void _onTick() { - if (mounted) setState(() {}); - } - - static Uint8List? _previewBytes(String? data) { - if (data == null) return null; - const marker = 'base64,'; - final idx = data.indexOf(marker); - if (idx < 0) return null; - try { - return base64Decode(data.substring(idx + marker.length)); - } catch (_) { - return null; - } - } - - Future _toggle() async { - final existing = _controller; - if (existing != null) { - setState( - () => existing.value.isPlaying ? existing.pause() : existing.play(), - ); - return; - } - if (_loading) return; - - final a = widget.attachment; - final videoId = a.videoId; - final token = a.videoToken; - if (videoId == null || token == null) { - setState(() => _error = true); - return; - } - - setState(() => _loading = true); - Haptics.tap(); - try { - final cacheName = 'videonote_$videoId.mp4'; - var file = await MediaCache.existing(cacheName); - if (file == null) { - final url = await messagesModule.getVideoUrl( - messageId: widget.messageId, - chatId: widget.chatId, - token: token, - videoId: videoId, - ); - if (url == null) throw Exception('no_url'); - file = await MediaCache.getOrDownload(cacheName, url); - if (file == null) throw Exception('download'); - } - if (!mounted) return; - final c = VideoPlayerController.file(file); - _controller = c; - await c.initialize(); - if (!mounted) { - c.dispose(); - return; - } - await c.setLooping(true); - c.addListener(_onTick); - c.play(); - setState(() => _loading = false); - } catch (_) { - if (mounted) { - setState(() { - _loading = false; - _error = true; - }); - } - } - } - - @override - Widget build(BuildContext context) { - final a = widget.attachment; - final c = _controller; - final ready = c != null && c.value.isInitialized; - final playing = ready && c.value.isPlaying; - final preview = _previewBytes(a.previewData); - - double progress = 0; - if (ready && c.value.duration.inMilliseconds > 0) { - progress = - c.value.position.inMilliseconds / c.value.duration.inMilliseconds; - } - - return GestureDetector( - onTap: _toggle, - child: SizedBox( - width: _size, - height: _size, - child: Stack( - alignment: Alignment.center, - children: [ - ClipOval( - child: SizedBox( - width: _size, - height: _size, - child: ready - ? FittedBox( - fit: BoxFit.cover, - clipBehavior: Clip.hardEdge, - child: SizedBox( - width: c.value.size.width, - height: c.value.size.height, - child: VideoPlayer(c), - ), - ) - : preview != null - ? Image.memory( - preview, - fit: BoxFit.cover, - gaplessPlayback: true, - ) - : Container(color: widget.cs.surfaceContainerHighest), - ), - ), - if (ready) - SizedBox( - width: _size - 2, - height: _size - 2, - child: CircularProgressIndicator( - value: progress.clamp(0.0, 1.0), - strokeWidth: 3, - color: widget.cs.primary, - backgroundColor: Colors.white24, - ), - ), - if (!playing) - Container( - width: 52, - height: 52, - decoration: const BoxDecoration( - color: Colors.black45, - shape: BoxShape.circle, - ), - child: _loading - ? const Padding( - padding: EdgeInsets.all(14), - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : Icon( - _error ? Symbols.error : Symbols.play_arrow, - color: Colors.white, - size: 30, - ), - ), - ], - ), - ), - ); - } } diff --git a/lib/frontend/widgets/photo_viewer.dart b/lib/frontend/widgets/photo_viewer.dart index 8cfc89e..a82cc7c 100644 --- a/lib/frontend/widgets/photo_viewer.dart +++ b/lib/frontend/widgets/photo_viewer.dart @@ -21,8 +21,11 @@ class PhotoViewerScreen extends StatelessWidget { maxScale: 5, child: Center( child: _url.isEmpty - ? const Icon(Symbols.broken_image, - color: Colors.white54, size: 64) + ? const Icon( + Symbols.broken_image, + color: Colors.white54, + size: 64, + ) : CachedNetworkImage( imageUrl: _url, fit: BoxFit.contain, diff --git a/lib/frontend/widgets/poll_view.dart b/lib/frontend/widgets/poll_view.dart index e17d4b0..6f0d2c8 100644 --- a/lib/frontend/widgets/poll_view.dart +++ b/lib/frontend/widgets/poll_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../main.dart'; +import '../../core/utils/format.dart'; import '../../core/utils/haptics.dart'; import '../../models/poll.dart'; import 'custom_notification.dart'; @@ -62,7 +63,7 @@ class _PollViewState extends State widget.chatId, widget.messageId, widget.pollId, - force: true, + force: false, ); } @@ -177,7 +178,8 @@ class _PollViewState extends State ? 'Несколько вариантов ответа' : 'Один вариант ответа'; if (poll.total == 0) return kind; - return '$kind · ${_votesLabel(poll.total)}'; + return '$kind · ${poll.total} ' + '${pluralRu(poll.total, 'голос', 'голоса', 'голосов')}'; } Widget _buildChoiceRow(PollAnswer answer, bool multiple) { @@ -274,8 +276,7 @@ class _PollViewState extends State ) { final pct = total > 0 ? answer.voteCount / total : 0.0; final value = answer.rate > 0 ? (answer.rate / 100.0).clamp(0.0, 1.0) : pct; - final pctLabel = - '${(answer.rate > 0 ? answer.rate : pct * 100).round()}%'; + final pctLabel = '${(answer.rate > 0 ? answer.rate : pct * 100).round()}%'; final leadWidth = 30.0 * (1 - m); final dotOpacity = (1 - m * 1.8).clamp(0.0, 1.0); @@ -358,8 +359,9 @@ class _PollViewState extends State value: fillFactor, minHeight: 6, backgroundColor: widget.dimColor.withValues(alpha: 0.2), - valueColor: - AlwaysStoppedAnimation(widget.accentColor), + valueColor: AlwaysStoppedAnimation( + widget.accentColor, + ), ), ), ), @@ -369,18 +371,4 @@ class _PollViewState extends State ), ); } - - String _votesLabel(int total) { - final mod10 = total % 10; - final mod100 = total % 100; - String word; - if (mod10 == 1 && mod100 != 11) { - word = 'голос'; - } else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) { - word = 'голоса'; - } else { - word = 'голосов'; - } - return '$total $word'; - } } diff --git a/lib/frontend/widgets/primary_loading_button.dart b/lib/frontend/widgets/primary_loading_button.dart new file mode 100644 index 0000000..64803fb --- /dev/null +++ b/lib/frontend/widgets/primary_loading_button.dart @@ -0,0 +1,46 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class PrimaryLoadingButton extends StatelessWidget { + final ValueListenable loading; + final VoidCallback? onPressed; + final Widget child; + final Color? background; + final Color? foreground; + + const PrimaryLoadingButton({ + super.key, + required this.loading, + required this.onPressed, + required this.child, + this.background, + this.foreground, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final fg = foreground ?? cs.onPrimary; + return ValueListenableBuilder( + valueListenable: loading, + builder: (context, isLoading, _) => FilledButton( + onPressed: isLoading ? null : onPressed, + style: FilledButton.styleFrom( + backgroundColor: background ?? cs.primary, + foregroundColor: fg, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: fg), + ) + : child, + ), + ); + } +} diff --git a/lib/frontend/widgets/prompt_dialog.dart b/lib/frontend/widgets/prompt_dialog.dart new file mode 100644 index 0000000..9eab14f --- /dev/null +++ b/lib/frontend/widgets/prompt_dialog.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +Future showTextInputDialog( + BuildContext context, { + String? title, + String? description, + String? hint, + String? initialValue, + String confirmLabel = 'Подтвердить', + String cancelLabel = 'Отмена', + bool obscureText = false, + int maxLines = 1, + TextInputType? keyboardType, +}) async { + final tec = TextEditingController(text: initialValue); + try { + return await showDialog( + context: context, + builder: (dialogContext) { + final cs = Theme.of(dialogContext).colorScheme; + return AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + title: title == null + ? null + : Text( + title, + style: TextStyle( + fontFamily: 'Outfit', + fontWeight: FontWeight.w600, + fontSize: 18, + color: cs.onSurface, + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (description != null) ...[ + Text( + description, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + height: 1.35, + ), + ), + const SizedBox(height: 18), + ], + TextField( + controller: tec, + autofocus: true, + obscureText: obscureText, + maxLines: obscureText ? 1 : maxLines, + keyboardType: keyboardType, + decoration: InputDecoration(hintText: hint), + onSubmitted: (v) { + final t = v.trim(); + Navigator.pop(dialogContext, t.isEmpty ? null : t); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text( + cancelLabel, + style: TextStyle(color: cs.onSurfaceVariant), + ), + ), + FilledButton( + onPressed: () { + final t = tec.text.trim(); + Navigator.pop(dialogContext, t.isEmpty ? null : t); + }, + child: Text(confirmLabel), + ), + ], + ); + }, + ); + } finally { + tec.dispose(); + } +} diff --git a/lib/frontend/widgets/rich_message_controller.dart b/lib/frontend/widgets/rich_message_controller.dart index f4045e5..ef65efd 100644 --- a/lib/frontend/widgets/rich_message_controller.dart +++ b/lib/frontend/widgets/rich_message_controller.dart @@ -31,8 +31,7 @@ class RichMessageController extends TextEditingController { super.value = newValue; } - bool get hasFormatting => - _intervals.values.any((list) => list.isNotEmpty); + bool get hasFormatting => _intervals.values.any((list) => list.isNotEmpty); void clearFormatting() { if (_intervals.isEmpty) return; @@ -44,9 +43,9 @@ class RichMessageController extends TextEditingController { _intervals.clear(); for (final range in ranges) { if (!composerFormats.contains(range.format)) continue; - _intervals.putIfAbsent(range.format, () => []).add( - _Interval(range.start, range.end), - ); + _intervals + .putIfAbsent(range.format, () => []) + .add(_Interval(range.start, range.end)); } for (final list in _intervals.values) { _normalize(list); @@ -55,6 +54,10 @@ class RichMessageController extends TextEditingController { } List> elementsForSend() { + return serializeFormatElements(_toFormatRanges()); + } + + List _toFormatRanges() { final ranges = []; _intervals.forEach((format, list) { for (final interval in list) { @@ -67,7 +70,7 @@ class RichMessageController extends TextEditingController { ); } }); - return serializeFormatElements(ranges); + return ranges; } bool isFormatActive(TextFormat format) { @@ -205,18 +208,7 @@ class RichMessageController extends TextEditingController { return TextSpan(style: baseStyle, text: content); } - final ranges = []; - _intervals.forEach((format, list) { - for (final interval in list) { - ranges.add( - FormatRange( - format: format, - start: interval.start, - length: interval.end - interval.start, - ), - ); - } - }); + final ranges = _toFormatRanges(); final baseColor = baseStyle.color; final quoteColor = baseColor?.withValues(alpha: 0.85); diff --git a/lib/frontend/widgets/rightward_drag_recognizer.dart b/lib/frontend/widgets/rightward_drag_recognizer.dart index c63fcbb..8ac3e81 100644 --- a/lib/frontend/widgets/rightward_drag_recognizer.dart +++ b/lib/frontend/widgets/rightward_drag_recognizer.dart @@ -24,8 +24,10 @@ class RightwardDragRecognizer extends HorizontalDragGestureRecognizer { @override void handleEvent(PointerEvent event) { if (event is PointerMoveEvent) { - _velocityTrackers[event.pointer] - ?.addPosition(event.timeStamp, event.localPosition); + _velocityTrackers[event.pointer]?.addPosition( + event.timeStamp, + event.localPosition, + ); final initial = _initialPositions[event.pointer]; if (initial != null) { final dx = event.position.dx - initial.dx; @@ -46,7 +48,9 @@ class RightwardDragRecognizer extends HorizontalDragGestureRecognizer { double? deviceTouchSlop, ) { if (!super.hasSufficientGlobalDistanceToAccept( - pointerDeviceKind, deviceTouchSlop)) { + pointerDeviceKind, + deviceTouchSlop, + )) { return false; } double maxDx = 0; diff --git a/lib/frontend/widgets/schedule_time_picker.dart b/lib/frontend/widgets/schedule_time_picker.dart index 61b5037..04e9624 100644 --- a/lib/frontend/widgets/schedule_time_picker.dart +++ b/lib/frontend/widgets/schedule_time_picker.dart @@ -3,18 +3,9 @@ import 'package:flutter/material.dart'; import '../../core/utils/format.dart'; import 'custom_notification.dart'; +import 'sheet_helpers.dart'; -const List _weekdayShort = [ - 'пн', - 'вт', - 'ср', - 'чт', - 'пт', - 'сб', - 'вс', -]; - -String _two(int n) => n.toString().padLeft(2, '0'); +const List _weekdayShort = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс']; /// Барабан выбора времени отправки («Отправить позже»): три колонки — /// день, час, минута. Возвращает выбранный момент в будущем или null. @@ -26,9 +17,7 @@ Future showScheduleTimePicker( return showModalBottomSheet( context: context, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (_) => _ScheduleSheet(initial: initial, title: title), ); } @@ -165,14 +154,14 @@ class _ScheduleSheetState extends State<_ScheduleSheet> { controller: _hourCtrl, count: 24, onChanged: (i) => _hour = i, - label: _two, + label: pad2, ), _wheel( cs: cs, controller: _minuteCtrl, count: 60, onChanged: (i) => _minute = i, - label: _two, + label: pad2, ), ], ), diff --git a/lib/frontend/widgets/settings_card.dart b/lib/frontend/widgets/settings_card.dart new file mode 100644 index 0000000..b8a1870 --- /dev/null +++ b/lib/frontend/widgets/settings_card.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import 'glossy_pill.dart'; + +class SettingsCard extends StatelessWidget { + final List children; + + const SettingsCard({super.key, required this.children}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return GlossyPill( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + depth: 6, + child: Column( + children: [ + for (var i = 0; i < children.length; i++) ...[ + children[i], + if (i != children.length - 1) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + ], + ], + ), + ); + } +} + +class SettingsToggleTile extends StatelessWidget { + final IconData icon; + final String label; + final String? subtitle; + final bool value; + final ValueChanged onChanged; + final bool enabled; + + const SettingsToggleTile({ + super.key, + required this.icon, + required this.label, + this.subtitle, + required this.value, + required this.onChanged, + this.enabled = true, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: enabled ? 1 : 0.4, + child: IgnorePointer( + ignoring: !enabled, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => onChanged(!value), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + child: Row( + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + height: 1.3, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + Switch(value: value, onChanged: onChanged), + ], + ), + ), + ), + ), + ), + ); + } +} + +class SettingsNavTile extends StatelessWidget { + final IconData? icon; + final Widget? leading; + final String label; + final Color? tintColor; + final VoidCallback? onTap; + final bool isLast; + + const SettingsNavTile({ + super.key, + this.icon, + this.leading, + required this.label, + this.tintColor, + this.onTap, + this.isLast = false, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap ?? () {}, + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(20)) + : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + leading ?? + Icon( + icon, + color: tintColor ?? cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + label, + style: TextStyle( + color: tintColor ?? cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/settings_radio_tile.dart b/lib/frontend/widgets/settings_radio_tile.dart new file mode 100644 index 0000000..f3a497e --- /dev/null +++ b/lib/frontend/widgets/settings_radio_tile.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class SettingsRadioTile extends StatelessWidget { + final Widget leading; + final double leadingGap; + final String label; + final TextStyle? labelStyle; + final String? description; + final bool selected; + final VoidCallback onTap; + final ValueChanged? onTapDown; + + const SettingsRadioTile({ + super.key, + required this.leading, + this.leadingGap = 14, + required this.label, + this.labelStyle, + this.description, + required this.selected, + required this.onTap, + this.onTapDown, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final resolvedLabelStyle = + labelStyle ?? + TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500, + ); + final labelChild = description == null + ? Text(label, style: resolvedLabelStyle) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: resolvedLabelStyle), + const SizedBox(height: 2), + Text( + description!, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12.5, + height: 1.3, + ), + ), + ], + ); + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: onTapDown, + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Row( + children: [ + leading, + SizedBox(width: leadingGap), + Expanded(child: labelChild), + if (description != null) const SizedBox(width: 8), + Icon( + selected + ? Symbols.radio_button_checked + : Symbols.radio_button_unchecked, + color: selected ? cs.primary : cs.outline, + size: 22, + fill: selected ? 1 : 0, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/sliding_pill_nav.dart b/lib/frontend/widgets/sliding_pill_nav.dart index 751759e..c877d32 100644 --- a/lib/frontend/widgets/sliding_pill_nav.dart +++ b/lib/frontend/widgets/sliding_pill_nav.dart @@ -135,8 +135,9 @@ class SlidingPillNav extends StatelessWidget { child: ClipRRect( borderRadius: BorderRadius.circular(34), child: DecoratedBox( - decoration: - BoxDecoration(gradient: GlossyDecor.topSheen(base)), + decoration: BoxDecoration( + gradient: GlossyDecor.topSheen(base), + ), ), ), ), diff --git a/lib/frontend/widgets/small_spinner.dart b/lib/frontend/widgets/small_spinner.dart new file mode 100644 index 0000000..0509e3d --- /dev/null +++ b/lib/frontend/widgets/small_spinner.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +class SmallSpinner extends StatelessWidget { + final double size; + final double strokeWidth; + final Color? color; + + const SmallSpinner({ + super.key, + this.size = 26, + this.strokeWidth = 2.4, + this.color, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: size, + height: size, + child: CircularProgressIndicator( + strokeWidth: strokeWidth, + color: color ?? Theme.of(context).colorScheme.primary, + ), + ); + } +} + +class BusyOverlay extends StatelessWidget { + const BusyOverlay({super.key}); + + @override + Widget build(BuildContext context) { + return const Positioned.fill( + child: ColoredBox( + color: Colors.black54, + child: Center(child: CircularProgressIndicator(color: Colors.white)), + ), + ); + } +} diff --git a/lib/frontend/widgets/sticker_lottie.dart b/lib/frontend/widgets/sticker_lottie.dart index 5701ff9..1b685f1 100644 --- a/lib/frontend/widgets/sticker_lottie.dart +++ b/lib/frontend/widgets/sticker_lottie.dart @@ -154,8 +154,10 @@ class _StickerFrameCache { Future<_StickerFrames?> _load(String url, int pxSize, String key) async { try { - final composition = - await NetworkLottie(url, backgroundLoading: true).load(); + final composition = await NetworkLottie( + url, + backgroundLoading: true, + ).load(); final durationMs = composition.duration.inMilliseconds; var frameCount = (durationMs / 1000 * _fps).round(); frameCount = frameCount.clamp(1, 120); @@ -181,10 +183,9 @@ class _StickerFrameCache { void _evictIfNeeded() { if (_totalBytes <= _maxBytes) return; - final candidates = _entries.entries - .where((e) => e.value.active <= 0) - .toList() - ..sort((a, b) => a.value.lastUsed.compareTo(b.value.lastUsed)); + final candidates = + _entries.entries.where((e) => e.value.active <= 0).toList() + ..sort((a, b) => a.value.lastUsed.compareTo(b.value.lastUsed)); for (final candidate in candidates) { if (_totalBytes <= _maxBytes) break; _totalBytes -= candidate.value.bytes; @@ -341,7 +342,8 @@ class _StickerLottieState extends State Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { - final box = widget.size ?? + final box = + widget.size ?? (constraints.hasBoundedWidth ? constraints.biggest.shortestSide : 96.0); diff --git a/lib/frontend/widgets/sticker_pack_sheet.dart b/lib/frontend/widgets/sticker_pack_sheet.dart index fbfbff5..67c1942 100644 --- a/lib/frontend/widgets/sticker_pack_sheet.dart +++ b/lib/frontend/widgets/sticker_pack_sheet.dart @@ -2,10 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../core/utils/format.dart'; import '../../main.dart' show stickersModule, messagesModule; import '../../models/sticker.dart'; import '../screens/chats/chat_list_screen.dart'; import 'custom_notification.dart'; +import 'small_spinner.dart'; import 'sticker_image.dart'; import 'sticker_peek.dart'; @@ -21,10 +23,8 @@ Future showStickerPackSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (_) => _StickerPackSheet( - stickerId: stickerId, - knownSetId: knownSetId, - ), + builder: (_) => + _StickerPackSheet(stickerId: stickerId, knownSetId: knownSetId), ); } @@ -53,7 +53,8 @@ class _StickerPackSheetState extends State<_StickerPackSheet> { Future _load() async { try { - final setId = widget.knownSetId ?? + final setId = + widget.knownSetId ?? (widget.stickerId != null ? await stickersModule.resolveSetId(widget.stickerId!) : null); @@ -162,13 +163,7 @@ class _StickerPackSheetState extends State<_StickerPackSheet> { Widget _buildBody(ColorScheme cs) { if (_loading) { - return Center( - child: SizedBox( - width: 26, - height: 26, - child: CircularProgressIndicator(strokeWidth: 2.4, color: cs.primary), - ), - ); + return Center(child: SmallSpinner()); } final set = _set; if (_error != null || set == null) { @@ -210,7 +205,8 @@ class _StickerPackSheetState extends State<_StickerPackSheet> { ), const SizedBox(height: 2), Text( - _pluralStickers(set.stickerIds.length), + '${set.stickerIds.length} ' + '${pluralRu(set.stickerIds.length, 'стикер', 'стикера', 'стикеров')}', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), ], @@ -333,13 +329,4 @@ class _StickerPackSheetState extends State<_StickerPackSheet> { ), ); } - - String _pluralStickers(int n) { - final mod100 = n % 100; - final mod10 = n % 10; - if (mod100 >= 11 && mod100 <= 14) return '$n стикеров'; - if (mod10 == 1) return '$n стикер'; - if (mod10 >= 2 && mod10 <= 4) return '$n стикера'; - return '$n стикеров'; - } } diff --git a/lib/frontend/widgets/sticker_panel.dart b/lib/frontend/widgets/sticker_panel.dart index a30145a..9156294 100644 --- a/lib/frontend/widgets/sticker_panel.dart +++ b/lib/frontend/widgets/sticker_panel.dart @@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../main.dart' show stickersModule; import '../../models/sticker.dart'; +import 'small_spinner.dart'; import 'sticker_image.dart'; import 'sticker_lottie.dart'; import 'sticker_peek.dart'; @@ -162,17 +163,13 @@ class _StickerPanelState extends State height: widget.height, color: cs.surface, child: _loading - ? Center( - child: SizedBox( - width: 26, - height: 26, - child: CircularProgressIndicator(strokeWidth: 2.4, color: cs.primary), - ), - ) + ? Center(child: SmallSpinner()) : _error != null || _sections.isEmpty ? Center( child: Text( - _error != null ? 'Не удалось загрузить стикеры' : 'Нет стикеров', + _error != null + ? 'Не удалось загрузить стикеры' + : 'Нет стикеров', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), ) @@ -259,17 +256,26 @@ class _StickerPanelState extends State height: 44, margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 4), decoration: BoxDecoration( - color: selected ? cs.surfaceContainerHighest : Colors.transparent, + color: selected + ? cs.surfaceContainerHighest + : Colors.transparent, borderRadius: BorderRadius.circular(12), ), padding: const EdgeInsets.all(8), child: s.icon != null - ? Icon(s.icon, size: 24, color: selected ? cs.primary : cs.onSurfaceVariant) + ? Icon( + s.icon, + size: 24, + color: selected ? cs.primary : cs.onSurfaceVariant, + ) : CachedNetworkImage( imageUrl: s.iconUrl ?? '', fit: BoxFit.contain, - errorWidget: (_, _, _) => - Icon(Symbols.image, size: 20, color: cs.onSurfaceVariant), + errorWidget: (_, _, _) => Icon( + Symbols.image, + size: 20, + color: cs.onSurfaceVariant, + ), ), ), ); diff --git a/lib/frontend/widgets/swipe_route.dart b/lib/frontend/widgets/swipe_route.dart index 2927d9f..c13bcd2 100644 --- a/lib/frontend/widgets/swipe_route.dart +++ b/lib/frontend/widgets/swipe_route.dart @@ -32,7 +32,8 @@ class SwipeRoute extends PageRoute { @override bool canTransitionTo(TransitionRoute nextRoute) { - return nextRoute is SwipeRoute || nextRoute is CupertinoRouteTransitionMixin; + return nextRoute is SwipeRoute || + nextRoute is CupertinoRouteTransitionMixin; } @override @@ -103,9 +104,9 @@ Future pushSwipeable( WidgetBuilder builder, { RouteSettings? settings, }) { - return Navigator.of(context).push( - SwipeRoute(builder: builder, settings: settings), - ); + return Navigator.of( + context, + ).push(SwipeRoute(builder: builder, settings: settings)); } class _SwipeBackGestureDetector extends StatefulWidget { @@ -159,15 +160,15 @@ class _SwipeBackGestureDetectorState gestures: { RightwardDragRecognizer: GestureRecognizerFactoryWithHandlers( - () => RightwardDragRecognizer(debugOwner: this), - (instance) { - instance - ..onStart = _handleStart - ..onUpdate = _handleUpdate - ..onEnd = _handleEnd - ..onCancel = _handleCancel; - }, - ), + () => RightwardDragRecognizer(debugOwner: this), + (instance) { + instance + ..onStart = _handleStart + ..onUpdate = _handleUpdate + ..onEnd = _handleEnd + ..onCancel = _handleCancel; + }, + ), }, child: widget.child, ); @@ -175,10 +176,7 @@ class _SwipeBackGestureDetectorState } class _SwipeBackController { - _SwipeBackController({ - required this.navigator, - required this.controller, - }); + _SwipeBackController({required this.navigator, required this.controller}); final NavigatorState navigator; final AnimationController controller; diff --git a/lib/frontend/widgets/swipe_to_pop.dart b/lib/frontend/widgets/swipe_to_pop.dart index 18ce6ff..ea20bf8 100644 --- a/lib/frontend/widgets/swipe_to_pop.dart +++ b/lib/frontend/widgets/swipe_to_pop.dart @@ -57,14 +57,17 @@ class _SwipeToPopState extends State } void _onDragUpdate(DragUpdateDetails d) { - final next = - (_controller.value + (d.primaryDelta ?? 0.0) / _width).clamp(0.0, 1.0); + final next = (_controller.value + (d.primaryDelta ?? 0.0) / _width).clamp( + 0.0, + 1.0, + ); _controller.value = next; } Future _onDragEnd(DragEndDetails d) async { final velocity = d.velocity.pixelsPerSecond.dx; - final pastThreshold = _controller.value > widget.popThreshold || + final pastThreshold = + _controller.value > widget.popThreshold || velocity > widget.velocityThreshold; if (pastThreshold) { final remaining = 1.0 - _controller.value; @@ -101,15 +104,15 @@ class _SwipeToPopState extends State gestures: { RightwardDragRecognizer: GestureRecognizerFactoryWithHandlers( - () => RightwardDragRecognizer(debugOwner: this), - (instance) { - instance - ..onStart = _onDragStart - ..onUpdate = _onDragUpdate - ..onEnd = _onDragEnd - ..onCancel = _onDragCancel; - }, - ), + () => RightwardDragRecognizer(debugOwner: this), + (instance) { + instance + ..onStart = _onDragStart + ..onUpdate = _onDragUpdate + ..onEnd = _onDragEnd + ..onCancel = _onDragCancel; + }, + ), }, child: AnimatedBuilder( animation: _controller, diff --git a/lib/frontend/widgets/theme_reveal.dart b/lib/frontend/widgets/theme_reveal.dart index 3db78d0..808495c 100644 --- a/lib/frontend/widgets/theme_reveal.dart +++ b/lib/frontend/widgets/theme_reveal.dart @@ -21,10 +21,7 @@ class ThemeRevealOverlay { animation.value.clamp(0.0, 1.0), ); return ClipPath( - clipper: _RevealClipper( - center: center, - radius: maxRadius * t, - ), + clipper: _RevealClipper(center: center, radius: maxRadius * t), child: Opacity( opacity: 1.0 - (t * t * t * t), child: RawImage( diff --git a/lib/frontend/widgets/video_player_screen.dart b/lib/frontend/widgets/video_player_screen.dart index 3f73f88..6ed451a 100644 --- a/lib/frontend/widgets/video_player_screen.dart +++ b/lib/frontend/widgets/video_player_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:video_player/video_player.dart'; +import '../../core/utils/format.dart'; + class VideoPlayerScreen extends StatefulWidget { final Map sources; final String? initialQuality; @@ -22,11 +24,13 @@ class _VideoPlayerScreenState extends State { bool _controlsVisible = true; double? _dragValue; late String _quality; + int _loadGeneration = 0; @override void initState() { super.initState(); - _quality = widget.initialQuality != null && + _quality = + widget.initialQuality != null && widget.sources.containsKey(widget.initialQuality) ? widget.initialQuality! : widget.sources.keys.first; @@ -44,6 +48,7 @@ class _VideoPlayerScreenState extends State { return; } + final generation = ++_loadGeneration; final old = _controller; final controller = VideoPlayerController.networkUrl(Uri.parse(url)); _controller = controller; @@ -60,12 +65,20 @@ class _VideoPlayerScreenState extends State { await controller.dispose(); return; } + if (generation != _loadGeneration) { + return; + } if (position != null) await controller.seekTo(position); + if (generation != _loadGeneration) { + return; + } controller.addListener(_onTick); if (wasPlaying) controller.play(); setState(() {}); } catch (_) { - if (mounted) setState(() => _error = true); + if (generation == _loadGeneration && mounted) { + setState(() => _error = true); + } } } @@ -98,18 +111,6 @@ class _VideoPlayerScreenState extends State { setState(() => _controlsVisible = !_controlsVisible); } - static String _fmt(Duration d) { - final s = d.inSeconds; - final sec = (s % 60).toString().padLeft(2, '0'); - final m = s ~/ 60; - if (m >= 60) { - final h = m ~/ 60; - final mm = (m % 60).toString().padLeft(2, '0'); - return '$h:$mm:$sec'; - } - return '$m:$sec'; - } - @override Widget build(BuildContext context) { final c = _controller; @@ -128,14 +129,16 @@ class _VideoPlayerScreenState extends State { child: _error ? const Icon(Symbols.error, color: Colors.white54, size: 64) : ready - ? AspectRatio( - aspectRatio: c.value.aspectRatio, - child: VideoPlayer(c), - ) - : const CircularProgressIndicator(color: Colors.white), + ? AspectRatio( + aspectRatio: c.value.aspectRatio, + child: VideoPlayer(c), + ) + : const CircularProgressIndicator(color: Colors.white), ), if (buffering) - const Center(child: CircularProgressIndicator(color: Colors.white)), + const Center( + child: CircularProgressIndicator(color: Colors.white), + ), if (!_error) AnimatedOpacity( opacity: _controlsVisible ? 1 : 0, @@ -192,7 +195,9 @@ class _VideoPlayerScreenState extends State { onSelected: _switchQuality, child: Container( padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 6), + horizontal: 12, + vertical: 6, + ), decoration: BoxDecoration( color: Colors.white24, borderRadius: BorderRadius.circular(8), @@ -200,12 +205,19 @@ class _VideoPlayerScreenState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Symbols.tune, - color: Colors.white, size: 18), + const Icon( + Symbols.tune, + color: Colors.white, + size: 18, + ), const SizedBox(width: 6), - Text(_quality, - style: const TextStyle( - color: Colors.white, fontSize: 14)), + Text( + _quality, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), ], ), ), @@ -225,9 +237,10 @@ class _VideoPlayerScreenState extends State { size: 18, ), const SizedBox(width: 8), - Text(q, - style: - const TextStyle(color: Colors.white)), + Text( + q, + style: const TextStyle(color: Colors.white), + ), ], ), ), @@ -253,19 +266,27 @@ class _VideoPlayerScreenState extends State { ), ), Padding( - padding: EdgeInsets.only(left: 12, right: 12, bottom: bottomPad + 8), + padding: EdgeInsets.only( + left: 12, + right: 12, + bottom: bottomPad + 8, + ), child: Row( children: [ - Text(_fmt(position), - style: const TextStyle(color: Colors.white, fontSize: 12)), + Text( + formatDurationClock(position), + style: const TextStyle(color: Colors.white, fontSize: 12), + ), Expanded( child: SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: 2, thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 6), + enabledThumbRadius: 6, + ), overlayShape: const RoundSliderOverlayShape( - overlayRadius: 14), + overlayRadius: 14, + ), activeTrackColor: Colors.white, inactiveTrackColor: Colors.white30, thumbColor: Colors.white, @@ -282,15 +303,18 @@ class _VideoPlayerScreenState extends State { onChangeEnd: maxMs <= 0 ? null : (v) { - _controller - ?.seekTo(Duration(milliseconds: v.round())); + _controller?.seekTo( + Duration(milliseconds: v.round()), + ); setState(() => _dragValue = null); }, ), ), ), - Text(_fmt(duration), - style: const TextStyle(color: Colors.white, fontSize: 12)), + Text( + formatDurationClock(duration), + style: const TextStyle(color: Colors.white, fontSize: 12), + ), ], ), ), diff --git a/lib/frontend/widgets/web_qr_login.dart b/lib/frontend/widgets/web_qr_login.dart index 3f763ab..96feca7 100644 --- a/lib/frontend/widgets/web_qr_login.dart +++ b/lib/frontend/widgets/web_qr_login.dart @@ -8,9 +8,7 @@ Future showWebQrLoginConfirmSheet(BuildContext context) async { final agreed = await showModalBottomSheet( context: context, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (sheetContext) { final cs = Theme.of(sheetContext).colorScheme; return SafeArea( @@ -24,7 +22,8 @@ Future showWebQrLoginConfirmSheet(BuildContext context) async { const SizedBox(height: 20), Text( 'Вход по QR', - style: TextStyle(fontFamily: 'Outfit', + style: TextStyle( + fontFamily: 'Outfit', fontSize: 20, fontWeight: FontWeight.w700, color: cs.onSurface, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e00dab0..143a7b8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -181,5 +181,641 @@ "editProfileRemovePhoto": "Remove photo", "registrationTitle": "Create your profile", "registrationSubtitle": "Add your name and pick an avatar", - "registrationChooseAvatar": "Choose an avatar" + "registrationChooseAvatar": "Choose an avatar", + "msgActionsCopy": "Copy", + "msgActionsEdit": "Edit", + "msgActionsReply": "Reply", + "msgActionsForward": "Forward", + "msgActionsMarkUnread": "Mark as unread", + "msgActionsEditHistory": "Edit history", + "msgActionsReport": "Report", + "msgActionsDelete": "Delete", + "msgActionsCopied": "Copied", + "msgActionsLoadReasonsFailed": "Failed to load reasons", + "msgActionsCurrentVersion": "current version", + "msgActionsCurrentVersionWithDate": "current version · {date}", + "@msgActionsCurrentVersionWithDate": { + "placeholders": { + "date": { + "type": "String" + } + } + }, + "msgActionsNoText": "(no text)", + "notificationsSaveFailed": "Could not save: {error}", + "@notificationsSaveFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "notificationsFkmAlreadyHasFcm": "Why? You already have FCM.", + "notificationsFkmDownloadFcm": "Better download the FCM version.", + "notificationsTitle": "Notifications", + "notificationsFkmSectionTitle": "FKM", + "notificationsFkmEnableLabel": "Enable notifications", + "notificationsFkmEnableSubtitle": "For FKM notifications to work, the app will need to keep a notification in the shade.", + "notificationsMainSectionTitle": "Notifications", + "notificationsAllLabel": "All notifications", + "notificationsNewSectionTitle": "New notifications", + "notificationsPreviewLabel": "Message preview", + "notificationsSoundLabel": "Sound", + "notificationsAdditionalSectionTitle": "Additional", + "notificationsCallsLabel": "Call notifications", + "notificationsNewContactsLabel": "Notifications from new contacts", + "notificationsHapticsSectionTitle": "Haptic feedback", + "notificationsHapticsLabel": "Haptic feedback", + "notificationsHapticsSubtitle": "Vibration feedback for actions in the app", + "devicesLoadFailed": "Failed to load: {error}", + "@devicesLoadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "devicesQrLinkDialogTitle": "Link from QR", + "devicesQrLinkDialogHint": "Paste the QR code content", + "devicesAllTerminated": "All sessions terminated", + "devicesGenericError": "Error: {error}", + "@devicesGenericError": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "devicesIpLookupError": "IP error: {error}", + "@devicesIpLookupError": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "devicesTitle": "Devices", + "devicesPromoTitle": "Devices in KOMET", + "devicesPromoSubtitle": "Who has access to your account?", + "devicesScanQrButton": "Scan QR", + "devicesCurrentSuffix": " (current)", + "devicesOnlineStatus": "Online", + "devicesTerminateOthersButton": "Terminate all sessions except the current one", + "devicesMobileNetworkLabel": "Mobile network", + "devicesProxyDetectedLabel": "Proxy/VPN detected", + "themeSettingsTitle": "Theme", + "themeSettingsModeCardTitle": "Theme mode", + "themeSettingsModeCardSubtitle": "Light, dark, or automatic switching", + "themeSettingsModeSystem": "System", + "themeSettingsModeLight": "Light", + "themeSettingsModeDark": "Dark", + "themeSettingsModeSchedule": "Scheduled", + "themeSettingsAmoledTitle": "AMOLED black", + "themeSettingsAmoledSubtitle": "Pure black background for OLED screens", + "themeSettingsScheduleTitle": "Schedule", + "themeSettingsScheduleSubtitleEnabled": "When dark theme turns on automatically", + "themeSettingsScheduleSubtitleDisabled": "Available in \"Scheduled\" mode", + "themeSettingsScheduleDarkFrom": "Dark from", + "themeSettingsScheduleLightFrom": "Light from", + "appearanceTitle": "Appearance", + "appearanceVisualStyleTitle": "Visual style", + "appearanceVisualStyleSubtitle": "Material You or dimensional Glossy capsules", + "appearanceVisualStyleMaterialYou": "Material You", + "appearanceVisualStyleGlossy": "Glossy", + "appearanceChatChromeTitle": "Chat screen elements", + "appearanceChatChromeSubtitle": "Background of the top and bottom panels: color, blur, or transparent. With blur or transparency, messages scroll under the panels", + "appearanceChatChromeColor": "Color", + "appearanceChatChromeBlur": "Blur", + "appearanceChatChromeNone": "None", + "appearanceGradientTitle": "Gradient", + "appearanceGradientSubtitle": "Depth and highlights in Glossy capsules", + "appearanceAccentColorTitle": "Accent color", + "appearanceAccentColorSystem": "System", + "appearanceAccentColorSubtitle": "Main color of the interface and bubbles", + "appearanceAccentColorSystemActive": "System color is active", + "appearanceAccentColorReset": "Reset to system", + "appearanceBubbleShapeTitle": "Message shape", + "appearanceBubbleShapeSubtitle": "Bubble corner rounding", + "appearanceBubbleShapeMobile": "TG Mobile", + "appearanceBubbleShapeDesktop": "TG Desktop", + "appearanceBubbleBehaviorTitle": "Message behavior", + "appearanceBubbleBehaviorSubtitle": "Whether bubble shape changes based on neighbors in a group", + "appearanceBubbleBehaviorMutable": "Mutable", + "appearanceBubbleBehaviorImmutable": "Immutable", + "appearancePreviewHello": "Hi!", + "appearancePreviewHowIsIt": "How do you like it?", + "appearancePreviewHmm": "hmm...", + "appearancePreviewNotBad": "Not bad at all!", + + "callKometDetectedNotification": "This person uses Komet! :3", + "callStatusConnecting": "Connecting", + "callGroupConnecting": "Connecting…", + "callGroupWaitingParticipants": "Waiting for participants…", + "callParticipantYou": "You", + "callParticipantFallback": "Participant", + "callTooltipMinimize": "Minimize", + "callTooltipKometHub": "Komet", + "callInfoTitle": "About call", + "callPeerMicOff": "Microphone off", + "callPeerCameraOn": "Camera on", + "callUnknownName": "Unknown", + "callIncoming": "Incoming call", + "callStatusRinging": "Calling", + "callStatusEnded": "Call ended", + "callDecline": "Decline", + "callAccept": "Accept", + "callSpeaker": "Speaker", + "callVideoLabel": "Video", + "callScreenLabel": "Screen", + "callUnmute": "Unmute", + "callMute": "Mute", + "callEndButton": "End", + "callInfoClient": "Client", + "callInfoPlatform": "Platform", + "callInfoCountry": "Country", + "callInfoInContacts": "In contacts", + "callValueYes": "yes", + "callValueNo": "no", + "callInfoPeerIp": "Peer IP", + "callInfoPeerNetwork": "Peer network", + "callInfoPath": "Connection path", + "callInfoCodec": "Codec", + "callInfoServer": "Server", + "callInfoTopology": "Topology", + "callInfoStatus": "Status", + "callStatusValueConnected": "connected", + "callStatusValueConnecting": "connecting…", + "callInfoPeerMic": "Peer microphone", + "callMicValueOn": "on", + "callMicValueOff": "off", + "callInfoPeerCamera": "Peer camera", + "callCameraValueOn": "on", + "callCameraValueOff": "off", + "callInfoVideoTrack": "Video track", + "callInfoVideoTrackPresent": "yes ({count})", + "@callInfoVideoTrackPresent": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "callInfoVideoSize": "Video size", + "callInfoFrameRendering": "Frame rendering", + "callBadgeEncrypted": "Encrypted", + "callBadgeAudio": "Audio", + "callBadgeRecording": "Recording", + "callBadgeNoiseSuppression": "Noise suppression", + "callBadgeAnimoji": "Animoji", + "callInfoNoDataYet": "Data will appear after connecting…", + + "hubTitleMenu": "Komet", + "hubChatPageTitle": "Anonymous chat", + "hubGamesTitle": "Games", + "hubCheckersTitle": "Checkers", + "hubChatTileTitle": "Chat", + "hubChatTileSubtitle": "Anonymous messages", + "hubGamesTileSubtitle": "Play with your partner", + "hubCheckersTileSubtitle": "Russian checkers", + "hubMoreSoonTitle": "More coming soon…", + "hubMoreSoonSubtitle": "In development", + "hubChatPrivacyNote": "Sent directly through the call, stored nowhere", + "hubChatEmpty": "No messages yet", + "hubChatInputHint": "Message…", + "hubCheckersRestart": "Restart", + "hubCheckersYouWhite": "You're playing white", + "hubCheckersYouBlack": "You're playing black", + "hubCheckersWon": "You won 🎉", + "hubCheckersLost": "You lost", + "hubCheckersYourMove": "Your move", + "hubCheckersOpponentMove": "Opponent's move…", + + "scheduledPickTimeTitle": "When to send", + "scheduledEditTitle": "Edit", + "scheduledMessageTextHint": "Message text", + "scheduledSave": "Save", + "scheduledEditFailed": "Failed to edit message", + "scheduledDeleteConfirmTitle": "Delete scheduled message?", + "scheduledDeleteConfirmMessage": "The message won't be sent.", + "scheduledDeleteConfirmLabel": "Delete", + "scheduledDeleteFailed": "Failed to delete message", + "scheduledAppBarTitle": "Scheduled", + "scheduledEmpty": "No scheduled messages", + "scheduledAttachPhoto": "Photo", + "scheduledAttachVideo": "Video", + "scheduledAttachVoice": "Voice message", + "scheduledAttachFile": "File", + "scheduledAttachLocation": "Location", + "scheduledAttachForwarded": "Forwarded", + "scheduledAttachGeneric": "Attachment", + + "contactProfileLoadError": "Error: {error}", + "@contactProfileLoadError": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "contactProfileBot": "Bot", + "contactProfileOnline": "Online", + "contactProfileRecentlyActive": "Recently active", + "contactProfileActionChat": "Chat", + "contactProfileActionSound": "Sound", + "contactProfileActionCall": "Call", + "contactProfileInfoPhone": "Phone", + "contactProfileInfoCountry": "Country", + "contactProfileInfoGender": "Gender", + "contactProfileInfoRegistration": "Registration", + "contactProfileInfoUpdated": "Updated", + "contactProfileInfoAccountStatus": "Account status", + "contactProfileInfoDescription": "Description", + "contactProfileInfoLink": "Link", + "contactProfileInfoFlags": "Flags", + + "nfcPeerNameFallback": "Contact #{id}", + "@nfcPeerNameFallback": { + "placeholders": { + "id": { + "type": "String" + } + } + }, + "nfcPeerFirstNameFallback": "Contact", + "nfcContactAdded": "Contact added", + "nfcAddFailed": "Failed to add: {error}", + "@nfcAddFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "nfcReasonBluetoothOff": "Turn on Bluetooth and try again", + "nfcReasonPermission": "Bluetooth permissions are needed for exchange", + "nfcReasonDefault": "Failed to establish connection", + "nfcSheetTitle": "Contact exchange", + "nfcUnsupported": "NFC is not available on this device", + "nfcDisabled": "Turn on NFC in phone settings and try again", + "nfcScanningTitle": "Hold the phones close together", + "nfcScanningSubtitle": "Both devices must keep this screen open", + "nfcExchangingTitle": "Exchanging contacts…", + "nfcExchangingSubtitle": "Almost done", + "nfcPeerIdFallback": "ID {id}", + "@nfcPeerIdFallback": { + "placeholders": { + "id": { + "type": "String" + } + } + }, + "nfcAdded": "Added", + "nfcAddContact": "Add contact", + + "chatInfoTabGeneralChats": "Common chats", + "chatInfoTabMedia": "Media", + "chatInfoTabFiles": "Files", + "chatInfoTabVoice": "Voice messages", + "chatInfoTabLinks": "Links", + "chatInfoTabMembers": "Members", + "chatInfoEmptyGeneralChats": "No common chats", + "chatInfoEmptyMedia": "No media", + "chatInfoEmptyFiles": "No files", + "chatInfoEmptyVoice": "No voice messages", + "chatInfoEmptyLinks": "No links", + "chatInfoOnlineOfTotal": "{online} of {total} online", + "@chatInfoOnlineOfTotal": { + "placeholders": { + "online": { + "type": "String" + }, + "total": { + "type": "String" + } + } + }, + "chatInfoActionLeave": "Leave", + "chatInfoBio": "About", + "chatInfoInviteLink": "Invite link", + "chatInfoCollapse": "Collapse", + "chatInfoShowMore": "More", + "chatInfoAddMember": "Add member", + "chatInfoRoleOwner": "owner", + "chatInfoRoleAdmin": "Admin", + "chatInfoNoData": "No data", + "chatInfoHideExtra": "Hide", + "chatInfoShowMoreExtra": "Details", + "chatInfoRowId": "Chat ID", + "chatInfoRowCreated": "Created", + "chatInfoRowModified": "Modified", + "chatInfoRowMembersCount": "Members", + "chatInfoRowOwner": "Owner", + "chatInfoRowCreatedGroup": "Created", + "chatInfoRowJoined": "Joined", + "chatInfoRowModifiedGroup": "Modified", + "chatInfoRowHasBots": "Has bots", + "chatInfoRowBlockedCount": "Blocked", + "chatInfoRowOfficialGroup": "Official", + "chatInfoRowSignAdmin": "Admin signature", + "chatInfoRowSubscribersCount": "Subscribers", + "chatInfoRowOfficialChannel": "Official", + "chatInfoRowComments": "Comments", + "chatInfoRowRkn": "Roskomnadzor approved", + "chatInfoRowOnlyAdmin": "Admins only", + + "securityTitle": "Security", + "securityLoadError": "Loading error: {error}", + "@securityLoadError": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "securitySaveError": "Save error: {error}", + "@securitySaveError": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "securityPrivacyAll": "Everyone", + "securityPrivacyContacts": "My contacts", + "securityPrivacyNobody": "Nobody", + "securityFamilyProtection": "Family protection", + "securityEnabledFem": "Enabled", + "securityDisabledFem": "Disabled", + "securityPasswordTitle": "Login password", + "securityEnabledMasc": "Enabled", + "securityDisabledMasc": "Disabled", + "securityModeTitle": "Safe mode", + "securityModeSubtitle": "Hides personal information", + "securitySettingsUnavailable": "Changing this setting is not available yet", + "securityFindByPhone": "Find me by phone number", + "securityWhoCanCall": "Who can call me", + "securityWhoCanInvite": "Who can invite me to chats", + "securityShowContact": "Show contact", + "securityContentSafe": "Safe", + "securityContentAll": "All", + "securityShowOnlineStatus": "See online status", + "securityShowMyNumber": "See my number", + "securityConfirmTitle": "Are you sure?", + "securityHiddenStatusWarning": "You won't be able to see the online status of other users.", + "securityConfidentialityHeader": "PRIVACY", + "securityReadReceipts": "Read receipts", + "securityAltKeyboard": "Alternative keyboard", + "securityUnsafeFiles": "Accept unsafe files", + "securityAudioTranscription": "Audio transcription", + "securityBlacklistTitle": "Blacklist", + "securityBlacklistNotification": "Blacklist: {count} contacts", + "@securityBlacklistNotification": { + "placeholders": { + "count": { + "type": "String" + } + } + }, + + "passwordEntryWrongPassword": "Wrong password", + "passwordEntryConfirmTitle": "Confirm password", + "passwordEntryCurrentPasswordHint": "Current password", + "passwordEntryContinue": "Continue", + "passwordEntryNotSetTitle": "Password is not set", + "passwordEntry2faSubtitle": "Two-factor authentication", + "passwordEntrySetupAction": "Set password", + "passwordEntryGateMessage": "Enter your login password to manage protection", + "passwordEntryGenericPasswordHint": "Password", + "passwordEntrySetTitle": "Password is set", + "passwordEntryHintPrefix": "Hint: {hint}", + "@passwordEntryHintPrefix": { + "placeholders": { + "hint": { + "type": "String" + } + } + }, + "passwordEntryChangePasswordAction": "Change password", + "passwordEntryChangeEmailAction": "Change email", + "passwordEntryDeleteAction": "Delete password", + "passwordEntryMinPasswordError": "Password must be at least 6 characters", + "passwordEntryMismatchError": "Passwords do not match", + "passwordEntryInvalidEmailError": "Enter a valid email", + "passwordEntryInvalidCodeError": "Enter the 6-digit code", + "passwordEntrySetupTitle": "Password setup", + "passwordEntryStepPassword": "Password", + "passwordEntryStepHint": "Hint", + "passwordEntryStepEmail": "Email", + "passwordEntryStepCode": "Code", + "passwordEntryChoosePassword": "Choose a password", + "passwordEntryMinCharsHint": "At least 6 characters", + "passwordEntryEnterPasswordHint": "Enter password", + "passwordEntryEnterAgain": "Enter the password again", + "passwordEntryRepeatHint": "Repeat password", + "passwordEntryHintForPassword": "Password hint", + "passwordEntryOptional": "Optional", + "passwordEntryHintFieldHint": "Enter a hint (optional)", + "passwordEntryLinkEmail": "Link an email", + "passwordEntryEmailPurpose": "For password recovery. Optional", + "passwordEntryEmailHintOptional": "example@mail.com (optional)", + "passwordEntryEnterCode": "Enter the code", + "passwordEntryCodeSentTo": "Code sent to {email}", + "@passwordEntryCodeSentTo": { + "placeholders": { + "email": { + "type": "String" + } + } + }, + "passwordEntryChangedNotif": "Password changed", + "passwordEntryNewPassword": "New password", + "passwordEntryNewPasswordHint": "Enter new password", + "passwordEntryRepeatNewPasswordHint": "Repeat new password", + "passwordEntryEmailChangedNotif": "Email changed", + "passwordEntryNewEmail": "New email", + "passwordEntryEmailHint": "example@mail.com", + "passwordEntryRemovedNotif": "Password removed", + "passwordEntryRemoveTitle": "Remove password", + "passwordEntryRemoveWarning": "Warning! Removing the password will weaken your account's protection.", + "cloudStorageNoActiveProfile": "No active profile", + "cloudStorageSetupFailed": "Could not create environment", + "cloudStorageTitle": "Cloud storage", + "cloudStorageNotConfiguredTitle": "Cloud storage environment isn't set up", + "cloudStorageNotConfiguredSubtitle": "Let's start? It's quick.", + "cloudStorageStart": "Start", + "cloudStorageUploadingPercent": "Uploading {percent}%", + "@cloudStorageUploadingPercent": { + "placeholders": { + "percent": { + "type": "String" + } + } + }, + "cloudStorageStartUploadHint": "Start an upload to see the progress bar", + "cloudStorageEmptyTitle": "No cloud files yet...", + "cloudStorageEmptySubtitle": "Add one?", + "cloudStorageUpload": "Upload", + "cloudStorageFromFile": "From file", + "cloudStorageById": "By ID", + "cloudStorageFileIdLabel": "File ID", + "cloudStorageSizeLabel": "Size", + "cloudStorageNoLinkYet": "No link yet. Create one.", + "cloudStorageLinkExpiresIn": "Link expires in {time}", + "@cloudStorageLinkExpiresIn": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "cloudStorageLinkCopied": "Link copied", + "cloudStorageInvalidId": "Invalid ID", + "cloudStorageSendError": "Send error", + "cloudStorageSendByIdTitle": "Send by ID", + "cloudStorageSend": "Send", + "digitalIdGosuslugiLinkUnavailable": "Linking Gosuslugi isn't available on this platform. Do this in the mobile app.", + "digitalIdGosuslugiLinkFailed": "Could not get the Gosuslugi link", + "digitalIdGosuslugiTitle": "Gosuslugi", + "digitalIdDocsUnavailable": "Documents aren't available yet. Try again later.", + "digitalIdTitle": "Digital ID", + "digitalIdNotConfiguredTitle": "Digital ID isn't set up", + "digitalIdLinkGosuslugiHint": "Link your Gosuslugi account so your documents appear in Digital ID. The phone number in MAX must match the one in your Gosuslugi profile.", + "digitalIdLinkOrRefreshHint": "Link Gosuslugi to get access to your documents, or refresh the page if you've already set up Digital ID.", + "digitalIdLoadDocuments": "Load documents", + "digitalIdLinkGosuslugiButton": "Link Gosuslugi", + "digitalIdGosuslugiProfileFallback": "Gosuslugi profile", + "digitalIdBirthDate": "Date of birth: {date}", + "@digitalIdBirthDate": { + "placeholders": { + "date": { + "type": "String" + } + } + }, + "digitalIdPersonalDataTitle": "Personal data", + "digitalIdSnilsLabel": "SNILS", + "digitalIdInnLabel": "INN", + "digitalIdBirthPlaceLabel": "Place of birth", + "digitalIdRegistrationAddressLabel": "Registration address", + "digitalIdDocumentsTitle": "Documents", + "digitalIdDocSeries": "series {series}", + "@digitalIdDocSeries": { + "placeholders": { + "series": { + "type": "String" + } + } + }, + "digitalIdDocNumber": "No. {number}", + "@digitalIdDocNumber": { + "placeholders": { + "number": { + "type": "String" + } + } + }, + "digitalIdPassesTitle": "Passes", + "digitalIdCardInn": "INN {inn}", + "@digitalIdCardInn": { + "placeholders": { + "inn": { + "type": "String" + } + } + }, + "digitalIdBiometryConfigured": "Biometrics set up on this device", + "digitalIdBiometryNotConfigured": "Biometrics not set up on this device", + "digitalIdDocPassport": "Russian passport", + "digitalIdDocOms": "Health insurance policy (OMS)", + "digitalIdDocDriverLicense": "Driver's license", + "digitalIdDocVehicleSts": "Vehicle registration certificate (STS)", + "digitalIdDocChildBirthCert": "Birth certificate", + "digitalIdDocPensionCert": "Pension certificate", + "digitalIdDocDisabledCert": "Disability certificate", + "digitalIdDocLargeFamilyCert": "Large family certificate", + "digitalIdDocStudentTicket": "Student ID", + "digitalIdDocChildInn": "Child's INN", + "digitalIdDocChildOms": "Child's health insurance policy (OMS)", + "attachSheetGallery": "Gallery", + "attachSheetPoll": "Poll", + "attachSheetCameraComingSoon": "Camera is coming soon", + "attachSheetSendFileTitle": "Send a file", + "attachSheetSendFileSubtitle": "A document, archive, or any other file", + "attachSheetChooseFileButton": "Choose file", + "attachSheetShareLocationTitle": "Share location", + "attachSheetShareLocationSubtitle": "Send your current location", + "attachSheetSendLocationButton": "Send location", + "attachSheetCreatePoll": "Create poll", + "attachSheetCreatePollSubtitle": "A question with answer options", + "attachSheetNoImagesFound": "No images found", + "attachSheetLimitedAccessInfo": "Not all photos are accessible", + "attachSheetSectionInProgress": "Section under development", + "attachSheetNoGalleryAccessTitle": "No access to the gallery", + "attachSheetNoGalleryAccessSubtitle": "Allow access to photos to pick them from here", + "attachSheetAllow": "Allow", + "attachSheetSettings": "Settings", + "attachSheetAddCaptionHint": "Add a caption...", + "attachSheetCamera": "Camera", + "photoEditorApplyFailed": "Couldn't apply", + "photoEditorFlipTooltip": "Flip", + "photoEditorRotateTooltip": "Rotate", + "photoEditorCancel": "CANCEL", + "photoEditorReset": "RESET", + "photoEditorDone": "DONE", + "photoEditorTextDialogTitle": "Text", + "photoEditorTextDialogHint": "Enter text", + "photoEditorOk": "OK", + "photoEditorApplyChangesFailed": "Couldn't apply changes", + "photoEditorClearAll": "Clear all", + "photoEditorAddText": "Add text", + "photoEditorTabDraw": "DRAW", + "photoEditorTabStickers": "STICKERS", + "photoEditorTabText": "TEXT", + "photoEditorChannelAll": "All", + "photoEditorChannelRed": "Red", + "photoEditorChannelGreen": "Green", + "photoEditorChannelBlue": "Blue", + "photoEditorEnhance": "Enhance", + "photoEditorExposure": "Exposure", + "photoEditorContrast": "Contrast", + "photoEditorSaturation": "Saturation", + "photoEditorWarmth": "Warmth", + "photoEditorVignette": "Vignette", + "photoEditorBlurOff": "Off", + "photoEditorBlurRadial": "Radial", + "photoEditorBlurLinear": "Linear", + "fontSettingsInvalidInput": "Enter a font link or name", + "fontSettingsFontNotFound": "Font \"{name}\" not found or no network", + "@fontSettingsFontNotFound": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "fontSettingsFontAdded": "Font \"{name}\" added", + "@fontSettingsFontAdded": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "fontSettingsFontRemoved": "Font \"{name}\" removed", + "@fontSettingsFontRemoved": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "fontSettingsAddFontTitle": "Add font", + "fontSettingsAddFontDescription": "Paste a Google Fonts link or font name", + "fontSettingsAddFontConfirm": "Add", + "fontSettingsTitle": "Fonts", + "fontSettingsSectionFont": "Font", + "fontSettingsLoading": "Loading…", + "fontSettingsSectionFontSize": "Font size", + "fontSettingsPreviewLabel": "PREVIEW", + "fontSettingsReset": "Reset" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index fe413a5..89e2f9c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1087,6 +1087,2706 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Choose an avatar'** String get registrationChooseAvatar; + + /// No description provided for @msgActionsCopy. + /// + /// In en, this message translates to: + /// **'Copy'** + String get msgActionsCopy; + + /// No description provided for @msgActionsEdit. + /// + /// In en, this message translates to: + /// **'Edit'** + String get msgActionsEdit; + + /// No description provided for @msgActionsReply. + /// + /// In en, this message translates to: + /// **'Reply'** + String get msgActionsReply; + + /// No description provided for @msgActionsForward. + /// + /// In en, this message translates to: + /// **'Forward'** + String get msgActionsForward; + + /// No description provided for @msgActionsMarkUnread. + /// + /// In en, this message translates to: + /// **'Mark as unread'** + String get msgActionsMarkUnread; + + /// No description provided for @msgActionsEditHistory. + /// + /// In en, this message translates to: + /// **'Edit history'** + String get msgActionsEditHistory; + + /// No description provided for @msgActionsReport. + /// + /// In en, this message translates to: + /// **'Report'** + String get msgActionsReport; + + /// No description provided for @msgActionsDelete. + /// + /// In en, this message translates to: + /// **'Delete'** + String get msgActionsDelete; + + /// No description provided for @msgActionsCopied. + /// + /// In en, this message translates to: + /// **'Copied'** + String get msgActionsCopied; + + /// No description provided for @msgActionsLoadReasonsFailed. + /// + /// In en, this message translates to: + /// **'Failed to load reasons'** + String get msgActionsLoadReasonsFailed; + + /// No description provided for @msgActionsCurrentVersion. + /// + /// In en, this message translates to: + /// **'current version'** + String get msgActionsCurrentVersion; + + /// No description provided for @msgActionsCurrentVersionWithDate. + /// + /// In en, this message translates to: + /// **'current version · {date}'** + String msgActionsCurrentVersionWithDate(String date); + + /// No description provided for @msgActionsNoText. + /// + /// In en, this message translates to: + /// **'(no text)'** + String get msgActionsNoText; + + /// No description provided for @notificationsSaveFailed. + /// + /// In en, this message translates to: + /// **'Could not save: {error}'** + String notificationsSaveFailed(String error); + + /// No description provided for @notificationsFkmAlreadyHasFcm. + /// + /// In en, this message translates to: + /// **'Why? You already have FCM.'** + String get notificationsFkmAlreadyHasFcm; + + /// No description provided for @notificationsFkmDownloadFcm. + /// + /// In en, this message translates to: + /// **'Better download the FCM version.'** + String get notificationsFkmDownloadFcm; + + /// No description provided for @notificationsTitle. + /// + /// In en, this message translates to: + /// **'Notifications'** + String get notificationsTitle; + + /// No description provided for @notificationsFkmSectionTitle. + /// + /// In en, this message translates to: + /// **'FKM'** + String get notificationsFkmSectionTitle; + + /// No description provided for @notificationsFkmEnableLabel. + /// + /// In en, this message translates to: + /// **'Enable notifications'** + String get notificationsFkmEnableLabel; + + /// No description provided for @notificationsFkmEnableSubtitle. + /// + /// In en, this message translates to: + /// **'For FKM notifications to work, the app will need to keep a notification in the shade.'** + String get notificationsFkmEnableSubtitle; + + /// No description provided for @notificationsMainSectionTitle. + /// + /// In en, this message translates to: + /// **'Notifications'** + String get notificationsMainSectionTitle; + + /// No description provided for @notificationsAllLabel. + /// + /// In en, this message translates to: + /// **'All notifications'** + String get notificationsAllLabel; + + /// No description provided for @notificationsNewSectionTitle. + /// + /// In en, this message translates to: + /// **'New notifications'** + String get notificationsNewSectionTitle; + + /// No description provided for @notificationsPreviewLabel. + /// + /// In en, this message translates to: + /// **'Message preview'** + String get notificationsPreviewLabel; + + /// No description provided for @notificationsSoundLabel. + /// + /// In en, this message translates to: + /// **'Sound'** + String get notificationsSoundLabel; + + /// No description provided for @notificationsAdditionalSectionTitle. + /// + /// In en, this message translates to: + /// **'Additional'** + String get notificationsAdditionalSectionTitle; + + /// No description provided for @notificationsCallsLabel. + /// + /// In en, this message translates to: + /// **'Call notifications'** + String get notificationsCallsLabel; + + /// No description provided for @notificationsNewContactsLabel. + /// + /// In en, this message translates to: + /// **'Notifications from new contacts'** + String get notificationsNewContactsLabel; + + /// No description provided for @notificationsHapticsSectionTitle. + /// + /// In en, this message translates to: + /// **'Haptic feedback'** + String get notificationsHapticsSectionTitle; + + /// No description provided for @notificationsHapticsLabel. + /// + /// In en, this message translates to: + /// **'Haptic feedback'** + String get notificationsHapticsLabel; + + /// No description provided for @notificationsHapticsSubtitle. + /// + /// In en, this message translates to: + /// **'Vibration feedback for actions in the app'** + String get notificationsHapticsSubtitle; + + /// No description provided for @devicesLoadFailed. + /// + /// In en, this message translates to: + /// **'Failed to load: {error}'** + String devicesLoadFailed(String error); + + /// No description provided for @devicesQrLinkDialogTitle. + /// + /// In en, this message translates to: + /// **'Link from QR'** + String get devicesQrLinkDialogTitle; + + /// No description provided for @devicesQrLinkDialogHint. + /// + /// In en, this message translates to: + /// **'Paste the QR code content'** + String get devicesQrLinkDialogHint; + + /// No description provided for @devicesAllTerminated. + /// + /// In en, this message translates to: + /// **'All sessions terminated'** + String get devicesAllTerminated; + + /// No description provided for @devicesGenericError. + /// + /// In en, this message translates to: + /// **'Error: {error}'** + String devicesGenericError(String error); + + /// No description provided for @devicesIpLookupError. + /// + /// In en, this message translates to: + /// **'IP error: {error}'** + String devicesIpLookupError(String error); + + /// No description provided for @devicesTitle. + /// + /// In en, this message translates to: + /// **'Devices'** + String get devicesTitle; + + /// No description provided for @devicesPromoTitle. + /// + /// In en, this message translates to: + /// **'Devices in KOMET'** + String get devicesPromoTitle; + + /// No description provided for @devicesPromoSubtitle. + /// + /// In en, this message translates to: + /// **'Who has access to your account?'** + String get devicesPromoSubtitle; + + /// No description provided for @devicesScanQrButton. + /// + /// In en, this message translates to: + /// **'Scan QR'** + String get devicesScanQrButton; + + /// No description provided for @devicesCurrentSuffix. + /// + /// In en, this message translates to: + /// **' (current)'** + String get devicesCurrentSuffix; + + /// No description provided for @devicesOnlineStatus. + /// + /// In en, this message translates to: + /// **'Online'** + String get devicesOnlineStatus; + + /// No description provided for @devicesTerminateOthersButton. + /// + /// In en, this message translates to: + /// **'Terminate all sessions except the current one'** + String get devicesTerminateOthersButton; + + /// No description provided for @devicesMobileNetworkLabel. + /// + /// In en, this message translates to: + /// **'Mobile network'** + String get devicesMobileNetworkLabel; + + /// No description provided for @devicesProxyDetectedLabel. + /// + /// In en, this message translates to: + /// **'Proxy/VPN detected'** + String get devicesProxyDetectedLabel; + + /// No description provided for @themeSettingsTitle. + /// + /// In en, this message translates to: + /// **'Theme'** + String get themeSettingsTitle; + + /// No description provided for @themeSettingsModeCardTitle. + /// + /// In en, this message translates to: + /// **'Theme mode'** + String get themeSettingsModeCardTitle; + + /// No description provided for @themeSettingsModeCardSubtitle. + /// + /// In en, this message translates to: + /// **'Light, dark, or automatic switching'** + String get themeSettingsModeCardSubtitle; + + /// No description provided for @themeSettingsModeSystem. + /// + /// In en, this message translates to: + /// **'System'** + String get themeSettingsModeSystem; + + /// No description provided for @themeSettingsModeLight. + /// + /// In en, this message translates to: + /// **'Light'** + String get themeSettingsModeLight; + + /// No description provided for @themeSettingsModeDark. + /// + /// In en, this message translates to: + /// **'Dark'** + String get themeSettingsModeDark; + + /// No description provided for @themeSettingsModeSchedule. + /// + /// In en, this message translates to: + /// **'Scheduled'** + String get themeSettingsModeSchedule; + + /// No description provided for @themeSettingsAmoledTitle. + /// + /// In en, this message translates to: + /// **'AMOLED black'** + String get themeSettingsAmoledTitle; + + /// No description provided for @themeSettingsAmoledSubtitle. + /// + /// In en, this message translates to: + /// **'Pure black background for OLED screens'** + String get themeSettingsAmoledSubtitle; + + /// No description provided for @themeSettingsScheduleTitle. + /// + /// In en, this message translates to: + /// **'Schedule'** + String get themeSettingsScheduleTitle; + + /// No description provided for @themeSettingsScheduleSubtitleEnabled. + /// + /// In en, this message translates to: + /// **'When dark theme turns on automatically'** + String get themeSettingsScheduleSubtitleEnabled; + + /// No description provided for @themeSettingsScheduleSubtitleDisabled. + /// + /// In en, this message translates to: + /// **'Available in \"Scheduled\" mode'** + String get themeSettingsScheduleSubtitleDisabled; + + /// No description provided for @themeSettingsScheduleDarkFrom. + /// + /// In en, this message translates to: + /// **'Dark from'** + String get themeSettingsScheduleDarkFrom; + + /// No description provided for @themeSettingsScheduleLightFrom. + /// + /// In en, this message translates to: + /// **'Light from'** + String get themeSettingsScheduleLightFrom; + + /// No description provided for @appearanceTitle. + /// + /// In en, this message translates to: + /// **'Appearance'** + String get appearanceTitle; + + /// No description provided for @appearanceVisualStyleTitle. + /// + /// In en, this message translates to: + /// **'Visual style'** + String get appearanceVisualStyleTitle; + + /// No description provided for @appearanceVisualStyleSubtitle. + /// + /// In en, this message translates to: + /// **'Material You or dimensional Glossy capsules'** + String get appearanceVisualStyleSubtitle; + + /// No description provided for @appearanceVisualStyleMaterialYou. + /// + /// In en, this message translates to: + /// **'Material You'** + String get appearanceVisualStyleMaterialYou; + + /// No description provided for @appearanceVisualStyleGlossy. + /// + /// In en, this message translates to: + /// **'Glossy'** + String get appearanceVisualStyleGlossy; + + /// No description provided for @appearanceChatChromeTitle. + /// + /// In en, this message translates to: + /// **'Chat screen elements'** + String get appearanceChatChromeTitle; + + /// No description provided for @appearanceChatChromeSubtitle. + /// + /// In en, this message translates to: + /// **'Background of the top and bottom panels: color, blur, or transparent. With blur or transparency, messages scroll under the panels'** + String get appearanceChatChromeSubtitle; + + /// No description provided for @appearanceChatChromeColor. + /// + /// In en, this message translates to: + /// **'Color'** + String get appearanceChatChromeColor; + + /// No description provided for @appearanceChatChromeBlur. + /// + /// In en, this message translates to: + /// **'Blur'** + String get appearanceChatChromeBlur; + + /// No description provided for @appearanceChatChromeNone. + /// + /// In en, this message translates to: + /// **'None'** + String get appearanceChatChromeNone; + + /// No description provided for @appearanceGradientTitle. + /// + /// In en, this message translates to: + /// **'Gradient'** + String get appearanceGradientTitle; + + /// No description provided for @appearanceGradientSubtitle. + /// + /// In en, this message translates to: + /// **'Depth and highlights in Glossy capsules'** + String get appearanceGradientSubtitle; + + /// No description provided for @appearanceAccentColorTitle. + /// + /// In en, this message translates to: + /// **'Accent color'** + String get appearanceAccentColorTitle; + + /// No description provided for @appearanceAccentColorSystem. + /// + /// In en, this message translates to: + /// **'System'** + String get appearanceAccentColorSystem; + + /// No description provided for @appearanceAccentColorSubtitle. + /// + /// In en, this message translates to: + /// **'Main color of the interface and bubbles'** + String get appearanceAccentColorSubtitle; + + /// No description provided for @appearanceAccentColorSystemActive. + /// + /// In en, this message translates to: + /// **'System color is active'** + String get appearanceAccentColorSystemActive; + + /// No description provided for @appearanceAccentColorReset. + /// + /// In en, this message translates to: + /// **'Reset to system'** + String get appearanceAccentColorReset; + + /// No description provided for @appearanceBubbleShapeTitle. + /// + /// In en, this message translates to: + /// **'Message shape'** + String get appearanceBubbleShapeTitle; + + /// No description provided for @appearanceBubbleShapeSubtitle. + /// + /// In en, this message translates to: + /// **'Bubble corner rounding'** + String get appearanceBubbleShapeSubtitle; + + /// No description provided for @appearanceBubbleShapeMobile. + /// + /// In en, this message translates to: + /// **'TG Mobile'** + String get appearanceBubbleShapeMobile; + + /// No description provided for @appearanceBubbleShapeDesktop. + /// + /// In en, this message translates to: + /// **'TG Desktop'** + String get appearanceBubbleShapeDesktop; + + /// No description provided for @appearanceBubbleBehaviorTitle. + /// + /// In en, this message translates to: + /// **'Message behavior'** + String get appearanceBubbleBehaviorTitle; + + /// No description provided for @appearanceBubbleBehaviorSubtitle. + /// + /// In en, this message translates to: + /// **'Whether bubble shape changes based on neighbors in a group'** + String get appearanceBubbleBehaviorSubtitle; + + /// No description provided for @appearanceBubbleBehaviorMutable. + /// + /// In en, this message translates to: + /// **'Mutable'** + String get appearanceBubbleBehaviorMutable; + + /// No description provided for @appearanceBubbleBehaviorImmutable. + /// + /// In en, this message translates to: + /// **'Immutable'** + String get appearanceBubbleBehaviorImmutable; + + /// No description provided for @appearancePreviewHello. + /// + /// In en, this message translates to: + /// **'Hi!'** + String get appearancePreviewHello; + + /// No description provided for @appearancePreviewHowIsIt. + /// + /// In en, this message translates to: + /// **'How do you like it?'** + String get appearancePreviewHowIsIt; + + /// No description provided for @appearancePreviewHmm. + /// + /// In en, this message translates to: + /// **'hmm...'** + String get appearancePreviewHmm; + + /// No description provided for @appearancePreviewNotBad. + /// + /// In en, this message translates to: + /// **'Not bad at all!'** + String get appearancePreviewNotBad; + + /// No description provided for @callKometDetectedNotification. + /// + /// In en, this message translates to: + /// **'This person uses Komet! :3'** + String get callKometDetectedNotification; + + /// No description provided for @callStatusConnecting. + /// + /// In en, this message translates to: + /// **'Connecting'** + String get callStatusConnecting; + + /// No description provided for @callGroupConnecting. + /// + /// In en, this message translates to: + /// **'Connecting…'** + String get callGroupConnecting; + + /// No description provided for @callGroupWaitingParticipants. + /// + /// In en, this message translates to: + /// **'Waiting for participants…'** + String get callGroupWaitingParticipants; + + /// No description provided for @callParticipantYou. + /// + /// In en, this message translates to: + /// **'You'** + String get callParticipantYou; + + /// No description provided for @callParticipantFallback. + /// + /// In en, this message translates to: + /// **'Participant'** + String get callParticipantFallback; + + /// No description provided for @callTooltipMinimize. + /// + /// In en, this message translates to: + /// **'Minimize'** + String get callTooltipMinimize; + + /// No description provided for @callTooltipKometHub. + /// + /// In en, this message translates to: + /// **'Komet'** + String get callTooltipKometHub; + + /// No description provided for @callInfoTitle. + /// + /// In en, this message translates to: + /// **'About call'** + String get callInfoTitle; + + /// No description provided for @callPeerMicOff. + /// + /// In en, this message translates to: + /// **'Microphone off'** + String get callPeerMicOff; + + /// No description provided for @callPeerCameraOn. + /// + /// In en, this message translates to: + /// **'Camera on'** + String get callPeerCameraOn; + + /// No description provided for @callUnknownName. + /// + /// In en, this message translates to: + /// **'Unknown'** + String get callUnknownName; + + /// No description provided for @callIncoming. + /// + /// In en, this message translates to: + /// **'Incoming call'** + String get callIncoming; + + /// No description provided for @callStatusRinging. + /// + /// In en, this message translates to: + /// **'Calling'** + String get callStatusRinging; + + /// No description provided for @callStatusEnded. + /// + /// In en, this message translates to: + /// **'Call ended'** + String get callStatusEnded; + + /// No description provided for @callDecline. + /// + /// In en, this message translates to: + /// **'Decline'** + String get callDecline; + + /// No description provided for @callAccept. + /// + /// In en, this message translates to: + /// **'Accept'** + String get callAccept; + + /// No description provided for @callSpeaker. + /// + /// In en, this message translates to: + /// **'Speaker'** + String get callSpeaker; + + /// No description provided for @callVideoLabel. + /// + /// In en, this message translates to: + /// **'Video'** + String get callVideoLabel; + + /// No description provided for @callScreenLabel. + /// + /// In en, this message translates to: + /// **'Screen'** + String get callScreenLabel; + + /// No description provided for @callUnmute. + /// + /// In en, this message translates to: + /// **'Unmute'** + String get callUnmute; + + /// No description provided for @callMute. + /// + /// In en, this message translates to: + /// **'Mute'** + String get callMute; + + /// No description provided for @callEndButton. + /// + /// In en, this message translates to: + /// **'End'** + String get callEndButton; + + /// No description provided for @callInfoClient. + /// + /// In en, this message translates to: + /// **'Client'** + String get callInfoClient; + + /// No description provided for @callInfoPlatform. + /// + /// In en, this message translates to: + /// **'Platform'** + String get callInfoPlatform; + + /// No description provided for @callInfoCountry. + /// + /// In en, this message translates to: + /// **'Country'** + String get callInfoCountry; + + /// No description provided for @callInfoInContacts. + /// + /// In en, this message translates to: + /// **'In contacts'** + String get callInfoInContacts; + + /// No description provided for @callValueYes. + /// + /// In en, this message translates to: + /// **'yes'** + String get callValueYes; + + /// No description provided for @callValueNo. + /// + /// In en, this message translates to: + /// **'no'** + String get callValueNo; + + /// No description provided for @callInfoPeerIp. + /// + /// In en, this message translates to: + /// **'Peer IP'** + String get callInfoPeerIp; + + /// No description provided for @callInfoPeerNetwork. + /// + /// In en, this message translates to: + /// **'Peer network'** + String get callInfoPeerNetwork; + + /// No description provided for @callInfoPath. + /// + /// In en, this message translates to: + /// **'Connection path'** + String get callInfoPath; + + /// No description provided for @callInfoCodec. + /// + /// In en, this message translates to: + /// **'Codec'** + String get callInfoCodec; + + /// No description provided for @callInfoServer. + /// + /// In en, this message translates to: + /// **'Server'** + String get callInfoServer; + + /// No description provided for @callInfoTopology. + /// + /// In en, this message translates to: + /// **'Topology'** + String get callInfoTopology; + + /// No description provided for @callInfoStatus. + /// + /// In en, this message translates to: + /// **'Status'** + String get callInfoStatus; + + /// No description provided for @callStatusValueConnected. + /// + /// In en, this message translates to: + /// **'connected'** + String get callStatusValueConnected; + + /// No description provided for @callStatusValueConnecting. + /// + /// In en, this message translates to: + /// **'connecting…'** + String get callStatusValueConnecting; + + /// No description provided for @callInfoPeerMic. + /// + /// In en, this message translates to: + /// **'Peer microphone'** + String get callInfoPeerMic; + + /// No description provided for @callMicValueOn. + /// + /// In en, this message translates to: + /// **'on'** + String get callMicValueOn; + + /// No description provided for @callMicValueOff. + /// + /// In en, this message translates to: + /// **'off'** + String get callMicValueOff; + + /// No description provided for @callInfoPeerCamera. + /// + /// In en, this message translates to: + /// **'Peer camera'** + String get callInfoPeerCamera; + + /// No description provided for @callCameraValueOn. + /// + /// In en, this message translates to: + /// **'on'** + String get callCameraValueOn; + + /// No description provided for @callCameraValueOff. + /// + /// In en, this message translates to: + /// **'off'** + String get callCameraValueOff; + + /// No description provided for @callInfoVideoTrack. + /// + /// In en, this message translates to: + /// **'Video track'** + String get callInfoVideoTrack; + + /// No description provided for @callInfoVideoTrackPresent. + /// + /// In en, this message translates to: + /// **'yes ({count})'** + String callInfoVideoTrackPresent(int count); + + /// No description provided for @callInfoVideoSize. + /// + /// In en, this message translates to: + /// **'Video size'** + String get callInfoVideoSize; + + /// No description provided for @callInfoFrameRendering. + /// + /// In en, this message translates to: + /// **'Frame rendering'** + String get callInfoFrameRendering; + + /// No description provided for @callBadgeEncrypted. + /// + /// In en, this message translates to: + /// **'Encrypted'** + String get callBadgeEncrypted; + + /// No description provided for @callBadgeAudio. + /// + /// In en, this message translates to: + /// **'Audio'** + String get callBadgeAudio; + + /// No description provided for @callBadgeRecording. + /// + /// In en, this message translates to: + /// **'Recording'** + String get callBadgeRecording; + + /// No description provided for @callBadgeNoiseSuppression. + /// + /// In en, this message translates to: + /// **'Noise suppression'** + String get callBadgeNoiseSuppression; + + /// No description provided for @callBadgeAnimoji. + /// + /// In en, this message translates to: + /// **'Animoji'** + String get callBadgeAnimoji; + + /// No description provided for @callInfoNoDataYet. + /// + /// In en, this message translates to: + /// **'Data will appear after connecting…'** + String get callInfoNoDataYet; + + /// No description provided for @hubTitleMenu. + /// + /// In en, this message translates to: + /// **'Komet'** + String get hubTitleMenu; + + /// No description provided for @hubChatPageTitle. + /// + /// In en, this message translates to: + /// **'Anonymous chat'** + String get hubChatPageTitle; + + /// No description provided for @hubGamesTitle. + /// + /// In en, this message translates to: + /// **'Games'** + String get hubGamesTitle; + + /// No description provided for @hubCheckersTitle. + /// + /// In en, this message translates to: + /// **'Checkers'** + String get hubCheckersTitle; + + /// No description provided for @hubChatTileTitle. + /// + /// In en, this message translates to: + /// **'Chat'** + String get hubChatTileTitle; + + /// No description provided for @hubChatTileSubtitle. + /// + /// In en, this message translates to: + /// **'Anonymous messages'** + String get hubChatTileSubtitle; + + /// No description provided for @hubGamesTileSubtitle. + /// + /// In en, this message translates to: + /// **'Play with your partner'** + String get hubGamesTileSubtitle; + + /// No description provided for @hubCheckersTileSubtitle. + /// + /// In en, this message translates to: + /// **'Russian checkers'** + String get hubCheckersTileSubtitle; + + /// No description provided for @hubMoreSoonTitle. + /// + /// In en, this message translates to: + /// **'More coming soon…'** + String get hubMoreSoonTitle; + + /// No description provided for @hubMoreSoonSubtitle. + /// + /// In en, this message translates to: + /// **'In development'** + String get hubMoreSoonSubtitle; + + /// No description provided for @hubChatPrivacyNote. + /// + /// In en, this message translates to: + /// **'Sent directly through the call, stored nowhere'** + String get hubChatPrivacyNote; + + /// No description provided for @hubChatEmpty. + /// + /// In en, this message translates to: + /// **'No messages yet'** + String get hubChatEmpty; + + /// No description provided for @hubChatInputHint. + /// + /// In en, this message translates to: + /// **'Message…'** + String get hubChatInputHint; + + /// No description provided for @hubCheckersRestart. + /// + /// In en, this message translates to: + /// **'Restart'** + String get hubCheckersRestart; + + /// No description provided for @hubCheckersYouWhite. + /// + /// In en, this message translates to: + /// **'You\'re playing white'** + String get hubCheckersYouWhite; + + /// No description provided for @hubCheckersYouBlack. + /// + /// In en, this message translates to: + /// **'You\'re playing black'** + String get hubCheckersYouBlack; + + /// No description provided for @hubCheckersWon. + /// + /// In en, this message translates to: + /// **'You won 🎉'** + String get hubCheckersWon; + + /// No description provided for @hubCheckersLost. + /// + /// In en, this message translates to: + /// **'You lost'** + String get hubCheckersLost; + + /// No description provided for @hubCheckersYourMove. + /// + /// In en, this message translates to: + /// **'Your move'** + String get hubCheckersYourMove; + + /// No description provided for @hubCheckersOpponentMove. + /// + /// In en, this message translates to: + /// **'Opponent\'s move…'** + String get hubCheckersOpponentMove; + + /// No description provided for @scheduledPickTimeTitle. + /// + /// In en, this message translates to: + /// **'When to send'** + String get scheduledPickTimeTitle; + + /// No description provided for @scheduledEditTitle. + /// + /// In en, this message translates to: + /// **'Edit'** + String get scheduledEditTitle; + + /// No description provided for @scheduledMessageTextHint. + /// + /// In en, this message translates to: + /// **'Message text'** + String get scheduledMessageTextHint; + + /// No description provided for @scheduledSave. + /// + /// In en, this message translates to: + /// **'Save'** + String get scheduledSave; + + /// No description provided for @scheduledEditFailed. + /// + /// In en, this message translates to: + /// **'Failed to edit message'** + String get scheduledEditFailed; + + /// No description provided for @scheduledDeleteConfirmTitle. + /// + /// In en, this message translates to: + /// **'Delete scheduled message?'** + String get scheduledDeleteConfirmTitle; + + /// No description provided for @scheduledDeleteConfirmMessage. + /// + /// In en, this message translates to: + /// **'The message won\'t be sent.'** + String get scheduledDeleteConfirmMessage; + + /// No description provided for @scheduledDeleteConfirmLabel. + /// + /// In en, this message translates to: + /// **'Delete'** + String get scheduledDeleteConfirmLabel; + + /// No description provided for @scheduledDeleteFailed. + /// + /// In en, this message translates to: + /// **'Failed to delete message'** + String get scheduledDeleteFailed; + + /// No description provided for @scheduledAppBarTitle. + /// + /// In en, this message translates to: + /// **'Scheduled'** + String get scheduledAppBarTitle; + + /// No description provided for @scheduledEmpty. + /// + /// In en, this message translates to: + /// **'No scheduled messages'** + String get scheduledEmpty; + + /// No description provided for @scheduledAttachPhoto. + /// + /// In en, this message translates to: + /// **'Photo'** + String get scheduledAttachPhoto; + + /// No description provided for @scheduledAttachVideo. + /// + /// In en, this message translates to: + /// **'Video'** + String get scheduledAttachVideo; + + /// No description provided for @scheduledAttachVoice. + /// + /// In en, this message translates to: + /// **'Voice message'** + String get scheduledAttachVoice; + + /// No description provided for @scheduledAttachFile. + /// + /// In en, this message translates to: + /// **'File'** + String get scheduledAttachFile; + + /// No description provided for @scheduledAttachLocation. + /// + /// In en, this message translates to: + /// **'Location'** + String get scheduledAttachLocation; + + /// No description provided for @scheduledAttachForwarded. + /// + /// In en, this message translates to: + /// **'Forwarded'** + String get scheduledAttachForwarded; + + /// No description provided for @scheduledAttachGeneric. + /// + /// In en, this message translates to: + /// **'Attachment'** + String get scheduledAttachGeneric; + + /// No description provided for @contactProfileLoadError. + /// + /// In en, this message translates to: + /// **'Error: {error}'** + String contactProfileLoadError(String error); + + /// No description provided for @contactProfileBot. + /// + /// In en, this message translates to: + /// **'Bot'** + String get contactProfileBot; + + /// No description provided for @contactProfileOnline. + /// + /// In en, this message translates to: + /// **'Online'** + String get contactProfileOnline; + + /// No description provided for @contactProfileRecentlyActive. + /// + /// In en, this message translates to: + /// **'Recently active'** + String get contactProfileRecentlyActive; + + /// No description provided for @contactProfileActionChat. + /// + /// In en, this message translates to: + /// **'Chat'** + String get contactProfileActionChat; + + /// No description provided for @contactProfileActionSound. + /// + /// In en, this message translates to: + /// **'Sound'** + String get contactProfileActionSound; + + /// No description provided for @contactProfileActionCall. + /// + /// In en, this message translates to: + /// **'Call'** + String get contactProfileActionCall; + + /// No description provided for @contactProfileInfoPhone. + /// + /// In en, this message translates to: + /// **'Phone'** + String get contactProfileInfoPhone; + + /// No description provided for @contactProfileInfoCountry. + /// + /// In en, this message translates to: + /// **'Country'** + String get contactProfileInfoCountry; + + /// No description provided for @contactProfileInfoGender. + /// + /// In en, this message translates to: + /// **'Gender'** + String get contactProfileInfoGender; + + /// No description provided for @contactProfileInfoRegistration. + /// + /// In en, this message translates to: + /// **'Registration'** + String get contactProfileInfoRegistration; + + /// No description provided for @contactProfileInfoUpdated. + /// + /// In en, this message translates to: + /// **'Updated'** + String get contactProfileInfoUpdated; + + /// No description provided for @contactProfileInfoAccountStatus. + /// + /// In en, this message translates to: + /// **'Account status'** + String get contactProfileInfoAccountStatus; + + /// No description provided for @contactProfileInfoDescription. + /// + /// In en, this message translates to: + /// **'Description'** + String get contactProfileInfoDescription; + + /// No description provided for @contactProfileInfoLink. + /// + /// In en, this message translates to: + /// **'Link'** + String get contactProfileInfoLink; + + /// No description provided for @contactProfileInfoFlags. + /// + /// In en, this message translates to: + /// **'Flags'** + String get contactProfileInfoFlags; + + /// No description provided for @nfcPeerNameFallback. + /// + /// In en, this message translates to: + /// **'Contact #{id}'** + String nfcPeerNameFallback(String id); + + /// No description provided for @nfcPeerFirstNameFallback. + /// + /// In en, this message translates to: + /// **'Contact'** + String get nfcPeerFirstNameFallback; + + /// No description provided for @nfcContactAdded. + /// + /// In en, this message translates to: + /// **'Contact added'** + String get nfcContactAdded; + + /// No description provided for @nfcAddFailed. + /// + /// In en, this message translates to: + /// **'Failed to add: {error}'** + String nfcAddFailed(String error); + + /// No description provided for @nfcReasonBluetoothOff. + /// + /// In en, this message translates to: + /// **'Turn on Bluetooth and try again'** + String get nfcReasonBluetoothOff; + + /// No description provided for @nfcReasonPermission. + /// + /// In en, this message translates to: + /// **'Bluetooth permissions are needed for exchange'** + String get nfcReasonPermission; + + /// No description provided for @nfcReasonDefault. + /// + /// In en, this message translates to: + /// **'Failed to establish connection'** + String get nfcReasonDefault; + + /// No description provided for @nfcSheetTitle. + /// + /// In en, this message translates to: + /// **'Contact exchange'** + String get nfcSheetTitle; + + /// No description provided for @nfcUnsupported. + /// + /// In en, this message translates to: + /// **'NFC is not available on this device'** + String get nfcUnsupported; + + /// No description provided for @nfcDisabled. + /// + /// In en, this message translates to: + /// **'Turn on NFC in phone settings and try again'** + String get nfcDisabled; + + /// No description provided for @nfcScanningTitle. + /// + /// In en, this message translates to: + /// **'Hold the phones close together'** + String get nfcScanningTitle; + + /// No description provided for @nfcScanningSubtitle. + /// + /// In en, this message translates to: + /// **'Both devices must keep this screen open'** + String get nfcScanningSubtitle; + + /// No description provided for @nfcExchangingTitle. + /// + /// In en, this message translates to: + /// **'Exchanging contacts…'** + String get nfcExchangingTitle; + + /// No description provided for @nfcExchangingSubtitle. + /// + /// In en, this message translates to: + /// **'Almost done'** + String get nfcExchangingSubtitle; + + /// No description provided for @nfcPeerIdFallback. + /// + /// In en, this message translates to: + /// **'ID {id}'** + String nfcPeerIdFallback(String id); + + /// No description provided for @nfcAdded. + /// + /// In en, this message translates to: + /// **'Added'** + String get nfcAdded; + + /// No description provided for @nfcAddContact. + /// + /// In en, this message translates to: + /// **'Add contact'** + String get nfcAddContact; + + /// No description provided for @chatInfoTabGeneralChats. + /// + /// In en, this message translates to: + /// **'Common chats'** + String get chatInfoTabGeneralChats; + + /// No description provided for @chatInfoTabMedia. + /// + /// In en, this message translates to: + /// **'Media'** + String get chatInfoTabMedia; + + /// No description provided for @chatInfoTabFiles. + /// + /// In en, this message translates to: + /// **'Files'** + String get chatInfoTabFiles; + + /// No description provided for @chatInfoTabVoice. + /// + /// In en, this message translates to: + /// **'Voice messages'** + String get chatInfoTabVoice; + + /// No description provided for @chatInfoTabLinks. + /// + /// In en, this message translates to: + /// **'Links'** + String get chatInfoTabLinks; + + /// No description provided for @chatInfoTabMembers. + /// + /// In en, this message translates to: + /// **'Members'** + String get chatInfoTabMembers; + + /// No description provided for @chatInfoEmptyGeneralChats. + /// + /// In en, this message translates to: + /// **'No common chats'** + String get chatInfoEmptyGeneralChats; + + /// No description provided for @chatInfoEmptyMedia. + /// + /// In en, this message translates to: + /// **'No media'** + String get chatInfoEmptyMedia; + + /// No description provided for @chatInfoEmptyFiles. + /// + /// In en, this message translates to: + /// **'No files'** + String get chatInfoEmptyFiles; + + /// No description provided for @chatInfoEmptyVoice. + /// + /// In en, this message translates to: + /// **'No voice messages'** + String get chatInfoEmptyVoice; + + /// No description provided for @chatInfoEmptyLinks. + /// + /// In en, this message translates to: + /// **'No links'** + String get chatInfoEmptyLinks; + + /// No description provided for @chatInfoOnlineOfTotal. + /// + /// In en, this message translates to: + /// **'{online} of {total} online'** + String chatInfoOnlineOfTotal(String online, String total); + + /// No description provided for @chatInfoActionLeave. + /// + /// In en, this message translates to: + /// **'Leave'** + String get chatInfoActionLeave; + + /// No description provided for @chatInfoBio. + /// + /// In en, this message translates to: + /// **'About'** + String get chatInfoBio; + + /// No description provided for @chatInfoInviteLink. + /// + /// In en, this message translates to: + /// **'Invite link'** + String get chatInfoInviteLink; + + /// No description provided for @chatInfoCollapse. + /// + /// In en, this message translates to: + /// **'Collapse'** + String get chatInfoCollapse; + + /// No description provided for @chatInfoShowMore. + /// + /// In en, this message translates to: + /// **'More'** + String get chatInfoShowMore; + + /// No description provided for @chatInfoAddMember. + /// + /// In en, this message translates to: + /// **'Add member'** + String get chatInfoAddMember; + + /// No description provided for @chatInfoRoleOwner. + /// + /// In en, this message translates to: + /// **'owner'** + String get chatInfoRoleOwner; + + /// No description provided for @chatInfoRoleAdmin. + /// + /// In en, this message translates to: + /// **'Admin'** + String get chatInfoRoleAdmin; + + /// No description provided for @chatInfoNoData. + /// + /// In en, this message translates to: + /// **'No data'** + String get chatInfoNoData; + + /// No description provided for @chatInfoHideExtra. + /// + /// In en, this message translates to: + /// **'Hide'** + String get chatInfoHideExtra; + + /// No description provided for @chatInfoShowMoreExtra. + /// + /// In en, this message translates to: + /// **'Details'** + String get chatInfoShowMoreExtra; + + /// No description provided for @chatInfoRowId. + /// + /// In en, this message translates to: + /// **'Chat ID'** + String get chatInfoRowId; + + /// No description provided for @chatInfoRowCreated. + /// + /// In en, this message translates to: + /// **'Created'** + String get chatInfoRowCreated; + + /// No description provided for @chatInfoRowModified. + /// + /// In en, this message translates to: + /// **'Modified'** + String get chatInfoRowModified; + + /// No description provided for @chatInfoRowMembersCount. + /// + /// In en, this message translates to: + /// **'Members'** + String get chatInfoRowMembersCount; + + /// No description provided for @chatInfoRowOwner. + /// + /// In en, this message translates to: + /// **'Owner'** + String get chatInfoRowOwner; + + /// No description provided for @chatInfoRowCreatedGroup. + /// + /// In en, this message translates to: + /// **'Created'** + String get chatInfoRowCreatedGroup; + + /// No description provided for @chatInfoRowJoined. + /// + /// In en, this message translates to: + /// **'Joined'** + String get chatInfoRowJoined; + + /// No description provided for @chatInfoRowModifiedGroup. + /// + /// In en, this message translates to: + /// **'Modified'** + String get chatInfoRowModifiedGroup; + + /// No description provided for @chatInfoRowHasBots. + /// + /// In en, this message translates to: + /// **'Has bots'** + String get chatInfoRowHasBots; + + /// No description provided for @chatInfoRowBlockedCount. + /// + /// In en, this message translates to: + /// **'Blocked'** + String get chatInfoRowBlockedCount; + + /// No description provided for @chatInfoRowOfficialGroup. + /// + /// In en, this message translates to: + /// **'Official'** + String get chatInfoRowOfficialGroup; + + /// No description provided for @chatInfoRowSignAdmin. + /// + /// In en, this message translates to: + /// **'Admin signature'** + String get chatInfoRowSignAdmin; + + /// No description provided for @chatInfoRowSubscribersCount. + /// + /// In en, this message translates to: + /// **'Subscribers'** + String get chatInfoRowSubscribersCount; + + /// No description provided for @chatInfoRowOfficialChannel. + /// + /// In en, this message translates to: + /// **'Official'** + String get chatInfoRowOfficialChannel; + + /// No description provided for @chatInfoRowComments. + /// + /// In en, this message translates to: + /// **'Comments'** + String get chatInfoRowComments; + + /// No description provided for @chatInfoRowRkn. + /// + /// In en, this message translates to: + /// **'Roskomnadzor approved'** + String get chatInfoRowRkn; + + /// No description provided for @chatInfoRowOnlyAdmin. + /// + /// In en, this message translates to: + /// **'Admins only'** + String get chatInfoRowOnlyAdmin; + + /// No description provided for @securityTitle. + /// + /// In en, this message translates to: + /// **'Security'** + String get securityTitle; + + /// No description provided for @securityLoadError. + /// + /// In en, this message translates to: + /// **'Loading error: {error}'** + String securityLoadError(String error); + + /// No description provided for @securitySaveError. + /// + /// In en, this message translates to: + /// **'Save error: {error}'** + String securitySaveError(String error); + + /// No description provided for @securityPrivacyAll. + /// + /// In en, this message translates to: + /// **'Everyone'** + String get securityPrivacyAll; + + /// No description provided for @securityPrivacyContacts. + /// + /// In en, this message translates to: + /// **'My contacts'** + String get securityPrivacyContacts; + + /// No description provided for @securityPrivacyNobody. + /// + /// In en, this message translates to: + /// **'Nobody'** + String get securityPrivacyNobody; + + /// No description provided for @securityFamilyProtection. + /// + /// In en, this message translates to: + /// **'Family protection'** + String get securityFamilyProtection; + + /// No description provided for @securityEnabledFem. + /// + /// In en, this message translates to: + /// **'Enabled'** + String get securityEnabledFem; + + /// No description provided for @securityDisabledFem. + /// + /// In en, this message translates to: + /// **'Disabled'** + String get securityDisabledFem; + + /// No description provided for @securityPasswordTitle. + /// + /// In en, this message translates to: + /// **'Login password'** + String get securityPasswordTitle; + + /// No description provided for @securityEnabledMasc. + /// + /// In en, this message translates to: + /// **'Enabled'** + String get securityEnabledMasc; + + /// No description provided for @securityDisabledMasc. + /// + /// In en, this message translates to: + /// **'Disabled'** + String get securityDisabledMasc; + + /// No description provided for @securityModeTitle. + /// + /// In en, this message translates to: + /// **'Safe mode'** + String get securityModeTitle; + + /// No description provided for @securityModeSubtitle. + /// + /// In en, this message translates to: + /// **'Hides personal information'** + String get securityModeSubtitle; + + /// No description provided for @securitySettingsUnavailable. + /// + /// In en, this message translates to: + /// **'Changing this setting is not available yet'** + String get securitySettingsUnavailable; + + /// No description provided for @securityFindByPhone. + /// + /// In en, this message translates to: + /// **'Find me by phone number'** + String get securityFindByPhone; + + /// No description provided for @securityWhoCanCall. + /// + /// In en, this message translates to: + /// **'Who can call me'** + String get securityWhoCanCall; + + /// No description provided for @securityWhoCanInvite. + /// + /// In en, this message translates to: + /// **'Who can invite me to chats'** + String get securityWhoCanInvite; + + /// No description provided for @securityShowContact. + /// + /// In en, this message translates to: + /// **'Show contact'** + String get securityShowContact; + + /// No description provided for @securityContentSafe. + /// + /// In en, this message translates to: + /// **'Safe'** + String get securityContentSafe; + + /// No description provided for @securityContentAll. + /// + /// In en, this message translates to: + /// **'All'** + String get securityContentAll; + + /// No description provided for @securityShowOnlineStatus. + /// + /// In en, this message translates to: + /// **'See online status'** + String get securityShowOnlineStatus; + + /// No description provided for @securityShowMyNumber. + /// + /// In en, this message translates to: + /// **'See my number'** + String get securityShowMyNumber; + + /// No description provided for @securityConfirmTitle. + /// + /// In en, this message translates to: + /// **'Are you sure?'** + String get securityConfirmTitle; + + /// No description provided for @securityHiddenStatusWarning. + /// + /// In en, this message translates to: + /// **'You won\'t be able to see the online status of other users.'** + String get securityHiddenStatusWarning; + + /// No description provided for @securityConfidentialityHeader. + /// + /// In en, this message translates to: + /// **'PRIVACY'** + String get securityConfidentialityHeader; + + /// No description provided for @securityReadReceipts. + /// + /// In en, this message translates to: + /// **'Read receipts'** + String get securityReadReceipts; + + /// No description provided for @securityAltKeyboard. + /// + /// In en, this message translates to: + /// **'Alternative keyboard'** + String get securityAltKeyboard; + + /// No description provided for @securityUnsafeFiles. + /// + /// In en, this message translates to: + /// **'Accept unsafe files'** + String get securityUnsafeFiles; + + /// No description provided for @securityAudioTranscription. + /// + /// In en, this message translates to: + /// **'Audio transcription'** + String get securityAudioTranscription; + + /// No description provided for @securityBlacklistTitle. + /// + /// In en, this message translates to: + /// **'Blacklist'** + String get securityBlacklistTitle; + + /// No description provided for @securityBlacklistNotification. + /// + /// In en, this message translates to: + /// **'Blacklist: {count} contacts'** + String securityBlacklistNotification(String count); + + /// No description provided for @passwordEntryWrongPassword. + /// + /// In en, this message translates to: + /// **'Wrong password'** + String get passwordEntryWrongPassword; + + /// No description provided for @passwordEntryConfirmTitle. + /// + /// In en, this message translates to: + /// **'Confirm password'** + String get passwordEntryConfirmTitle; + + /// No description provided for @passwordEntryCurrentPasswordHint. + /// + /// In en, this message translates to: + /// **'Current password'** + String get passwordEntryCurrentPasswordHint; + + /// No description provided for @passwordEntryContinue. + /// + /// In en, this message translates to: + /// **'Continue'** + String get passwordEntryContinue; + + /// No description provided for @passwordEntryNotSetTitle. + /// + /// In en, this message translates to: + /// **'Password is not set'** + String get passwordEntryNotSetTitle; + + /// No description provided for @passwordEntry2faSubtitle. + /// + /// In en, this message translates to: + /// **'Two-factor authentication'** + String get passwordEntry2faSubtitle; + + /// No description provided for @passwordEntrySetupAction. + /// + /// In en, this message translates to: + /// **'Set password'** + String get passwordEntrySetupAction; + + /// No description provided for @passwordEntryGateMessage. + /// + /// In en, this message translates to: + /// **'Enter your login password to manage protection'** + String get passwordEntryGateMessage; + + /// No description provided for @passwordEntryGenericPasswordHint. + /// + /// In en, this message translates to: + /// **'Password'** + String get passwordEntryGenericPasswordHint; + + /// No description provided for @passwordEntrySetTitle. + /// + /// In en, this message translates to: + /// **'Password is set'** + String get passwordEntrySetTitle; + + /// No description provided for @passwordEntryHintPrefix. + /// + /// In en, this message translates to: + /// **'Hint: {hint}'** + String passwordEntryHintPrefix(String hint); + + /// No description provided for @passwordEntryChangePasswordAction. + /// + /// In en, this message translates to: + /// **'Change password'** + String get passwordEntryChangePasswordAction; + + /// No description provided for @passwordEntryChangeEmailAction. + /// + /// In en, this message translates to: + /// **'Change email'** + String get passwordEntryChangeEmailAction; + + /// No description provided for @passwordEntryDeleteAction. + /// + /// In en, this message translates to: + /// **'Delete password'** + String get passwordEntryDeleteAction; + + /// No description provided for @passwordEntryMinPasswordError. + /// + /// In en, this message translates to: + /// **'Password must be at least 6 characters'** + String get passwordEntryMinPasswordError; + + /// No description provided for @passwordEntryMismatchError. + /// + /// In en, this message translates to: + /// **'Passwords do not match'** + String get passwordEntryMismatchError; + + /// No description provided for @passwordEntryInvalidEmailError. + /// + /// In en, this message translates to: + /// **'Enter a valid email'** + String get passwordEntryInvalidEmailError; + + /// No description provided for @passwordEntryInvalidCodeError. + /// + /// In en, this message translates to: + /// **'Enter the 6-digit code'** + String get passwordEntryInvalidCodeError; + + /// No description provided for @passwordEntrySetupTitle. + /// + /// In en, this message translates to: + /// **'Password setup'** + String get passwordEntrySetupTitle; + + /// No description provided for @passwordEntryStepPassword. + /// + /// In en, this message translates to: + /// **'Password'** + String get passwordEntryStepPassword; + + /// No description provided for @passwordEntryStepHint. + /// + /// In en, this message translates to: + /// **'Hint'** + String get passwordEntryStepHint; + + /// No description provided for @passwordEntryStepEmail. + /// + /// In en, this message translates to: + /// **'Email'** + String get passwordEntryStepEmail; + + /// No description provided for @passwordEntryStepCode. + /// + /// In en, this message translates to: + /// **'Code'** + String get passwordEntryStepCode; + + /// No description provided for @passwordEntryChoosePassword. + /// + /// In en, this message translates to: + /// **'Choose a password'** + String get passwordEntryChoosePassword; + + /// No description provided for @passwordEntryMinCharsHint. + /// + /// In en, this message translates to: + /// **'At least 6 characters'** + String get passwordEntryMinCharsHint; + + /// No description provided for @passwordEntryEnterPasswordHint. + /// + /// In en, this message translates to: + /// **'Enter password'** + String get passwordEntryEnterPasswordHint; + + /// No description provided for @passwordEntryEnterAgain. + /// + /// In en, this message translates to: + /// **'Enter the password again'** + String get passwordEntryEnterAgain; + + /// No description provided for @passwordEntryRepeatHint. + /// + /// In en, this message translates to: + /// **'Repeat password'** + String get passwordEntryRepeatHint; + + /// No description provided for @passwordEntryHintForPassword. + /// + /// In en, this message translates to: + /// **'Password hint'** + String get passwordEntryHintForPassword; + + /// No description provided for @passwordEntryOptional. + /// + /// In en, this message translates to: + /// **'Optional'** + String get passwordEntryOptional; + + /// No description provided for @passwordEntryHintFieldHint. + /// + /// In en, this message translates to: + /// **'Enter a hint (optional)'** + String get passwordEntryHintFieldHint; + + /// No description provided for @passwordEntryLinkEmail. + /// + /// In en, this message translates to: + /// **'Link an email'** + String get passwordEntryLinkEmail; + + /// No description provided for @passwordEntryEmailPurpose. + /// + /// In en, this message translates to: + /// **'For password recovery. Optional'** + String get passwordEntryEmailPurpose; + + /// No description provided for @passwordEntryEmailHintOptional. + /// + /// In en, this message translates to: + /// **'example@mail.com (optional)'** + String get passwordEntryEmailHintOptional; + + /// No description provided for @passwordEntryEnterCode. + /// + /// In en, this message translates to: + /// **'Enter the code'** + String get passwordEntryEnterCode; + + /// No description provided for @passwordEntryCodeSentTo. + /// + /// In en, this message translates to: + /// **'Code sent to {email}'** + String passwordEntryCodeSentTo(String email); + + /// No description provided for @passwordEntryChangedNotif. + /// + /// In en, this message translates to: + /// **'Password changed'** + String get passwordEntryChangedNotif; + + /// No description provided for @passwordEntryNewPassword. + /// + /// In en, this message translates to: + /// **'New password'** + String get passwordEntryNewPassword; + + /// No description provided for @passwordEntryNewPasswordHint. + /// + /// In en, this message translates to: + /// **'Enter new password'** + String get passwordEntryNewPasswordHint; + + /// No description provided for @passwordEntryRepeatNewPasswordHint. + /// + /// In en, this message translates to: + /// **'Repeat new password'** + String get passwordEntryRepeatNewPasswordHint; + + /// No description provided for @passwordEntryEmailChangedNotif. + /// + /// In en, this message translates to: + /// **'Email changed'** + String get passwordEntryEmailChangedNotif; + + /// No description provided for @passwordEntryNewEmail. + /// + /// In en, this message translates to: + /// **'New email'** + String get passwordEntryNewEmail; + + /// No description provided for @passwordEntryEmailHint. + /// + /// In en, this message translates to: + /// **'example@mail.com'** + String get passwordEntryEmailHint; + + /// No description provided for @passwordEntryRemovedNotif. + /// + /// In en, this message translates to: + /// **'Password removed'** + String get passwordEntryRemovedNotif; + + /// No description provided for @passwordEntryRemoveTitle. + /// + /// In en, this message translates to: + /// **'Remove password'** + String get passwordEntryRemoveTitle; + + /// No description provided for @passwordEntryRemoveWarning. + /// + /// In en, this message translates to: + /// **'Warning! Removing the password will weaken your account\'s protection.'** + String get passwordEntryRemoveWarning; + + /// No description provided for @cloudStorageNoActiveProfile. + /// + /// In en, this message translates to: + /// **'No active profile'** + String get cloudStorageNoActiveProfile; + + /// No description provided for @cloudStorageSetupFailed. + /// + /// In en, this message translates to: + /// **'Could not create environment'** + String get cloudStorageSetupFailed; + + /// No description provided for @cloudStorageTitle. + /// + /// In en, this message translates to: + /// **'Cloud storage'** + String get cloudStorageTitle; + + /// No description provided for @cloudStorageNotConfiguredTitle. + /// + /// In en, this message translates to: + /// **'Cloud storage environment isn\'t set up'** + String get cloudStorageNotConfiguredTitle; + + /// No description provided for @cloudStorageNotConfiguredSubtitle. + /// + /// In en, this message translates to: + /// **'Let\'s start? It\'s quick.'** + String get cloudStorageNotConfiguredSubtitle; + + /// No description provided for @cloudStorageStart. + /// + /// In en, this message translates to: + /// **'Start'** + String get cloudStorageStart; + + /// No description provided for @cloudStorageUploadingPercent. + /// + /// In en, this message translates to: + /// **'Uploading {percent}%'** + String cloudStorageUploadingPercent(String percent); + + /// No description provided for @cloudStorageStartUploadHint. + /// + /// In en, this message translates to: + /// **'Start an upload to see the progress bar'** + String get cloudStorageStartUploadHint; + + /// No description provided for @cloudStorageEmptyTitle. + /// + /// In en, this message translates to: + /// **'No cloud files yet...'** + String get cloudStorageEmptyTitle; + + /// No description provided for @cloudStorageEmptySubtitle. + /// + /// In en, this message translates to: + /// **'Add one?'** + String get cloudStorageEmptySubtitle; + + /// No description provided for @cloudStorageUpload. + /// + /// In en, this message translates to: + /// **'Upload'** + String get cloudStorageUpload; + + /// No description provided for @cloudStorageFromFile. + /// + /// In en, this message translates to: + /// **'From file'** + String get cloudStorageFromFile; + + /// No description provided for @cloudStorageById. + /// + /// In en, this message translates to: + /// **'By ID'** + String get cloudStorageById; + + /// No description provided for @cloudStorageFileIdLabel. + /// + /// In en, this message translates to: + /// **'File ID'** + String get cloudStorageFileIdLabel; + + /// No description provided for @cloudStorageSizeLabel. + /// + /// In en, this message translates to: + /// **'Size'** + String get cloudStorageSizeLabel; + + /// No description provided for @cloudStorageNoLinkYet. + /// + /// In en, this message translates to: + /// **'No link yet. Create one.'** + String get cloudStorageNoLinkYet; + + /// No description provided for @cloudStorageLinkExpiresIn. + /// + /// In en, this message translates to: + /// **'Link expires in {time}'** + String cloudStorageLinkExpiresIn(String time); + + /// No description provided for @cloudStorageLinkCopied. + /// + /// In en, this message translates to: + /// **'Link copied'** + String get cloudStorageLinkCopied; + + /// No description provided for @cloudStorageInvalidId. + /// + /// In en, this message translates to: + /// **'Invalid ID'** + String get cloudStorageInvalidId; + + /// No description provided for @cloudStorageSendError. + /// + /// In en, this message translates to: + /// **'Send error'** + String get cloudStorageSendError; + + /// No description provided for @cloudStorageSendByIdTitle. + /// + /// In en, this message translates to: + /// **'Send by ID'** + String get cloudStorageSendByIdTitle; + + /// No description provided for @cloudStorageSend. + /// + /// In en, this message translates to: + /// **'Send'** + String get cloudStorageSend; + + /// No description provided for @digitalIdGosuslugiLinkUnavailable. + /// + /// In en, this message translates to: + /// **'Linking Gosuslugi isn\'t available on this platform. Do this in the mobile app.'** + String get digitalIdGosuslugiLinkUnavailable; + + /// No description provided for @digitalIdGosuslugiLinkFailed. + /// + /// In en, this message translates to: + /// **'Could not get the Gosuslugi link'** + String get digitalIdGosuslugiLinkFailed; + + /// No description provided for @digitalIdGosuslugiTitle. + /// + /// In en, this message translates to: + /// **'Gosuslugi'** + String get digitalIdGosuslugiTitle; + + /// No description provided for @digitalIdDocsUnavailable. + /// + /// In en, this message translates to: + /// **'Documents aren\'t available yet. Try again later.'** + String get digitalIdDocsUnavailable; + + /// No description provided for @digitalIdTitle. + /// + /// In en, this message translates to: + /// **'Digital ID'** + String get digitalIdTitle; + + /// No description provided for @digitalIdNotConfiguredTitle. + /// + /// In en, this message translates to: + /// **'Digital ID isn\'t set up'** + String get digitalIdNotConfiguredTitle; + + /// No description provided for @digitalIdLinkGosuslugiHint. + /// + /// In en, this message translates to: + /// **'Link your Gosuslugi account so your documents appear in Digital ID. The phone number in MAX must match the one in your Gosuslugi profile.'** + String get digitalIdLinkGosuslugiHint; + + /// No description provided for @digitalIdLinkOrRefreshHint. + /// + /// In en, this message translates to: + /// **'Link Gosuslugi to get access to your documents, or refresh the page if you\'ve already set up Digital ID.'** + String get digitalIdLinkOrRefreshHint; + + /// No description provided for @digitalIdLoadDocuments. + /// + /// In en, this message translates to: + /// **'Load documents'** + String get digitalIdLoadDocuments; + + /// No description provided for @digitalIdLinkGosuslugiButton. + /// + /// In en, this message translates to: + /// **'Link Gosuslugi'** + String get digitalIdLinkGosuslugiButton; + + /// No description provided for @digitalIdGosuslugiProfileFallback. + /// + /// In en, this message translates to: + /// **'Gosuslugi profile'** + String get digitalIdGosuslugiProfileFallback; + + /// No description provided for @digitalIdBirthDate. + /// + /// In en, this message translates to: + /// **'Date of birth: {date}'** + String digitalIdBirthDate(String date); + + /// No description provided for @digitalIdPersonalDataTitle. + /// + /// In en, this message translates to: + /// **'Personal data'** + String get digitalIdPersonalDataTitle; + + /// No description provided for @digitalIdSnilsLabel. + /// + /// In en, this message translates to: + /// **'SNILS'** + String get digitalIdSnilsLabel; + + /// No description provided for @digitalIdInnLabel. + /// + /// In en, this message translates to: + /// **'INN'** + String get digitalIdInnLabel; + + /// No description provided for @digitalIdBirthPlaceLabel. + /// + /// In en, this message translates to: + /// **'Place of birth'** + String get digitalIdBirthPlaceLabel; + + /// No description provided for @digitalIdRegistrationAddressLabel. + /// + /// In en, this message translates to: + /// **'Registration address'** + String get digitalIdRegistrationAddressLabel; + + /// No description provided for @digitalIdDocumentsTitle. + /// + /// In en, this message translates to: + /// **'Documents'** + String get digitalIdDocumentsTitle; + + /// No description provided for @digitalIdDocSeries. + /// + /// In en, this message translates to: + /// **'series {series}'** + String digitalIdDocSeries(String series); + + /// No description provided for @digitalIdDocNumber. + /// + /// In en, this message translates to: + /// **'No. {number}'** + String digitalIdDocNumber(String number); + + /// No description provided for @digitalIdPassesTitle. + /// + /// In en, this message translates to: + /// **'Passes'** + String get digitalIdPassesTitle; + + /// No description provided for @digitalIdCardInn. + /// + /// In en, this message translates to: + /// **'INN {inn}'** + String digitalIdCardInn(String inn); + + /// No description provided for @digitalIdBiometryConfigured. + /// + /// In en, this message translates to: + /// **'Biometrics set up on this device'** + String get digitalIdBiometryConfigured; + + /// No description provided for @digitalIdBiometryNotConfigured. + /// + /// In en, this message translates to: + /// **'Biometrics not set up on this device'** + String get digitalIdBiometryNotConfigured; + + /// No description provided for @digitalIdDocPassport. + /// + /// In en, this message translates to: + /// **'Russian passport'** + String get digitalIdDocPassport; + + /// No description provided for @digitalIdDocOms. + /// + /// In en, this message translates to: + /// **'Health insurance policy (OMS)'** + String get digitalIdDocOms; + + /// No description provided for @digitalIdDocDriverLicense. + /// + /// In en, this message translates to: + /// **'Driver\'s license'** + String get digitalIdDocDriverLicense; + + /// No description provided for @digitalIdDocVehicleSts. + /// + /// In en, this message translates to: + /// **'Vehicle registration certificate (STS)'** + String get digitalIdDocVehicleSts; + + /// No description provided for @digitalIdDocChildBirthCert. + /// + /// In en, this message translates to: + /// **'Birth certificate'** + String get digitalIdDocChildBirthCert; + + /// No description provided for @digitalIdDocPensionCert. + /// + /// In en, this message translates to: + /// **'Pension certificate'** + String get digitalIdDocPensionCert; + + /// No description provided for @digitalIdDocDisabledCert. + /// + /// In en, this message translates to: + /// **'Disability certificate'** + String get digitalIdDocDisabledCert; + + /// No description provided for @digitalIdDocLargeFamilyCert. + /// + /// In en, this message translates to: + /// **'Large family certificate'** + String get digitalIdDocLargeFamilyCert; + + /// No description provided for @digitalIdDocStudentTicket. + /// + /// In en, this message translates to: + /// **'Student ID'** + String get digitalIdDocStudentTicket; + + /// No description provided for @digitalIdDocChildInn. + /// + /// In en, this message translates to: + /// **'Child\'s INN'** + String get digitalIdDocChildInn; + + /// No description provided for @digitalIdDocChildOms. + /// + /// In en, this message translates to: + /// **'Child\'s health insurance policy (OMS)'** + String get digitalIdDocChildOms; + + /// No description provided for @attachSheetGallery. + /// + /// In en, this message translates to: + /// **'Gallery'** + String get attachSheetGallery; + + /// No description provided for @attachSheetPoll. + /// + /// In en, this message translates to: + /// **'Poll'** + String get attachSheetPoll; + + /// No description provided for @attachSheetCameraComingSoon. + /// + /// In en, this message translates to: + /// **'Camera is coming soon'** + String get attachSheetCameraComingSoon; + + /// No description provided for @attachSheetSendFileTitle. + /// + /// In en, this message translates to: + /// **'Send a file'** + String get attachSheetSendFileTitle; + + /// No description provided for @attachSheetSendFileSubtitle. + /// + /// In en, this message translates to: + /// **'A document, archive, or any other file'** + String get attachSheetSendFileSubtitle; + + /// No description provided for @attachSheetChooseFileButton. + /// + /// In en, this message translates to: + /// **'Choose file'** + String get attachSheetChooseFileButton; + + /// No description provided for @attachSheetShareLocationTitle. + /// + /// In en, this message translates to: + /// **'Share location'** + String get attachSheetShareLocationTitle; + + /// No description provided for @attachSheetShareLocationSubtitle. + /// + /// In en, this message translates to: + /// **'Send your current location'** + String get attachSheetShareLocationSubtitle; + + /// No description provided for @attachSheetSendLocationButton. + /// + /// In en, this message translates to: + /// **'Send location'** + String get attachSheetSendLocationButton; + + /// No description provided for @attachSheetCreatePoll. + /// + /// In en, this message translates to: + /// **'Create poll'** + String get attachSheetCreatePoll; + + /// No description provided for @attachSheetCreatePollSubtitle. + /// + /// In en, this message translates to: + /// **'A question with answer options'** + String get attachSheetCreatePollSubtitle; + + /// No description provided for @attachSheetNoImagesFound. + /// + /// In en, this message translates to: + /// **'No images found'** + String get attachSheetNoImagesFound; + + /// No description provided for @attachSheetLimitedAccessInfo. + /// + /// In en, this message translates to: + /// **'Not all photos are accessible'** + String get attachSheetLimitedAccessInfo; + + /// No description provided for @attachSheetSectionInProgress. + /// + /// In en, this message translates to: + /// **'Section under development'** + String get attachSheetSectionInProgress; + + /// No description provided for @attachSheetNoGalleryAccessTitle. + /// + /// In en, this message translates to: + /// **'No access to the gallery'** + String get attachSheetNoGalleryAccessTitle; + + /// No description provided for @attachSheetNoGalleryAccessSubtitle. + /// + /// In en, this message translates to: + /// **'Allow access to photos to pick them from here'** + String get attachSheetNoGalleryAccessSubtitle; + + /// No description provided for @attachSheetAllow. + /// + /// In en, this message translates to: + /// **'Allow'** + String get attachSheetAllow; + + /// No description provided for @attachSheetSettings. + /// + /// In en, this message translates to: + /// **'Settings'** + String get attachSheetSettings; + + /// No description provided for @attachSheetAddCaptionHint. + /// + /// In en, this message translates to: + /// **'Add a caption...'** + String get attachSheetAddCaptionHint; + + /// No description provided for @attachSheetCamera. + /// + /// In en, this message translates to: + /// **'Camera'** + String get attachSheetCamera; + + /// No description provided for @photoEditorApplyFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t apply'** + String get photoEditorApplyFailed; + + /// No description provided for @photoEditorFlipTooltip. + /// + /// In en, this message translates to: + /// **'Flip'** + String get photoEditorFlipTooltip; + + /// No description provided for @photoEditorRotateTooltip. + /// + /// In en, this message translates to: + /// **'Rotate'** + String get photoEditorRotateTooltip; + + /// No description provided for @photoEditorCancel. + /// + /// In en, this message translates to: + /// **'CANCEL'** + String get photoEditorCancel; + + /// No description provided for @photoEditorReset. + /// + /// In en, this message translates to: + /// **'RESET'** + String get photoEditorReset; + + /// No description provided for @photoEditorDone. + /// + /// In en, this message translates to: + /// **'DONE'** + String get photoEditorDone; + + /// No description provided for @photoEditorTextDialogTitle. + /// + /// In en, this message translates to: + /// **'Text'** + String get photoEditorTextDialogTitle; + + /// No description provided for @photoEditorTextDialogHint. + /// + /// In en, this message translates to: + /// **'Enter text'** + String get photoEditorTextDialogHint; + + /// No description provided for @photoEditorOk. + /// + /// In en, this message translates to: + /// **'OK'** + String get photoEditorOk; + + /// No description provided for @photoEditorApplyChangesFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t apply changes'** + String get photoEditorApplyChangesFailed; + + /// No description provided for @photoEditorClearAll. + /// + /// In en, this message translates to: + /// **'Clear all'** + String get photoEditorClearAll; + + /// No description provided for @photoEditorAddText. + /// + /// In en, this message translates to: + /// **'Add text'** + String get photoEditorAddText; + + /// No description provided for @photoEditorTabDraw. + /// + /// In en, this message translates to: + /// **'DRAW'** + String get photoEditorTabDraw; + + /// No description provided for @photoEditorTabStickers. + /// + /// In en, this message translates to: + /// **'STICKERS'** + String get photoEditorTabStickers; + + /// No description provided for @photoEditorTabText. + /// + /// In en, this message translates to: + /// **'TEXT'** + String get photoEditorTabText; + + /// No description provided for @photoEditorChannelAll. + /// + /// In en, this message translates to: + /// **'All'** + String get photoEditorChannelAll; + + /// No description provided for @photoEditorChannelRed. + /// + /// In en, this message translates to: + /// **'Red'** + String get photoEditorChannelRed; + + /// No description provided for @photoEditorChannelGreen. + /// + /// In en, this message translates to: + /// **'Green'** + String get photoEditorChannelGreen; + + /// No description provided for @photoEditorChannelBlue. + /// + /// In en, this message translates to: + /// **'Blue'** + String get photoEditorChannelBlue; + + /// No description provided for @photoEditorEnhance. + /// + /// In en, this message translates to: + /// **'Enhance'** + String get photoEditorEnhance; + + /// No description provided for @photoEditorExposure. + /// + /// In en, this message translates to: + /// **'Exposure'** + String get photoEditorExposure; + + /// No description provided for @photoEditorContrast. + /// + /// In en, this message translates to: + /// **'Contrast'** + String get photoEditorContrast; + + /// No description provided for @photoEditorSaturation. + /// + /// In en, this message translates to: + /// **'Saturation'** + String get photoEditorSaturation; + + /// No description provided for @photoEditorWarmth. + /// + /// In en, this message translates to: + /// **'Warmth'** + String get photoEditorWarmth; + + /// No description provided for @photoEditorVignette. + /// + /// In en, this message translates to: + /// **'Vignette'** + String get photoEditorVignette; + + /// No description provided for @photoEditorBlurOff. + /// + /// In en, this message translates to: + /// **'Off'** + String get photoEditorBlurOff; + + /// No description provided for @photoEditorBlurRadial. + /// + /// In en, this message translates to: + /// **'Radial'** + String get photoEditorBlurRadial; + + /// No description provided for @photoEditorBlurLinear. + /// + /// In en, this message translates to: + /// **'Linear'** + String get photoEditorBlurLinear; + + /// No description provided for @fontSettingsInvalidInput. + /// + /// In en, this message translates to: + /// **'Enter a font link or name'** + String get fontSettingsInvalidInput; + + /// No description provided for @fontSettingsFontNotFound. + /// + /// In en, this message translates to: + /// **'Font \"{name}\" not found or no network'** + String fontSettingsFontNotFound(String name); + + /// No description provided for @fontSettingsFontAdded. + /// + /// In en, this message translates to: + /// **'Font \"{name}\" added'** + String fontSettingsFontAdded(String name); + + /// No description provided for @fontSettingsFontRemoved. + /// + /// In en, this message translates to: + /// **'Font \"{name}\" removed'** + String fontSettingsFontRemoved(String name); + + /// No description provided for @fontSettingsAddFontTitle. + /// + /// In en, this message translates to: + /// **'Add font'** + String get fontSettingsAddFontTitle; + + /// No description provided for @fontSettingsAddFontDescription. + /// + /// In en, this message translates to: + /// **'Paste a Google Fonts link or font name'** + String get fontSettingsAddFontDescription; + + /// No description provided for @fontSettingsAddFontConfirm. + /// + /// In en, this message translates to: + /// **'Add'** + String get fontSettingsAddFontConfirm; + + /// No description provided for @fontSettingsTitle. + /// + /// In en, this message translates to: + /// **'Fonts'** + String get fontSettingsTitle; + + /// No description provided for @fontSettingsSectionFont. + /// + /// In en, this message translates to: + /// **'Font'** + String get fontSettingsSectionFont; + + /// No description provided for @fontSettingsLoading. + /// + /// In en, this message translates to: + /// **'Loading…'** + String get fontSettingsLoading; + + /// No description provided for @fontSettingsSectionFontSize. + /// + /// In en, this message translates to: + /// **'Font size'** + String get fontSettingsSectionFontSize; + + /// No description provided for @fontSettingsPreviewLabel. + /// + /// In en, this message translates to: + /// **'PREVIEW'** + String get fontSettingsPreviewLabel; + + /// No description provided for @fontSettingsReset. + /// + /// In en, this message translates to: + /// **'Reset'** + String get fontSettingsReset; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d7de50c..59cff9b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -520,4 +520,1433 @@ class AppLocalizationsEn extends AppLocalizations { @override String get registrationChooseAvatar => 'Choose an avatar'; + + @override + String get msgActionsCopy => 'Copy'; + + @override + String get msgActionsEdit => 'Edit'; + + @override + String get msgActionsReply => 'Reply'; + + @override + String get msgActionsForward => 'Forward'; + + @override + String get msgActionsMarkUnread => 'Mark as unread'; + + @override + String get msgActionsEditHistory => 'Edit history'; + + @override + String get msgActionsReport => 'Report'; + + @override + String get msgActionsDelete => 'Delete'; + + @override + String get msgActionsCopied => 'Copied'; + + @override + String get msgActionsLoadReasonsFailed => 'Failed to load reasons'; + + @override + String get msgActionsCurrentVersion => 'current version'; + + @override + String msgActionsCurrentVersionWithDate(String date) { + return 'current version · $date'; + } + + @override + String get msgActionsNoText => '(no text)'; + + @override + String notificationsSaveFailed(String error) { + return 'Could not save: $error'; + } + + @override + String get notificationsFkmAlreadyHasFcm => 'Why? You already have FCM.'; + + @override + String get notificationsFkmDownloadFcm => 'Better download the FCM version.'; + + @override + String get notificationsTitle => 'Notifications'; + + @override + String get notificationsFkmSectionTitle => 'FKM'; + + @override + String get notificationsFkmEnableLabel => 'Enable notifications'; + + @override + String get notificationsFkmEnableSubtitle => + 'For FKM notifications to work, the app will need to keep a notification in the shade.'; + + @override + String get notificationsMainSectionTitle => 'Notifications'; + + @override + String get notificationsAllLabel => 'All notifications'; + + @override + String get notificationsNewSectionTitle => 'New notifications'; + + @override + String get notificationsPreviewLabel => 'Message preview'; + + @override + String get notificationsSoundLabel => 'Sound'; + + @override + String get notificationsAdditionalSectionTitle => 'Additional'; + + @override + String get notificationsCallsLabel => 'Call notifications'; + + @override + String get notificationsNewContactsLabel => 'Notifications from new contacts'; + + @override + String get notificationsHapticsSectionTitle => 'Haptic feedback'; + + @override + String get notificationsHapticsLabel => 'Haptic feedback'; + + @override + String get notificationsHapticsSubtitle => + 'Vibration feedback for actions in the app'; + + @override + String devicesLoadFailed(String error) { + return 'Failed to load: $error'; + } + + @override + String get devicesQrLinkDialogTitle => 'Link from QR'; + + @override + String get devicesQrLinkDialogHint => 'Paste the QR code content'; + + @override + String get devicesAllTerminated => 'All sessions terminated'; + + @override + String devicesGenericError(String error) { + return 'Error: $error'; + } + + @override + String devicesIpLookupError(String error) { + return 'IP error: $error'; + } + + @override + String get devicesTitle => 'Devices'; + + @override + String get devicesPromoTitle => 'Devices in KOMET'; + + @override + String get devicesPromoSubtitle => 'Who has access to your account?'; + + @override + String get devicesScanQrButton => 'Scan QR'; + + @override + String get devicesCurrentSuffix => ' (current)'; + + @override + String get devicesOnlineStatus => 'Online'; + + @override + String get devicesTerminateOthersButton => + 'Terminate all sessions except the current one'; + + @override + String get devicesMobileNetworkLabel => 'Mobile network'; + + @override + String get devicesProxyDetectedLabel => 'Proxy/VPN detected'; + + @override + String get themeSettingsTitle => 'Theme'; + + @override + String get themeSettingsModeCardTitle => 'Theme mode'; + + @override + String get themeSettingsModeCardSubtitle => + 'Light, dark, or automatic switching'; + + @override + String get themeSettingsModeSystem => 'System'; + + @override + String get themeSettingsModeLight => 'Light'; + + @override + String get themeSettingsModeDark => 'Dark'; + + @override + String get themeSettingsModeSchedule => 'Scheduled'; + + @override + String get themeSettingsAmoledTitle => 'AMOLED black'; + + @override + String get themeSettingsAmoledSubtitle => + 'Pure black background for OLED screens'; + + @override + String get themeSettingsScheduleTitle => 'Schedule'; + + @override + String get themeSettingsScheduleSubtitleEnabled => + 'When dark theme turns on automatically'; + + @override + String get themeSettingsScheduleSubtitleDisabled => + 'Available in \"Scheduled\" mode'; + + @override + String get themeSettingsScheduleDarkFrom => 'Dark from'; + + @override + String get themeSettingsScheduleLightFrom => 'Light from'; + + @override + String get appearanceTitle => 'Appearance'; + + @override + String get appearanceVisualStyleTitle => 'Visual style'; + + @override + String get appearanceVisualStyleSubtitle => + 'Material You or dimensional Glossy capsules'; + + @override + String get appearanceVisualStyleMaterialYou => 'Material You'; + + @override + String get appearanceVisualStyleGlossy => 'Glossy'; + + @override + String get appearanceChatChromeTitle => 'Chat screen elements'; + + @override + String get appearanceChatChromeSubtitle => + 'Background of the top and bottom panels: color, blur, or transparent. With blur or transparency, messages scroll under the panels'; + + @override + String get appearanceChatChromeColor => 'Color'; + + @override + String get appearanceChatChromeBlur => 'Blur'; + + @override + String get appearanceChatChromeNone => 'None'; + + @override + String get appearanceGradientTitle => 'Gradient'; + + @override + String get appearanceGradientSubtitle => + 'Depth and highlights in Glossy capsules'; + + @override + String get appearanceAccentColorTitle => 'Accent color'; + + @override + String get appearanceAccentColorSystem => 'System'; + + @override + String get appearanceAccentColorSubtitle => + 'Main color of the interface and bubbles'; + + @override + String get appearanceAccentColorSystemActive => 'System color is active'; + + @override + String get appearanceAccentColorReset => 'Reset to system'; + + @override + String get appearanceBubbleShapeTitle => 'Message shape'; + + @override + String get appearanceBubbleShapeSubtitle => 'Bubble corner rounding'; + + @override + String get appearanceBubbleShapeMobile => 'TG Mobile'; + + @override + String get appearanceBubbleShapeDesktop => 'TG Desktop'; + + @override + String get appearanceBubbleBehaviorTitle => 'Message behavior'; + + @override + String get appearanceBubbleBehaviorSubtitle => + 'Whether bubble shape changes based on neighbors in a group'; + + @override + String get appearanceBubbleBehaviorMutable => 'Mutable'; + + @override + String get appearanceBubbleBehaviorImmutable => 'Immutable'; + + @override + String get appearancePreviewHello => 'Hi!'; + + @override + String get appearancePreviewHowIsIt => 'How do you like it?'; + + @override + String get appearancePreviewHmm => 'hmm...'; + + @override + String get appearancePreviewNotBad => 'Not bad at all!'; + + @override + String get callKometDetectedNotification => 'This person uses Komet! :3'; + + @override + String get callStatusConnecting => 'Connecting'; + + @override + String get callGroupConnecting => 'Connecting…'; + + @override + String get callGroupWaitingParticipants => 'Waiting for participants…'; + + @override + String get callParticipantYou => 'You'; + + @override + String get callParticipantFallback => 'Participant'; + + @override + String get callTooltipMinimize => 'Minimize'; + + @override + String get callTooltipKometHub => 'Komet'; + + @override + String get callInfoTitle => 'About call'; + + @override + String get callPeerMicOff => 'Microphone off'; + + @override + String get callPeerCameraOn => 'Camera on'; + + @override + String get callUnknownName => 'Unknown'; + + @override + String get callIncoming => 'Incoming call'; + + @override + String get callStatusRinging => 'Calling'; + + @override + String get callStatusEnded => 'Call ended'; + + @override + String get callDecline => 'Decline'; + + @override + String get callAccept => 'Accept'; + + @override + String get callSpeaker => 'Speaker'; + + @override + String get callVideoLabel => 'Video'; + + @override + String get callScreenLabel => 'Screen'; + + @override + String get callUnmute => 'Unmute'; + + @override + String get callMute => 'Mute'; + + @override + String get callEndButton => 'End'; + + @override + String get callInfoClient => 'Client'; + + @override + String get callInfoPlatform => 'Platform'; + + @override + String get callInfoCountry => 'Country'; + + @override + String get callInfoInContacts => 'In contacts'; + + @override + String get callValueYes => 'yes'; + + @override + String get callValueNo => 'no'; + + @override + String get callInfoPeerIp => 'Peer IP'; + + @override + String get callInfoPeerNetwork => 'Peer network'; + + @override + String get callInfoPath => 'Connection path'; + + @override + String get callInfoCodec => 'Codec'; + + @override + String get callInfoServer => 'Server'; + + @override + String get callInfoTopology => 'Topology'; + + @override + String get callInfoStatus => 'Status'; + + @override + String get callStatusValueConnected => 'connected'; + + @override + String get callStatusValueConnecting => 'connecting…'; + + @override + String get callInfoPeerMic => 'Peer microphone'; + + @override + String get callMicValueOn => 'on'; + + @override + String get callMicValueOff => 'off'; + + @override + String get callInfoPeerCamera => 'Peer camera'; + + @override + String get callCameraValueOn => 'on'; + + @override + String get callCameraValueOff => 'off'; + + @override + String get callInfoVideoTrack => 'Video track'; + + @override + String callInfoVideoTrackPresent(int count) { + return 'yes ($count)'; + } + + @override + String get callInfoVideoSize => 'Video size'; + + @override + String get callInfoFrameRendering => 'Frame rendering'; + + @override + String get callBadgeEncrypted => 'Encrypted'; + + @override + String get callBadgeAudio => 'Audio'; + + @override + String get callBadgeRecording => 'Recording'; + + @override + String get callBadgeNoiseSuppression => 'Noise suppression'; + + @override + String get callBadgeAnimoji => 'Animoji'; + + @override + String get callInfoNoDataYet => 'Data will appear after connecting…'; + + @override + String get hubTitleMenu => 'Komet'; + + @override + String get hubChatPageTitle => 'Anonymous chat'; + + @override + String get hubGamesTitle => 'Games'; + + @override + String get hubCheckersTitle => 'Checkers'; + + @override + String get hubChatTileTitle => 'Chat'; + + @override + String get hubChatTileSubtitle => 'Anonymous messages'; + + @override + String get hubGamesTileSubtitle => 'Play with your partner'; + + @override + String get hubCheckersTileSubtitle => 'Russian checkers'; + + @override + String get hubMoreSoonTitle => 'More coming soon…'; + + @override + String get hubMoreSoonSubtitle => 'In development'; + + @override + String get hubChatPrivacyNote => + 'Sent directly through the call, stored nowhere'; + + @override + String get hubChatEmpty => 'No messages yet'; + + @override + String get hubChatInputHint => 'Message…'; + + @override + String get hubCheckersRestart => 'Restart'; + + @override + String get hubCheckersYouWhite => 'You\'re playing white'; + + @override + String get hubCheckersYouBlack => 'You\'re playing black'; + + @override + String get hubCheckersWon => 'You won 🎉'; + + @override + String get hubCheckersLost => 'You lost'; + + @override + String get hubCheckersYourMove => 'Your move'; + + @override + String get hubCheckersOpponentMove => 'Opponent\'s move…'; + + @override + String get scheduledPickTimeTitle => 'When to send'; + + @override + String get scheduledEditTitle => 'Edit'; + + @override + String get scheduledMessageTextHint => 'Message text'; + + @override + String get scheduledSave => 'Save'; + + @override + String get scheduledEditFailed => 'Failed to edit message'; + + @override + String get scheduledDeleteConfirmTitle => 'Delete scheduled message?'; + + @override + String get scheduledDeleteConfirmMessage => 'The message won\'t be sent.'; + + @override + String get scheduledDeleteConfirmLabel => 'Delete'; + + @override + String get scheduledDeleteFailed => 'Failed to delete message'; + + @override + String get scheduledAppBarTitle => 'Scheduled'; + + @override + String get scheduledEmpty => 'No scheduled messages'; + + @override + String get scheduledAttachPhoto => 'Photo'; + + @override + String get scheduledAttachVideo => 'Video'; + + @override + String get scheduledAttachVoice => 'Voice message'; + + @override + String get scheduledAttachFile => 'File'; + + @override + String get scheduledAttachLocation => 'Location'; + + @override + String get scheduledAttachForwarded => 'Forwarded'; + + @override + String get scheduledAttachGeneric => 'Attachment'; + + @override + String contactProfileLoadError(String error) { + return 'Error: $error'; + } + + @override + String get contactProfileBot => 'Bot'; + + @override + String get contactProfileOnline => 'Online'; + + @override + String get contactProfileRecentlyActive => 'Recently active'; + + @override + String get contactProfileActionChat => 'Chat'; + + @override + String get contactProfileActionSound => 'Sound'; + + @override + String get contactProfileActionCall => 'Call'; + + @override + String get contactProfileInfoPhone => 'Phone'; + + @override + String get contactProfileInfoCountry => 'Country'; + + @override + String get contactProfileInfoGender => 'Gender'; + + @override + String get contactProfileInfoRegistration => 'Registration'; + + @override + String get contactProfileInfoUpdated => 'Updated'; + + @override + String get contactProfileInfoAccountStatus => 'Account status'; + + @override + String get contactProfileInfoDescription => 'Description'; + + @override + String get contactProfileInfoLink => 'Link'; + + @override + String get contactProfileInfoFlags => 'Flags'; + + @override + String nfcPeerNameFallback(String id) { + return 'Contact #$id'; + } + + @override + String get nfcPeerFirstNameFallback => 'Contact'; + + @override + String get nfcContactAdded => 'Contact added'; + + @override + String nfcAddFailed(String error) { + return 'Failed to add: $error'; + } + + @override + String get nfcReasonBluetoothOff => 'Turn on Bluetooth and try again'; + + @override + String get nfcReasonPermission => + 'Bluetooth permissions are needed for exchange'; + + @override + String get nfcReasonDefault => 'Failed to establish connection'; + + @override + String get nfcSheetTitle => 'Contact exchange'; + + @override + String get nfcUnsupported => 'NFC is not available on this device'; + + @override + String get nfcDisabled => 'Turn on NFC in phone settings and try again'; + + @override + String get nfcScanningTitle => 'Hold the phones close together'; + + @override + String get nfcScanningSubtitle => 'Both devices must keep this screen open'; + + @override + String get nfcExchangingTitle => 'Exchanging contacts…'; + + @override + String get nfcExchangingSubtitle => 'Almost done'; + + @override + String nfcPeerIdFallback(String id) { + return 'ID $id'; + } + + @override + String get nfcAdded => 'Added'; + + @override + String get nfcAddContact => 'Add contact'; + + @override + String get chatInfoTabGeneralChats => 'Common chats'; + + @override + String get chatInfoTabMedia => 'Media'; + + @override + String get chatInfoTabFiles => 'Files'; + + @override + String get chatInfoTabVoice => 'Voice messages'; + + @override + String get chatInfoTabLinks => 'Links'; + + @override + String get chatInfoTabMembers => 'Members'; + + @override + String get chatInfoEmptyGeneralChats => 'No common chats'; + + @override + String get chatInfoEmptyMedia => 'No media'; + + @override + String get chatInfoEmptyFiles => 'No files'; + + @override + String get chatInfoEmptyVoice => 'No voice messages'; + + @override + String get chatInfoEmptyLinks => 'No links'; + + @override + String chatInfoOnlineOfTotal(String online, String total) { + return '$online of $total online'; + } + + @override + String get chatInfoActionLeave => 'Leave'; + + @override + String get chatInfoBio => 'About'; + + @override + String get chatInfoInviteLink => 'Invite link'; + + @override + String get chatInfoCollapse => 'Collapse'; + + @override + String get chatInfoShowMore => 'More'; + + @override + String get chatInfoAddMember => 'Add member'; + + @override + String get chatInfoRoleOwner => 'owner'; + + @override + String get chatInfoRoleAdmin => 'Admin'; + + @override + String get chatInfoNoData => 'No data'; + + @override + String get chatInfoHideExtra => 'Hide'; + + @override + String get chatInfoShowMoreExtra => 'Details'; + + @override + String get chatInfoRowId => 'Chat ID'; + + @override + String get chatInfoRowCreated => 'Created'; + + @override + String get chatInfoRowModified => 'Modified'; + + @override + String get chatInfoRowMembersCount => 'Members'; + + @override + String get chatInfoRowOwner => 'Owner'; + + @override + String get chatInfoRowCreatedGroup => 'Created'; + + @override + String get chatInfoRowJoined => 'Joined'; + + @override + String get chatInfoRowModifiedGroup => 'Modified'; + + @override + String get chatInfoRowHasBots => 'Has bots'; + + @override + String get chatInfoRowBlockedCount => 'Blocked'; + + @override + String get chatInfoRowOfficialGroup => 'Official'; + + @override + String get chatInfoRowSignAdmin => 'Admin signature'; + + @override + String get chatInfoRowSubscribersCount => 'Subscribers'; + + @override + String get chatInfoRowOfficialChannel => 'Official'; + + @override + String get chatInfoRowComments => 'Comments'; + + @override + String get chatInfoRowRkn => 'Roskomnadzor approved'; + + @override + String get chatInfoRowOnlyAdmin => 'Admins only'; + + @override + String get securityTitle => 'Security'; + + @override + String securityLoadError(String error) { + return 'Loading error: $error'; + } + + @override + String securitySaveError(String error) { + return 'Save error: $error'; + } + + @override + String get securityPrivacyAll => 'Everyone'; + + @override + String get securityPrivacyContacts => 'My contacts'; + + @override + String get securityPrivacyNobody => 'Nobody'; + + @override + String get securityFamilyProtection => 'Family protection'; + + @override + String get securityEnabledFem => 'Enabled'; + + @override + String get securityDisabledFem => 'Disabled'; + + @override + String get securityPasswordTitle => 'Login password'; + + @override + String get securityEnabledMasc => 'Enabled'; + + @override + String get securityDisabledMasc => 'Disabled'; + + @override + String get securityModeTitle => 'Safe mode'; + + @override + String get securityModeSubtitle => 'Hides personal information'; + + @override + String get securitySettingsUnavailable => + 'Changing this setting is not available yet'; + + @override + String get securityFindByPhone => 'Find me by phone number'; + + @override + String get securityWhoCanCall => 'Who can call me'; + + @override + String get securityWhoCanInvite => 'Who can invite me to chats'; + + @override + String get securityShowContact => 'Show contact'; + + @override + String get securityContentSafe => 'Safe'; + + @override + String get securityContentAll => 'All'; + + @override + String get securityShowOnlineStatus => 'See online status'; + + @override + String get securityShowMyNumber => 'See my number'; + + @override + String get securityConfirmTitle => 'Are you sure?'; + + @override + String get securityHiddenStatusWarning => + 'You won\'t be able to see the online status of other users.'; + + @override + String get securityConfidentialityHeader => 'PRIVACY'; + + @override + String get securityReadReceipts => 'Read receipts'; + + @override + String get securityAltKeyboard => 'Alternative keyboard'; + + @override + String get securityUnsafeFiles => 'Accept unsafe files'; + + @override + String get securityAudioTranscription => 'Audio transcription'; + + @override + String get securityBlacklistTitle => 'Blacklist'; + + @override + String securityBlacklistNotification(String count) { + return 'Blacklist: $count contacts'; + } + + @override + String get passwordEntryWrongPassword => 'Wrong password'; + + @override + String get passwordEntryConfirmTitle => 'Confirm password'; + + @override + String get passwordEntryCurrentPasswordHint => 'Current password'; + + @override + String get passwordEntryContinue => 'Continue'; + + @override + String get passwordEntryNotSetTitle => 'Password is not set'; + + @override + String get passwordEntry2faSubtitle => 'Two-factor authentication'; + + @override + String get passwordEntrySetupAction => 'Set password'; + + @override + String get passwordEntryGateMessage => + 'Enter your login password to manage protection'; + + @override + String get passwordEntryGenericPasswordHint => 'Password'; + + @override + String get passwordEntrySetTitle => 'Password is set'; + + @override + String passwordEntryHintPrefix(String hint) { + return 'Hint: $hint'; + } + + @override + String get passwordEntryChangePasswordAction => 'Change password'; + + @override + String get passwordEntryChangeEmailAction => 'Change email'; + + @override + String get passwordEntryDeleteAction => 'Delete password'; + + @override + String get passwordEntryMinPasswordError => + 'Password must be at least 6 characters'; + + @override + String get passwordEntryMismatchError => 'Passwords do not match'; + + @override + String get passwordEntryInvalidEmailError => 'Enter a valid email'; + + @override + String get passwordEntryInvalidCodeError => 'Enter the 6-digit code'; + + @override + String get passwordEntrySetupTitle => 'Password setup'; + + @override + String get passwordEntryStepPassword => 'Password'; + + @override + String get passwordEntryStepHint => 'Hint'; + + @override + String get passwordEntryStepEmail => 'Email'; + + @override + String get passwordEntryStepCode => 'Code'; + + @override + String get passwordEntryChoosePassword => 'Choose a password'; + + @override + String get passwordEntryMinCharsHint => 'At least 6 characters'; + + @override + String get passwordEntryEnterPasswordHint => 'Enter password'; + + @override + String get passwordEntryEnterAgain => 'Enter the password again'; + + @override + String get passwordEntryRepeatHint => 'Repeat password'; + + @override + String get passwordEntryHintForPassword => 'Password hint'; + + @override + String get passwordEntryOptional => 'Optional'; + + @override + String get passwordEntryHintFieldHint => 'Enter a hint (optional)'; + + @override + String get passwordEntryLinkEmail => 'Link an email'; + + @override + String get passwordEntryEmailPurpose => 'For password recovery. Optional'; + + @override + String get passwordEntryEmailHintOptional => 'example@mail.com (optional)'; + + @override + String get passwordEntryEnterCode => 'Enter the code'; + + @override + String passwordEntryCodeSentTo(String email) { + return 'Code sent to $email'; + } + + @override + String get passwordEntryChangedNotif => 'Password changed'; + + @override + String get passwordEntryNewPassword => 'New password'; + + @override + String get passwordEntryNewPasswordHint => 'Enter new password'; + + @override + String get passwordEntryRepeatNewPasswordHint => 'Repeat new password'; + + @override + String get passwordEntryEmailChangedNotif => 'Email changed'; + + @override + String get passwordEntryNewEmail => 'New email'; + + @override + String get passwordEntryEmailHint => 'example@mail.com'; + + @override + String get passwordEntryRemovedNotif => 'Password removed'; + + @override + String get passwordEntryRemoveTitle => 'Remove password'; + + @override + String get passwordEntryRemoveWarning => + 'Warning! Removing the password will weaken your account\'s protection.'; + + @override + String get cloudStorageNoActiveProfile => 'No active profile'; + + @override + String get cloudStorageSetupFailed => 'Could not create environment'; + + @override + String get cloudStorageTitle => 'Cloud storage'; + + @override + String get cloudStorageNotConfiguredTitle => + 'Cloud storage environment isn\'t set up'; + + @override + String get cloudStorageNotConfiguredSubtitle => 'Let\'s start? It\'s quick.'; + + @override + String get cloudStorageStart => 'Start'; + + @override + String cloudStorageUploadingPercent(String percent) { + return 'Uploading $percent%'; + } + + @override + String get cloudStorageStartUploadHint => + 'Start an upload to see the progress bar'; + + @override + String get cloudStorageEmptyTitle => 'No cloud files yet...'; + + @override + String get cloudStorageEmptySubtitle => 'Add one?'; + + @override + String get cloudStorageUpload => 'Upload'; + + @override + String get cloudStorageFromFile => 'From file'; + + @override + String get cloudStorageById => 'By ID'; + + @override + String get cloudStorageFileIdLabel => 'File ID'; + + @override + String get cloudStorageSizeLabel => 'Size'; + + @override + String get cloudStorageNoLinkYet => 'No link yet. Create one.'; + + @override + String cloudStorageLinkExpiresIn(String time) { + return 'Link expires in $time'; + } + + @override + String get cloudStorageLinkCopied => 'Link copied'; + + @override + String get cloudStorageInvalidId => 'Invalid ID'; + + @override + String get cloudStorageSendError => 'Send error'; + + @override + String get cloudStorageSendByIdTitle => 'Send by ID'; + + @override + String get cloudStorageSend => 'Send'; + + @override + String get digitalIdGosuslugiLinkUnavailable => + 'Linking Gosuslugi isn\'t available on this platform. Do this in the mobile app.'; + + @override + String get digitalIdGosuslugiLinkFailed => 'Could not get the Gosuslugi link'; + + @override + String get digitalIdGosuslugiTitle => 'Gosuslugi'; + + @override + String get digitalIdDocsUnavailable => + 'Documents aren\'t available yet. Try again later.'; + + @override + String get digitalIdTitle => 'Digital ID'; + + @override + String get digitalIdNotConfiguredTitle => 'Digital ID isn\'t set up'; + + @override + String get digitalIdLinkGosuslugiHint => + 'Link your Gosuslugi account so your documents appear in Digital ID. The phone number in MAX must match the one in your Gosuslugi profile.'; + + @override + String get digitalIdLinkOrRefreshHint => + 'Link Gosuslugi to get access to your documents, or refresh the page if you\'ve already set up Digital ID.'; + + @override + String get digitalIdLoadDocuments => 'Load documents'; + + @override + String get digitalIdLinkGosuslugiButton => 'Link Gosuslugi'; + + @override + String get digitalIdGosuslugiProfileFallback => 'Gosuslugi profile'; + + @override + String digitalIdBirthDate(String date) { + return 'Date of birth: $date'; + } + + @override + String get digitalIdPersonalDataTitle => 'Personal data'; + + @override + String get digitalIdSnilsLabel => 'SNILS'; + + @override + String get digitalIdInnLabel => 'INN'; + + @override + String get digitalIdBirthPlaceLabel => 'Place of birth'; + + @override + String get digitalIdRegistrationAddressLabel => 'Registration address'; + + @override + String get digitalIdDocumentsTitle => 'Documents'; + + @override + String digitalIdDocSeries(String series) { + return 'series $series'; + } + + @override + String digitalIdDocNumber(String number) { + return 'No. $number'; + } + + @override + String get digitalIdPassesTitle => 'Passes'; + + @override + String digitalIdCardInn(String inn) { + return 'INN $inn'; + } + + @override + String get digitalIdBiometryConfigured => 'Biometrics set up on this device'; + + @override + String get digitalIdBiometryNotConfigured => + 'Biometrics not set up on this device'; + + @override + String get digitalIdDocPassport => 'Russian passport'; + + @override + String get digitalIdDocOms => 'Health insurance policy (OMS)'; + + @override + String get digitalIdDocDriverLicense => 'Driver\'s license'; + + @override + String get digitalIdDocVehicleSts => 'Vehicle registration certificate (STS)'; + + @override + String get digitalIdDocChildBirthCert => 'Birth certificate'; + + @override + String get digitalIdDocPensionCert => 'Pension certificate'; + + @override + String get digitalIdDocDisabledCert => 'Disability certificate'; + + @override + String get digitalIdDocLargeFamilyCert => 'Large family certificate'; + + @override + String get digitalIdDocStudentTicket => 'Student ID'; + + @override + String get digitalIdDocChildInn => 'Child\'s INN'; + + @override + String get digitalIdDocChildOms => 'Child\'s health insurance policy (OMS)'; + + @override + String get attachSheetGallery => 'Gallery'; + + @override + String get attachSheetPoll => 'Poll'; + + @override + String get attachSheetCameraComingSoon => 'Camera is coming soon'; + + @override + String get attachSheetSendFileTitle => 'Send a file'; + + @override + String get attachSheetSendFileSubtitle => + 'A document, archive, or any other file'; + + @override + String get attachSheetChooseFileButton => 'Choose file'; + + @override + String get attachSheetShareLocationTitle => 'Share location'; + + @override + String get attachSheetShareLocationSubtitle => 'Send your current location'; + + @override + String get attachSheetSendLocationButton => 'Send location'; + + @override + String get attachSheetCreatePoll => 'Create poll'; + + @override + String get attachSheetCreatePollSubtitle => 'A question with answer options'; + + @override + String get attachSheetNoImagesFound => 'No images found'; + + @override + String get attachSheetLimitedAccessInfo => 'Not all photos are accessible'; + + @override + String get attachSheetSectionInProgress => 'Section under development'; + + @override + String get attachSheetNoGalleryAccessTitle => 'No access to the gallery'; + + @override + String get attachSheetNoGalleryAccessSubtitle => + 'Allow access to photos to pick them from here'; + + @override + String get attachSheetAllow => 'Allow'; + + @override + String get attachSheetSettings => 'Settings'; + + @override + String get attachSheetAddCaptionHint => 'Add a caption...'; + + @override + String get attachSheetCamera => 'Camera'; + + @override + String get photoEditorApplyFailed => 'Couldn\'t apply'; + + @override + String get photoEditorFlipTooltip => 'Flip'; + + @override + String get photoEditorRotateTooltip => 'Rotate'; + + @override + String get photoEditorCancel => 'CANCEL'; + + @override + String get photoEditorReset => 'RESET'; + + @override + String get photoEditorDone => 'DONE'; + + @override + String get photoEditorTextDialogTitle => 'Text'; + + @override + String get photoEditorTextDialogHint => 'Enter text'; + + @override + String get photoEditorOk => 'OK'; + + @override + String get photoEditorApplyChangesFailed => 'Couldn\'t apply changes'; + + @override + String get photoEditorClearAll => 'Clear all'; + + @override + String get photoEditorAddText => 'Add text'; + + @override + String get photoEditorTabDraw => 'DRAW'; + + @override + String get photoEditorTabStickers => 'STICKERS'; + + @override + String get photoEditorTabText => 'TEXT'; + + @override + String get photoEditorChannelAll => 'All'; + + @override + String get photoEditorChannelRed => 'Red'; + + @override + String get photoEditorChannelGreen => 'Green'; + + @override + String get photoEditorChannelBlue => 'Blue'; + + @override + String get photoEditorEnhance => 'Enhance'; + + @override + String get photoEditorExposure => 'Exposure'; + + @override + String get photoEditorContrast => 'Contrast'; + + @override + String get photoEditorSaturation => 'Saturation'; + + @override + String get photoEditorWarmth => 'Warmth'; + + @override + String get photoEditorVignette => 'Vignette'; + + @override + String get photoEditorBlurOff => 'Off'; + + @override + String get photoEditorBlurRadial => 'Radial'; + + @override + String get photoEditorBlurLinear => 'Linear'; + + @override + String get fontSettingsInvalidInput => 'Enter a font link or name'; + + @override + String fontSettingsFontNotFound(String name) { + return 'Font \"$name\" not found or no network'; + } + + @override + String fontSettingsFontAdded(String name) { + return 'Font \"$name\" added'; + } + + @override + String fontSettingsFontRemoved(String name) { + return 'Font \"$name\" removed'; + } + + @override + String get fontSettingsAddFontTitle => 'Add font'; + + @override + String get fontSettingsAddFontDescription => + 'Paste a Google Fonts link or font name'; + + @override + String get fontSettingsAddFontConfirm => 'Add'; + + @override + String get fontSettingsTitle => 'Fonts'; + + @override + String get fontSettingsSectionFont => 'Font'; + + @override + String get fontSettingsLoading => 'Loading…'; + + @override + String get fontSettingsSectionFontSize => 'Font size'; + + @override + String get fontSettingsPreviewLabel => 'PREVIEW'; + + @override + String get fontSettingsReset => 'Reset'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 12a2e88..fdc2e2a 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -523,4 +523,1440 @@ class AppLocalizationsRu extends AppLocalizations { @override String get registrationChooseAvatar => 'Выберите аватар'; + + @override + String get msgActionsCopy => 'Копировать'; + + @override + String get msgActionsEdit => 'Изменить'; + + @override + String get msgActionsReply => 'Ответить'; + + @override + String get msgActionsForward => 'Переслать'; + + @override + String get msgActionsMarkUnread => 'Непрочитанное'; + + @override + String get msgActionsEditHistory => 'История изменений'; + + @override + String get msgActionsReport => 'Пожаловаться'; + + @override + String get msgActionsDelete => 'Удалить'; + + @override + String get msgActionsCopied => 'Скопировано'; + + @override + String get msgActionsLoadReasonsFailed => 'Не удалось загрузить причины'; + + @override + String get msgActionsCurrentVersion => 'текущая версия'; + + @override + String msgActionsCurrentVersionWithDate(String date) { + return 'текущая версия · $date'; + } + + @override + String get msgActionsNoText => '(без текста)'; + + @override + String notificationsSaveFailed(String error) { + return 'Не удалось сохранить: $error'; + } + + @override + String get notificationsFkmAlreadyHasFcm => 'А зачем? У тебя уже FCM.'; + + @override + String get notificationsFkmDownloadFcm => 'Скачай лучше FCM-версию.'; + + @override + String get notificationsTitle => 'Уведомления'; + + @override + String get notificationsFkmSectionTitle => 'FKM'; + + @override + String get notificationsFkmEnableLabel => 'Включить уведомления'; + + @override + String get notificationsFkmEnableSubtitle => + 'Для работы FKM уведомлений, приложению понадобится держать уведомление в шторке.'; + + @override + String get notificationsMainSectionTitle => 'Уведомления'; + + @override + String get notificationsAllLabel => 'Все уведомления'; + + @override + String get notificationsNewSectionTitle => 'Все новые уведомления'; + + @override + String get notificationsPreviewLabel => 'Предпросмотр сообщений'; + + @override + String get notificationsSoundLabel => 'Звук'; + + @override + String get notificationsAdditionalSectionTitle => 'Дополнительно'; + + @override + String get notificationsCallsLabel => 'Уведомления о звонках'; + + @override + String get notificationsNewContactsLabel => 'Уведомления от новых контактов'; + + @override + String get notificationsHapticsSectionTitle => 'Тактильная отдача'; + + @override + String get notificationsHapticsLabel => 'Тактильная отдача'; + + @override + String get notificationsHapticsSubtitle => + 'Виброотклик при действиях в приложении'; + + @override + String devicesLoadFailed(String error) { + return 'Ошибка загрузки: $error'; + } + + @override + String get devicesQrLinkDialogTitle => 'Ссылка из QR'; + + @override + String get devicesQrLinkDialogHint => 'Вставьте содержимое QR-кода'; + + @override + String get devicesAllTerminated => 'Все сессии завершены'; + + @override + String devicesGenericError(String error) { + return 'Ошибка: $error'; + } + + @override + String devicesIpLookupError(String error) { + return 'Ошибка IP: $error'; + } + + @override + String get devicesTitle => 'Устройства'; + + @override + String get devicesPromoTitle => 'Устройства в KOMET'; + + @override + String get devicesPromoSubtitle => 'Кто имеет доступ к вашему аккаунту?'; + + @override + String get devicesScanQrButton => 'Сканировать QR'; + + @override + String get devicesCurrentSuffix => ' (текущая)'; + + @override + String get devicesOnlineStatus => 'В сети'; + + @override + String get devicesTerminateOthersButton => + 'Завершить все сессии, кроме текущей'; + + @override + String get devicesMobileNetworkLabel => 'Мобильная сеть'; + + @override + String get devicesProxyDetectedLabel => 'Обнаружен прокси/VPN'; + + @override + String get themeSettingsTitle => 'Тема'; + + @override + String get themeSettingsModeCardTitle => 'Режим темы'; + + @override + String get themeSettingsModeCardSubtitle => + 'Светлая, тёмная или авто-переключение'; + + @override + String get themeSettingsModeSystem => 'Системная'; + + @override + String get themeSettingsModeLight => 'Светлая'; + + @override + String get themeSettingsModeDark => 'Тёмная'; + + @override + String get themeSettingsModeSchedule => 'По расписанию'; + + @override + String get themeSettingsAmoledTitle => 'AMOLED-чёрный'; + + @override + String get themeSettingsAmoledSubtitle => + 'Чистый чёрный фон для OLED-экранов'; + + @override + String get themeSettingsScheduleTitle => 'Расписание'; + + @override + String get themeSettingsScheduleSubtitleEnabled => + 'Когда автоматически включается тёмная тема'; + + @override + String get themeSettingsScheduleSubtitleDisabled => + 'Доступно в режиме «По расписанию»'; + + @override + String get themeSettingsScheduleDarkFrom => 'Тёмная с'; + + @override + String get themeSettingsScheduleLightFrom => 'Светлая с'; + + @override + String get appearanceTitle => 'Внешний вид'; + + @override + String get appearanceVisualStyleTitle => 'Визуал'; + + @override + String get appearanceVisualStyleSubtitle => + 'Material You или объёмные Glossy-капсулы'; + + @override + String get appearanceVisualStyleMaterialYou => 'Material You'; + + @override + String get appearanceVisualStyleGlossy => 'Glossy'; + + @override + String get appearanceChatChromeTitle => 'Элементы экрана чата'; + + @override + String get appearanceChatChromeSubtitle => + 'Фон панелей сверху и снизу: цвет, размытие или прозрачно. При размытии и прозрачности сообщения заходят под панели'; + + @override + String get appearanceChatChromeColor => 'Цвет'; + + @override + String get appearanceChatChromeBlur => 'Блюр'; + + @override + String get appearanceChatChromeNone => 'Нет'; + + @override + String get appearanceGradientTitle => 'Градиент'; + + @override + String get appearanceGradientSubtitle => 'Объём и блики в Glossy-капсулах'; + + @override + String get appearanceAccentColorTitle => 'Акцентный цвет'; + + @override + String get appearanceAccentColorSystem => 'Системный'; + + @override + String get appearanceAccentColorSubtitle => + 'Основной цвет интерфейса и пузырей'; + + @override + String get appearanceAccentColorSystemActive => 'Системный цвет активен'; + + @override + String get appearanceAccentColorReset => 'Сбросить на системный'; + + @override + String get appearanceBubbleShapeTitle => 'Форма сообщения'; + + @override + String get appearanceBubbleShapeSubtitle => 'Скругление углов пузырей'; + + @override + String get appearanceBubbleShapeMobile => 'TG Mobile'; + + @override + String get appearanceBubbleShapeDesktop => 'TG Desktop'; + + @override + String get appearanceBubbleBehaviorTitle => 'Поведение сообщения'; + + @override + String get appearanceBubbleBehaviorSubtitle => + 'Меняется ли форма пузыря по соседям в группе'; + + @override + String get appearanceBubbleBehaviorMutable => 'Изменяемая'; + + @override + String get appearanceBubbleBehaviorImmutable => 'Неизменяемая'; + + @override + String get appearancePreviewHello => 'Привет!'; + + @override + String get appearancePreviewHowIsIt => 'Как тебе?'; + + @override + String get appearancePreviewHmm => 'хм...'; + + @override + String get appearancePreviewNotBad => 'Вполне неплохо!'; + + @override + String get callKometDetectedNotification => + 'Этот человек использует Komet! :3'; + + @override + String get callStatusConnecting => 'Соединение'; + + @override + String get callGroupConnecting => 'Соединение…'; + + @override + String get callGroupWaitingParticipants => 'Ожидание участников…'; + + @override + String get callParticipantYou => 'Вы'; + + @override + String get callParticipantFallback => 'Участник'; + + @override + String get callTooltipMinimize => 'Свернуть'; + + @override + String get callTooltipKometHub => 'Komet'; + + @override + String get callInfoTitle => 'О звонке'; + + @override + String get callPeerMicOff => 'Микрофон выключен'; + + @override + String get callPeerCameraOn => 'Камера включена'; + + @override + String get callUnknownName => 'Неизвестный'; + + @override + String get callIncoming => 'Входящий звонок'; + + @override + String get callStatusRinging => 'Вызов'; + + @override + String get callStatusEnded => 'Звонок завершён'; + + @override + String get callDecline => 'Отклонить'; + + @override + String get callAccept => 'Принять'; + + @override + String get callSpeaker => 'Динамик'; + + @override + String get callVideoLabel => 'Видео'; + + @override + String get callScreenLabel => 'Экран'; + + @override + String get callUnmute => 'Вкл. звук'; + + @override + String get callMute => 'Выкл. звук'; + + @override + String get callEndButton => 'Завершить'; + + @override + String get callInfoClient => 'Клиент'; + + @override + String get callInfoPlatform => 'Платформа'; + + @override + String get callInfoCountry => 'Страна'; + + @override + String get callInfoInContacts => 'В контактах'; + + @override + String get callValueYes => 'да'; + + @override + String get callValueNo => 'нет'; + + @override + String get callInfoPeerIp => 'IP собеседника'; + + @override + String get callInfoPeerNetwork => 'Сеть собеседника'; + + @override + String get callInfoPath => 'Путь соединения'; + + @override + String get callInfoCodec => 'Кодек'; + + @override + String get callInfoServer => 'Сервер'; + + @override + String get callInfoTopology => 'Топология'; + + @override + String get callInfoStatus => 'Статус'; + + @override + String get callStatusValueConnected => 'соединён'; + + @override + String get callStatusValueConnecting => 'соединение…'; + + @override + String get callInfoPeerMic => 'Микрофон собеседника'; + + @override + String get callMicValueOn => 'включён'; + + @override + String get callMicValueOff => 'выключен'; + + @override + String get callInfoPeerCamera => 'Камера собеседника'; + + @override + String get callCameraValueOn => 'включена'; + + @override + String get callCameraValueOff => 'выключена'; + + @override + String get callInfoVideoTrack => 'Видео-дорожка'; + + @override + String callInfoVideoTrackPresent(int count) { + return 'есть ($count)'; + } + + @override + String get callInfoVideoSize => 'Размер видео'; + + @override + String get callInfoFrameRendering => 'Отрисовка кадров'; + + @override + String get callBadgeEncrypted => 'Зашифрован'; + + @override + String get callBadgeAudio => 'Аудио'; + + @override + String get callBadgeRecording => 'Запись'; + + @override + String get callBadgeNoiseSuppression => 'Шумоподавление'; + + @override + String get callBadgeAnimoji => 'Анимодзи'; + + @override + String get callInfoNoDataYet => 'Данные появятся после соединения…'; + + @override + String get hubTitleMenu => 'Komet'; + + @override + String get hubChatPageTitle => 'Анонимный чат'; + + @override + String get hubGamesTitle => 'Игры'; + + @override + String get hubCheckersTitle => 'Шашки'; + + @override + String get hubChatTileTitle => 'Чат'; + + @override + String get hubChatTileSubtitle => 'Анонимные сообщения'; + + @override + String get hubGamesTileSubtitle => 'Сыграть с собеседником'; + + @override + String get hubCheckersTileSubtitle => 'Русские шашки'; + + @override + String get hubMoreSoonTitle => 'Скоро ещё…'; + + @override + String get hubMoreSoonSubtitle => 'В разработке'; + + @override + String get hubChatPrivacyNote => + 'Напрямую через звонок, нигде не сохраняется'; + + @override + String get hubChatEmpty => 'Сообщений пока нет'; + + @override + String get hubChatInputHint => 'Сообщение…'; + + @override + String get hubCheckersRestart => 'Заново'; + + @override + String get hubCheckersYouWhite => 'Вы играете белыми'; + + @override + String get hubCheckersYouBlack => 'Вы играете чёрными'; + + @override + String get hubCheckersWon => 'Вы выиграли 🎉'; + + @override + String get hubCheckersLost => 'Вы проиграли'; + + @override + String get hubCheckersYourMove => 'Ваш ход'; + + @override + String get hubCheckersOpponentMove => 'Ход соперника…'; + + @override + String get scheduledPickTimeTitle => 'Когда отправить'; + + @override + String get scheduledEditTitle => 'Изменить'; + + @override + String get scheduledMessageTextHint => 'Текст сообщения'; + + @override + String get scheduledSave => 'Сохранить'; + + @override + String get scheduledEditFailed => 'Не удалось изменить сообщение'; + + @override + String get scheduledDeleteConfirmTitle => + 'Удалить запланированное сообщение?'; + + @override + String get scheduledDeleteConfirmMessage => 'Сообщение не будет отправлено.'; + + @override + String get scheduledDeleteConfirmLabel => 'Удалить'; + + @override + String get scheduledDeleteFailed => 'Не удалось удалить сообщение'; + + @override + String get scheduledAppBarTitle => 'Отложенные'; + + @override + String get scheduledEmpty => 'Нет отложенных сообщений'; + + @override + String get scheduledAttachPhoto => 'Фото'; + + @override + String get scheduledAttachVideo => 'Видео'; + + @override + String get scheduledAttachVoice => 'Голосовое'; + + @override + String get scheduledAttachFile => 'Файл'; + + @override + String get scheduledAttachLocation => 'Геопозиция'; + + @override + String get scheduledAttachForwarded => 'Переслано'; + + @override + String get scheduledAttachGeneric => 'Вложение'; + + @override + String contactProfileLoadError(String error) { + return 'Ошибка: $error'; + } + + @override + String get contactProfileBot => 'Бот'; + + @override + String get contactProfileOnline => 'В сети'; + + @override + String get contactProfileRecentlyActive => 'Был(-а) недавно'; + + @override + String get contactProfileActionChat => 'Чат'; + + @override + String get contactProfileActionSound => 'Звук'; + + @override + String get contactProfileActionCall => 'Звонок'; + + @override + String get contactProfileInfoPhone => 'Телефон'; + + @override + String get contactProfileInfoCountry => 'Страна'; + + @override + String get contactProfileInfoGender => 'Пол'; + + @override + String get contactProfileInfoRegistration => 'Регистрация'; + + @override + String get contactProfileInfoUpdated => 'Обновлён'; + + @override + String get contactProfileInfoAccountStatus => 'Статус аккаунта'; + + @override + String get contactProfileInfoDescription => 'Описание'; + + @override + String get contactProfileInfoLink => 'Ссылка'; + + @override + String get contactProfileInfoFlags => 'Флаги'; + + @override + String nfcPeerNameFallback(String id) { + return 'Контакт #$id'; + } + + @override + String get nfcPeerFirstNameFallback => 'Контакт'; + + @override + String get nfcContactAdded => 'Контакт добавлен'; + + @override + String nfcAddFailed(String error) { + return 'Не удалось добавить: $error'; + } + + @override + String get nfcReasonBluetoothOff => 'Включите Bluetooth и попробуйте снова'; + + @override + String get nfcReasonPermission => 'Нужны разрешения Bluetooth для обмена'; + + @override + String get nfcReasonDefault => 'Не удалось установить соединение'; + + @override + String get nfcSheetTitle => 'Обмен контактом'; + + @override + String get nfcUnsupported => 'NFC недоступен на этом устройстве'; + + @override + String get nfcDisabled => + 'Включите NFC в настройках телефона и попробуйте снова'; + + @override + String get nfcScanningTitle => 'Поднесите телефоны друг к другу'; + + @override + String get nfcScanningSubtitle => + 'Оба устройства должны держать этот экран открытым'; + + @override + String get nfcExchangingTitle => 'Идёт обмен контактами…'; + + @override + String get nfcExchangingSubtitle => 'Почти готово'; + + @override + String nfcPeerIdFallback(String id) { + return 'ID $id'; + } + + @override + String get nfcAdded => 'Добавлено'; + + @override + String get nfcAddContact => 'Добавить контакт'; + + @override + String get chatInfoTabGeneralChats => 'Общие чаты'; + + @override + String get chatInfoTabMedia => 'Медиа'; + + @override + String get chatInfoTabFiles => 'Файлы'; + + @override + String get chatInfoTabVoice => 'Голосовые'; + + @override + String get chatInfoTabLinks => 'Ссылки'; + + @override + String get chatInfoTabMembers => 'Участники'; + + @override + String get chatInfoEmptyGeneralChats => 'Нет общих чатов'; + + @override + String get chatInfoEmptyMedia => 'Нет медиа'; + + @override + String get chatInfoEmptyFiles => 'Нет файлов'; + + @override + String get chatInfoEmptyVoice => 'Нет голосовых'; + + @override + String get chatInfoEmptyLinks => 'Нет ссылок'; + + @override + String chatInfoOnlineOfTotal(String online, String total) { + return '$online из $total в сети'; + } + + @override + String get chatInfoActionLeave => 'Покинуть'; + + @override + String get chatInfoBio => 'О себе'; + + @override + String get chatInfoInviteLink => 'Ссылка-приглашение'; + + @override + String get chatInfoCollapse => 'Свернуть'; + + @override + String get chatInfoShowMore => 'Ещё'; + + @override + String get chatInfoAddMember => 'Добавить участника'; + + @override + String get chatInfoRoleOwner => 'владелец'; + + @override + String get chatInfoRoleAdmin => 'Адмін'; + + @override + String get chatInfoNoData => 'Нет данных'; + + @override + String get chatInfoHideExtra => 'Скрыть'; + + @override + String get chatInfoShowMoreExtra => 'Подробнее'; + + @override + String get chatInfoRowId => 'ID чата'; + + @override + String get chatInfoRowCreated => 'Создан'; + + @override + String get chatInfoRowModified => 'Изменён'; + + @override + String get chatInfoRowMembersCount => 'Участников'; + + @override + String get chatInfoRowOwner => 'Владелец'; + + @override + String get chatInfoRowCreatedGroup => 'Создана'; + + @override + String get chatInfoRowJoined => 'Вступил'; + + @override + String get chatInfoRowModifiedGroup => 'Изменена'; + + @override + String get chatInfoRowHasBots => 'Есть боты'; + + @override + String get chatInfoRowBlockedCount => 'Заблокировано'; + + @override + String get chatInfoRowOfficialGroup => 'Официальная'; + + @override + String get chatInfoRowSignAdmin => 'Подпись адм.'; + + @override + String get chatInfoRowSubscribersCount => 'Подписчиков'; + + @override + String get chatInfoRowOfficialChannel => 'Официальный'; + + @override + String get chatInfoRowComments => 'Комментарии'; + + @override + String get chatInfoRowRkn => 'РКН'; + + @override + String get chatInfoRowOnlyAdmin => 'Только адм.'; + + @override + String get securityTitle => 'Безопасность'; + + @override + String securityLoadError(String error) { + return 'Ошибка загрузки: $error'; + } + + @override + String securitySaveError(String error) { + return 'Ошибка сохранения: $error'; + } + + @override + String get securityPrivacyAll => 'Все'; + + @override + String get securityPrivacyContacts => 'Мои контакты'; + + @override + String get securityPrivacyNobody => 'Никто'; + + @override + String get securityFamilyProtection => 'Семейная защита'; + + @override + String get securityEnabledFem => 'Включена'; + + @override + String get securityDisabledFem => 'Отключена'; + + @override + String get securityPasswordTitle => 'Пароль для входа'; + + @override + String get securityEnabledMasc => 'Включён'; + + @override + String get securityDisabledMasc => 'Отключён'; + + @override + String get securityModeTitle => 'Безопасный режим'; + + @override + String get securityModeSubtitle => 'Скрывает личную информацию'; + + @override + String get securitySettingsUnavailable => + 'Изменение настроек пока недоступно'; + + @override + String get securityFindByPhone => 'Найти меня по номеру'; + + @override + String get securityWhoCanCall => 'Кто может мне звонить'; + + @override + String get securityWhoCanInvite => 'Кто может приглашать в чаты'; + + @override + String get securityShowContact => 'Показывать контакт'; + + @override + String get securityContentSafe => 'Безопасный'; + + @override + String get securityContentAll => 'Весь'; + + @override + String get securityShowOnlineStatus => 'Видеть статус «в сети»'; + + @override + String get securityShowMyNumber => 'Видеть мой номер'; + + @override + String get securityConfirmTitle => 'Вы уверены?'; + + @override + String get securityHiddenStatusWarning => + 'Вы не сможете видеть статусы посещения других пользователей.'; + + @override + String get securityConfidentialityHeader => 'КОНФИДЕНЦИАЛЬНОСТЬ'; + + @override + String get securityReadReceipts => 'Галочки «Прочитано»'; + + @override + String get securityAltKeyboard => 'Альтернативная клавиатура'; + + @override + String get securityUnsafeFiles => 'Принимать опасные файлы'; + + @override + String get securityAudioTranscription => 'Транскрибация аудио'; + + @override + String get securityBlacklistTitle => 'Чёрный список'; + + @override + String securityBlacklistNotification(String count) { + return 'Чёрный список: $count контактов'; + } + + @override + String get passwordEntryWrongPassword => 'Неверный пароль'; + + @override + String get passwordEntryConfirmTitle => 'Подтвердите пароль'; + + @override + String get passwordEntryCurrentPasswordHint => 'Текущий пароль'; + + @override + String get passwordEntryContinue => 'Продолжить'; + + @override + String get passwordEntryNotSetTitle => 'Пароль не установлен'; + + @override + String get passwordEntry2faSubtitle => 'Двухфакторная аутентификация'; + + @override + String get passwordEntrySetupAction => 'Установить пароль'; + + @override + String get passwordEntryGateMessage => + 'Введите пароль для входа, чтобы управлять защитой'; + + @override + String get passwordEntryGenericPasswordHint => 'Пароль'; + + @override + String get passwordEntrySetTitle => 'Пароль установлен'; + + @override + String passwordEntryHintPrefix(String hint) { + return 'Подсказка: $hint'; + } + + @override + String get passwordEntryChangePasswordAction => 'Изменить пароль'; + + @override + String get passwordEntryChangeEmailAction => 'Изменить почту'; + + @override + String get passwordEntryDeleteAction => 'Удалить пароль'; + + @override + String get passwordEntryMinPasswordError => + 'Пароль должен быть минимум 6 символов'; + + @override + String get passwordEntryMismatchError => 'Пароли не совпадают'; + + @override + String get passwordEntryInvalidEmailError => 'Введите корректный email'; + + @override + String get passwordEntryInvalidCodeError => 'Введите 6-значный код'; + + @override + String get passwordEntrySetupTitle => 'Установка пароля'; + + @override + String get passwordEntryStepPassword => 'Пароль'; + + @override + String get passwordEntryStepHint => 'Подсказка'; + + @override + String get passwordEntryStepEmail => 'Почта'; + + @override + String get passwordEntryStepCode => 'Код'; + + @override + String get passwordEntryChoosePassword => 'Придумайте пароль'; + + @override + String get passwordEntryMinCharsHint => 'Минимум 6 символов'; + + @override + String get passwordEntryEnterPasswordHint => 'Введите пароль'; + + @override + String get passwordEntryEnterAgain => 'Введите пароль ещё раз'; + + @override + String get passwordEntryRepeatHint => 'Повторите пароль'; + + @override + String get passwordEntryHintForPassword => 'Подсказка для пароля'; + + @override + String get passwordEntryOptional => 'Необязательно'; + + @override + String get passwordEntryHintFieldHint => 'Введите подсказку (необязательно)'; + + @override + String get passwordEntryLinkEmail => 'Привяжите email'; + + @override + String get passwordEntryEmailPurpose => + 'Для восстановления пароля. Необязательно'; + + @override + String get passwordEntryEmailHintOptional => + 'example@mail.ru (необязательно)'; + + @override + String get passwordEntryEnterCode => 'Введите код'; + + @override + String passwordEntryCodeSentTo(String email) { + return 'Код отправлен на $email'; + } + + @override + String get passwordEntryChangedNotif => 'Пароль изменён'; + + @override + String get passwordEntryNewPassword => 'Новый пароль'; + + @override + String get passwordEntryNewPasswordHint => 'Введите новый пароль'; + + @override + String get passwordEntryRepeatNewPasswordHint => 'Повторите новый пароль'; + + @override + String get passwordEntryEmailChangedNotif => 'Почта изменена'; + + @override + String get passwordEntryNewEmail => 'Новая почта'; + + @override + String get passwordEntryEmailHint => 'example@mail.ru'; + + @override + String get passwordEntryRemovedNotif => 'Пароль удалён'; + + @override + String get passwordEntryRemoveTitle => 'Удаление пароля'; + + @override + String get passwordEntryRemoveWarning => + 'Внимание! После удаления пароля защита вашего аккаунта ослабнет.'; + + @override + String get cloudStorageNoActiveProfile => 'Нет активного профиля'; + + @override + String get cloudStorageSetupFailed => 'Не удалось создать среду'; + + @override + String get cloudStorageTitle => 'Облачное хранилище'; + + @override + String get cloudStorageNotConfiguredTitle => + 'Среда для облачного хранилища не настроена'; + + @override + String get cloudStorageNotConfiguredSubtitle => 'Начнем? Это быстро.'; + + @override + String get cloudStorageStart => 'Начать'; + + @override + String cloudStorageUploadingPercent(String percent) { + return 'Загрузка $percent%'; + } + + @override + String get cloudStorageStartUploadHint => + 'Начните загрузку для прогресс-бара'; + + @override + String get cloudStorageEmptyTitle => 'Облачных файлов пока нет...'; + + @override + String get cloudStorageEmptySubtitle => 'Добавите?'; + + @override + String get cloudStorageUpload => 'Загрузить'; + + @override + String get cloudStorageFromFile => 'С файла'; + + @override + String get cloudStorageById => 'По ID'; + + @override + String get cloudStorageFileIdLabel => 'ID файла'; + + @override + String get cloudStorageSizeLabel => 'Размер'; + + @override + String get cloudStorageNoLinkYet => 'Ссылки пока нет. Создайте.'; + + @override + String cloudStorageLinkExpiresIn(String time) { + return 'Ссылка истечет $time'; + } + + @override + String get cloudStorageLinkCopied => 'Ссылка скопирована'; + + @override + String get cloudStorageInvalidId => 'Неверный ID'; + + @override + String get cloudStorageSendError => 'Ошибка отправки'; + + @override + String get cloudStorageSendByIdTitle => 'Отправить по ID'; + + @override + String get cloudStorageSend => 'Отправить'; + + @override + String get digitalIdGosuslugiLinkUnavailable => + 'Привязка Госуслуг недоступна на этой платформе. Сделайте это в приложении на телефоне.'; + + @override + String get digitalIdGosuslugiLinkFailed => + 'Не удалось получить ссылку Госуслуг'; + + @override + String get digitalIdGosuslugiTitle => 'Госуслуги'; + + @override + String get digitalIdDocsUnavailable => + 'Документы пока недоступны. Попробуйте позже.'; + + @override + String get digitalIdTitle => 'Цифровой ID'; + + @override + String get digitalIdNotConfiguredTitle => 'Цифровой ID не настроен'; + + @override + String get digitalIdLinkGosuslugiHint => + 'Привяжите аккаунт Госуслуг, чтобы документы появились в Цифровом ID. Номер телефона в MAX должен совпадать с номером в профиле Госуслуг.'; + + @override + String get digitalIdLinkOrRefreshHint => + 'Привяжите Госуслуги, чтобы получить доступ к документам, или обновите страницу, если уже настраивали Цифровой ID.'; + + @override + String get digitalIdLoadDocuments => 'Загрузить документы'; + + @override + String get digitalIdLinkGosuslugiButton => 'Привязать Госуслуги'; + + @override + String get digitalIdGosuslugiProfileFallback => 'Профиль Госуслуг'; + + @override + String digitalIdBirthDate(String date) { + return 'Дата рождения: $date'; + } + + @override + String get digitalIdPersonalDataTitle => 'Личные данные'; + + @override + String get digitalIdSnilsLabel => 'СНИЛС'; + + @override + String get digitalIdInnLabel => 'ИНН'; + + @override + String get digitalIdBirthPlaceLabel => 'Место рождения'; + + @override + String get digitalIdRegistrationAddressLabel => 'Адрес регистрации'; + + @override + String get digitalIdDocumentsTitle => 'Документы'; + + @override + String digitalIdDocSeries(String series) { + return 'серия $series'; + } + + @override + String digitalIdDocNumber(String number) { + return '№ $number'; + } + + @override + String get digitalIdPassesTitle => 'Пропуска'; + + @override + String digitalIdCardInn(String inn) { + return 'ИНН $inn'; + } + + @override + String get digitalIdBiometryConfigured => + 'Биометрия настроена на этом устройстве'; + + @override + String get digitalIdBiometryNotConfigured => + 'Биометрия на этом устройстве не настроена'; + + @override + String get digitalIdDocPassport => 'Паспорт РФ'; + + @override + String get digitalIdDocOms => 'Полис ОМС'; + + @override + String get digitalIdDocDriverLicense => 'Водительское удостоверение'; + + @override + String get digitalIdDocVehicleSts => 'СТС'; + + @override + String get digitalIdDocChildBirthCert => 'Свидетельство о рождении'; + + @override + String get digitalIdDocPensionCert => 'Пенсионное удостоверение'; + + @override + String get digitalIdDocDisabledCert => 'Справка об инвалидности'; + + @override + String get digitalIdDocLargeFamilyCert => 'Удостоверение многодетной семьи'; + + @override + String get digitalIdDocStudentTicket => 'Студенческий билет'; + + @override + String get digitalIdDocChildInn => 'ИНН ребёнка'; + + @override + String get digitalIdDocChildOms => 'Полис ОМС ребёнка'; + + @override + String get attachSheetGallery => 'Галерея'; + + @override + String get attachSheetPoll => 'Опрос'; + + @override + String get attachSheetCameraComingSoon => 'Камера скоро появится'; + + @override + String get attachSheetSendFileTitle => 'Отправить файл'; + + @override + String get attachSheetSendFileSubtitle => + 'Документ, архив или любой другой файл'; + + @override + String get attachSheetChooseFileButton => 'Выбрать файл'; + + @override + String get attachSheetShareLocationTitle => 'Поделиться геопозицией'; + + @override + String get attachSheetShareLocationSubtitle => + 'Отправить ваше текущее местоположение'; + + @override + String get attachSheetSendLocationButton => 'Отправить геопозицию'; + + @override + String get attachSheetCreatePoll => 'Создать опрос'; + + @override + String get attachSheetCreatePollSubtitle => 'Вопрос с вариантами ответа'; + + @override + String get attachSheetNoImagesFound => 'Изображений не найдено'; + + @override + String get attachSheetLimitedAccessInfo => 'Доступны не все фото'; + + @override + String get attachSheetSectionInProgress => 'Раздел в разработке'; + + @override + String get attachSheetNoGalleryAccessTitle => 'Нет доступа к галерее'; + + @override + String get attachSheetNoGalleryAccessSubtitle => + 'Разрешите доступ к фото, чтобы выбрать их отсюда'; + + @override + String get attachSheetAllow => 'Разрешить'; + + @override + String get attachSheetSettings => 'Настройки'; + + @override + String get attachSheetAddCaptionHint => 'Добавить подпись...'; + + @override + String get attachSheetCamera => 'Камера'; + + @override + String get photoEditorApplyFailed => 'Не удалось применить'; + + @override + String get photoEditorFlipTooltip => 'Отразить'; + + @override + String get photoEditorRotateTooltip => 'Повернуть'; + + @override + String get photoEditorCancel => 'ОТМЕНА'; + + @override + String get photoEditorReset => 'СБРОС'; + + @override + String get photoEditorDone => 'ГОТОВО'; + + @override + String get photoEditorTextDialogTitle => 'Текст'; + + @override + String get photoEditorTextDialogHint => 'Введите текст'; + + @override + String get photoEditorOk => 'ОК'; + + @override + String get photoEditorApplyChangesFailed => 'Не удалось применить изменения'; + + @override + String get photoEditorClearAll => 'Очистить всё'; + + @override + String get photoEditorAddText => 'Добавить текст'; + + @override + String get photoEditorTabDraw => 'РИСУНОК'; + + @override + String get photoEditorTabStickers => 'СТИКЕРЫ'; + + @override + String get photoEditorTabText => 'ТЕКСТ'; + + @override + String get photoEditorChannelAll => 'Все'; + + @override + String get photoEditorChannelRed => 'Красный'; + + @override + String get photoEditorChannelGreen => 'Зелёный'; + + @override + String get photoEditorChannelBlue => 'Синий'; + + @override + String get photoEditorEnhance => 'Улучшение'; + + @override + String get photoEditorExposure => 'Экспозиция'; + + @override + String get photoEditorContrast => 'Контраст'; + + @override + String get photoEditorSaturation => 'Насыщенность'; + + @override + String get photoEditorWarmth => 'Тёплость'; + + @override + String get photoEditorVignette => 'Виньетка'; + + @override + String get photoEditorBlurOff => 'Откл.'; + + @override + String get photoEditorBlurRadial => 'Радиальное'; + + @override + String get photoEditorBlurLinear => 'Линейное'; + + @override + String get fontSettingsInvalidInput => 'Введите ссылку или название шрифта'; + + @override + String fontSettingsFontNotFound(String name) { + return 'Шрифт «$name» не найден или нет сети'; + } + + @override + String fontSettingsFontAdded(String name) { + return 'Шрифт «$name» добавлен'; + } + + @override + String fontSettingsFontRemoved(String name) { + return 'Шрифт «$name» удалён'; + } + + @override + String get fontSettingsAddFontTitle => 'Добавить шрифт'; + + @override + String get fontSettingsAddFontDescription => + 'Вставьте ссылку Google Fonts или название шрифта'; + + @override + String get fontSettingsAddFontConfirm => 'Добавить'; + + @override + String get fontSettingsTitle => 'Шрифты'; + + @override + String get fontSettingsSectionFont => 'Шрифт'; + + @override + String get fontSettingsLoading => 'Загрузка…'; + + @override + String get fontSettingsSectionFontSize => 'Размер шрифта'; + + @override + String get fontSettingsPreviewLabel => 'ПРЕДПРОСМОТР'; + + @override + String get fontSettingsReset => 'Сбросить'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 9bba889..eca4acc 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -181,5 +181,463 @@ "editProfileRemovePhoto": "Удалить фото", "registrationTitle": "Создание профиля", "registrationSubtitle": "Укажите имя и выберите аватар", - "registrationChooseAvatar": "Выберите аватар" + "registrationChooseAvatar": "Выберите аватар", + "msgActionsCopy": "Копировать", + "msgActionsEdit": "Изменить", + "msgActionsReply": "Ответить", + "msgActionsForward": "Переслать", + "msgActionsMarkUnread": "Непрочитанное", + "msgActionsEditHistory": "История изменений", + "msgActionsReport": "Пожаловаться", + "msgActionsDelete": "Удалить", + "msgActionsCopied": "Скопировано", + "msgActionsLoadReasonsFailed": "Не удалось загрузить причины", + "msgActionsCurrentVersion": "текущая версия", + "msgActionsCurrentVersionWithDate": "текущая версия · {date}", + "msgActionsNoText": "(без текста)", + "notificationsSaveFailed": "Не удалось сохранить: {error}", + "notificationsFkmAlreadyHasFcm": "А зачем? У тебя уже FCM.", + "notificationsFkmDownloadFcm": "Скачай лучше FCM-версию.", + "notificationsTitle": "Уведомления", + "notificationsFkmSectionTitle": "FKM", + "notificationsFkmEnableLabel": "Включить уведомления", + "notificationsFkmEnableSubtitle": "Для работы FKM уведомлений, приложению понадобится держать уведомление в шторке.", + "notificationsMainSectionTitle": "Уведомления", + "notificationsAllLabel": "Все уведомления", + "notificationsNewSectionTitle": "Все новые уведомления", + "notificationsPreviewLabel": "Предпросмотр сообщений", + "notificationsSoundLabel": "Звук", + "notificationsAdditionalSectionTitle": "Дополнительно", + "notificationsCallsLabel": "Уведомления о звонках", + "notificationsNewContactsLabel": "Уведомления от новых контактов", + "notificationsHapticsSectionTitle": "Тактильная отдача", + "notificationsHapticsLabel": "Тактильная отдача", + "notificationsHapticsSubtitle": "Виброотклик при действиях в приложении", + "devicesLoadFailed": "Ошибка загрузки: {error}", + "devicesQrLinkDialogTitle": "Ссылка из QR", + "devicesQrLinkDialogHint": "Вставьте содержимое QR-кода", + "devicesAllTerminated": "Все сессии завершены", + "devicesGenericError": "Ошибка: {error}", + "devicesIpLookupError": "Ошибка IP: {error}", + "devicesTitle": "Устройства", + "devicesPromoTitle": "Устройства в KOMET", + "devicesPromoSubtitle": "Кто имеет доступ к вашему аккаунту?", + "devicesScanQrButton": "Сканировать QR", + "devicesCurrentSuffix": " (текущая)", + "devicesOnlineStatus": "В сети", + "devicesTerminateOthersButton": "Завершить все сессии, кроме текущей", + "devicesMobileNetworkLabel": "Мобильная сеть", + "devicesProxyDetectedLabel": "Обнаружен прокси/VPN", + "themeSettingsTitle": "Тема", + "themeSettingsModeCardTitle": "Режим темы", + "themeSettingsModeCardSubtitle": "Светлая, тёмная или авто-переключение", + "themeSettingsModeSystem": "Системная", + "themeSettingsModeLight": "Светлая", + "themeSettingsModeDark": "Тёмная", + "themeSettingsModeSchedule": "По расписанию", + "themeSettingsAmoledTitle": "AMOLED-чёрный", + "themeSettingsAmoledSubtitle": "Чистый чёрный фон для OLED-экранов", + "themeSettingsScheduleTitle": "Расписание", + "themeSettingsScheduleSubtitleEnabled": "Когда автоматически включается тёмная тема", + "themeSettingsScheduleSubtitleDisabled": "Доступно в режиме «По расписанию»", + "themeSettingsScheduleDarkFrom": "Тёмная с", + "themeSettingsScheduleLightFrom": "Светлая с", + "appearanceTitle": "Внешний вид", + "appearanceVisualStyleTitle": "Визуал", + "appearanceVisualStyleSubtitle": "Material You или объёмные Glossy-капсулы", + "appearanceVisualStyleMaterialYou": "Material You", + "appearanceVisualStyleGlossy": "Glossy", + "appearanceChatChromeTitle": "Элементы экрана чата", + "appearanceChatChromeSubtitle": "Фон панелей сверху и снизу: цвет, размытие или прозрачно. При размытии и прозрачности сообщения заходят под панели", + "appearanceChatChromeColor": "Цвет", + "appearanceChatChromeBlur": "Блюр", + "appearanceChatChromeNone": "Нет", + "appearanceGradientTitle": "Градиент", + "appearanceGradientSubtitle": "Объём и блики в Glossy-капсулах", + "appearanceAccentColorTitle": "Акцентный цвет", + "appearanceAccentColorSystem": "Системный", + "appearanceAccentColorSubtitle": "Основной цвет интерфейса и пузырей", + "appearanceAccentColorSystemActive": "Системный цвет активен", + "appearanceAccentColorReset": "Сбросить на системный", + "appearanceBubbleShapeTitle": "Форма сообщения", + "appearanceBubbleShapeSubtitle": "Скругление углов пузырей", + "appearanceBubbleShapeMobile": "TG Mobile", + "appearanceBubbleShapeDesktop": "TG Desktop", + "appearanceBubbleBehaviorTitle": "Поведение сообщения", + "appearanceBubbleBehaviorSubtitle": "Меняется ли форма пузыря по соседям в группе", + "appearanceBubbleBehaviorMutable": "Изменяемая", + "appearanceBubbleBehaviorImmutable": "Неизменяемая", + "appearancePreviewHello": "Привет!", + "appearancePreviewHowIsIt": "Как тебе?", + "appearancePreviewHmm": "хм...", + "appearancePreviewNotBad": "Вполне неплохо!", + + "callKometDetectedNotification": "Этот человек использует Komet! :3", + "callStatusConnecting": "Соединение", + "callGroupConnecting": "Соединение…", + "callGroupWaitingParticipants": "Ожидание участников…", + "callParticipantYou": "Вы", + "callParticipantFallback": "Участник", + "callTooltipMinimize": "Свернуть", + "callTooltipKometHub": "Komet", + "callInfoTitle": "О звонке", + "callPeerMicOff": "Микрофон выключен", + "callPeerCameraOn": "Камера включена", + "callUnknownName": "Неизвестный", + "callIncoming": "Входящий звонок", + "callStatusRinging": "Вызов", + "callStatusEnded": "Звонок завершён", + "callDecline": "Отклонить", + "callAccept": "Принять", + "callSpeaker": "Динамик", + "callVideoLabel": "Видео", + "callScreenLabel": "Экран", + "callUnmute": "Вкл. звук", + "callMute": "Выкл. звук", + "callEndButton": "Завершить", + "callInfoClient": "Клиент", + "callInfoPlatform": "Платформа", + "callInfoCountry": "Страна", + "callInfoInContacts": "В контактах", + "callValueYes": "да", + "callValueNo": "нет", + "callInfoPeerIp": "IP собеседника", + "callInfoPeerNetwork": "Сеть собеседника", + "callInfoPath": "Путь соединения", + "callInfoCodec": "Кодек", + "callInfoServer": "Сервер", + "callInfoTopology": "Топология", + "callInfoStatus": "Статус", + "callStatusValueConnected": "соединён", + "callStatusValueConnecting": "соединение…", + "callInfoPeerMic": "Микрофон собеседника", + "callMicValueOn": "включён", + "callMicValueOff": "выключен", + "callInfoPeerCamera": "Камера собеседника", + "callCameraValueOn": "включена", + "callCameraValueOff": "выключена", + "callInfoVideoTrack": "Видео-дорожка", + "callInfoVideoTrackPresent": "есть ({count})", + "callInfoVideoSize": "Размер видео", + "callInfoFrameRendering": "Отрисовка кадров", + "callBadgeEncrypted": "Зашифрован", + "callBadgeAudio": "Аудио", + "callBadgeRecording": "Запись", + "callBadgeNoiseSuppression": "Шумоподавление", + "callBadgeAnimoji": "Анимодзи", + "callInfoNoDataYet": "Данные появятся после соединения…", + + "hubTitleMenu": "Komet", + "hubChatPageTitle": "Анонимный чат", + "hubGamesTitle": "Игры", + "hubCheckersTitle": "Шашки", + "hubChatTileTitle": "Чат", + "hubChatTileSubtitle": "Анонимные сообщения", + "hubGamesTileSubtitle": "Сыграть с собеседником", + "hubCheckersTileSubtitle": "Русские шашки", + "hubMoreSoonTitle": "Скоро ещё…", + "hubMoreSoonSubtitle": "В разработке", + "hubChatPrivacyNote": "Напрямую через звонок, нигде не сохраняется", + "hubChatEmpty": "Сообщений пока нет", + "hubChatInputHint": "Сообщение…", + "hubCheckersRestart": "Заново", + "hubCheckersYouWhite": "Вы играете белыми", + "hubCheckersYouBlack": "Вы играете чёрными", + "hubCheckersWon": "Вы выиграли 🎉", + "hubCheckersLost": "Вы проиграли", + "hubCheckersYourMove": "Ваш ход", + "hubCheckersOpponentMove": "Ход соперника…", + + "scheduledPickTimeTitle": "Когда отправить", + "scheduledEditTitle": "Изменить", + "scheduledMessageTextHint": "Текст сообщения", + "scheduledSave": "Сохранить", + "scheduledEditFailed": "Не удалось изменить сообщение", + "scheduledDeleteConfirmTitle": "Удалить запланированное сообщение?", + "scheduledDeleteConfirmMessage": "Сообщение не будет отправлено.", + "scheduledDeleteConfirmLabel": "Удалить", + "scheduledDeleteFailed": "Не удалось удалить сообщение", + "scheduledAppBarTitle": "Отложенные", + "scheduledEmpty": "Нет отложенных сообщений", + "scheduledAttachPhoto": "Фото", + "scheduledAttachVideo": "Видео", + "scheduledAttachVoice": "Голосовое", + "scheduledAttachFile": "Файл", + "scheduledAttachLocation": "Геопозиция", + "scheduledAttachForwarded": "Переслано", + "scheduledAttachGeneric": "Вложение", + + "contactProfileLoadError": "Ошибка: {error}", + "contactProfileBot": "Бот", + "contactProfileOnline": "В сети", + "contactProfileRecentlyActive": "Был(-а) недавно", + "contactProfileActionChat": "Чат", + "contactProfileActionSound": "Звук", + "contactProfileActionCall": "Звонок", + "contactProfileInfoPhone": "Телефон", + "contactProfileInfoCountry": "Страна", + "contactProfileInfoGender": "Пол", + "contactProfileInfoRegistration": "Регистрация", + "contactProfileInfoUpdated": "Обновлён", + "contactProfileInfoAccountStatus": "Статус аккаунта", + "contactProfileInfoDescription": "Описание", + "contactProfileInfoLink": "Ссылка", + "contactProfileInfoFlags": "Флаги", + + "nfcPeerNameFallback": "Контакт #{id}", + "nfcPeerFirstNameFallback": "Контакт", + "nfcContactAdded": "Контакт добавлен", + "nfcAddFailed": "Не удалось добавить: {error}", + "nfcReasonBluetoothOff": "Включите Bluetooth и попробуйте снова", + "nfcReasonPermission": "Нужны разрешения Bluetooth для обмена", + "nfcReasonDefault": "Не удалось установить соединение", + "nfcSheetTitle": "Обмен контактом", + "nfcUnsupported": "NFC недоступен на этом устройстве", + "nfcDisabled": "Включите NFC в настройках телефона и попробуйте снова", + "nfcScanningTitle": "Поднесите телефоны друг к другу", + "nfcScanningSubtitle": "Оба устройства должны держать этот экран открытым", + "nfcExchangingTitle": "Идёт обмен контактами…", + "nfcExchangingSubtitle": "Почти готово", + "nfcPeerIdFallback": "ID {id}", + "nfcAdded": "Добавлено", + "nfcAddContact": "Добавить контакт", + + "chatInfoTabGeneralChats": "Общие чаты", + "chatInfoTabMedia": "Медиа", + "chatInfoTabFiles": "Файлы", + "chatInfoTabVoice": "Голосовые", + "chatInfoTabLinks": "Ссылки", + "chatInfoTabMembers": "Участники", + "chatInfoEmptyGeneralChats": "Нет общих чатов", + "chatInfoEmptyMedia": "Нет медиа", + "chatInfoEmptyFiles": "Нет файлов", + "chatInfoEmptyVoice": "Нет голосовых", + "chatInfoEmptyLinks": "Нет ссылок", + "chatInfoOnlineOfTotal": "{online} из {total} в сети", + "chatInfoActionLeave": "Покинуть", + "chatInfoBio": "О себе", + "chatInfoInviteLink": "Ссылка-приглашение", + "chatInfoCollapse": "Свернуть", + "chatInfoShowMore": "Ещё", + "chatInfoAddMember": "Добавить участника", + "chatInfoRoleOwner": "владелец", + "chatInfoRoleAdmin": "Адмін", + "chatInfoNoData": "Нет данных", + "chatInfoHideExtra": "Скрыть", + "chatInfoShowMoreExtra": "Подробнее", + "chatInfoRowId": "ID чата", + "chatInfoRowCreated": "Создан", + "chatInfoRowModified": "Изменён", + "chatInfoRowMembersCount": "Участников", + "chatInfoRowOwner": "Владелец", + "chatInfoRowCreatedGroup": "Создана", + "chatInfoRowJoined": "Вступил", + "chatInfoRowModifiedGroup": "Изменена", + "chatInfoRowHasBots": "Есть боты", + "chatInfoRowBlockedCount": "Заблокировано", + "chatInfoRowOfficialGroup": "Официальная", + "chatInfoRowSignAdmin": "Подпись адм.", + "chatInfoRowSubscribersCount": "Подписчиков", + "chatInfoRowOfficialChannel": "Официальный", + "chatInfoRowComments": "Комментарии", + "chatInfoRowRkn": "РКН", + "chatInfoRowOnlyAdmin": "Только адм.", + + "securityTitle": "Безопасность", + "securityLoadError": "Ошибка загрузки: {error}", + "securitySaveError": "Ошибка сохранения: {error}", + "securityPrivacyAll": "Все", + "securityPrivacyContacts": "Мои контакты", + "securityPrivacyNobody": "Никто", + "securityFamilyProtection": "Семейная защита", + "securityEnabledFem": "Включена", + "securityDisabledFem": "Отключена", + "securityPasswordTitle": "Пароль для входа", + "securityEnabledMasc": "Включён", + "securityDisabledMasc": "Отключён", + "securityModeTitle": "Безопасный режим", + "securityModeSubtitle": "Скрывает личную информацию", + "securitySettingsUnavailable": "Изменение настроек пока недоступно", + "securityFindByPhone": "Найти меня по номеру", + "securityWhoCanCall": "Кто может мне звонить", + "securityWhoCanInvite": "Кто может приглашать в чаты", + "securityShowContact": "Показывать контакт", + "securityContentSafe": "Безопасный", + "securityContentAll": "Весь", + "securityShowOnlineStatus": "Видеть статус «в сети»", + "securityShowMyNumber": "Видеть мой номер", + "securityConfirmTitle": "Вы уверены?", + "securityHiddenStatusWarning": "Вы не сможете видеть статусы посещения других пользователей.", + "securityConfidentialityHeader": "КОНФИДЕНЦИАЛЬНОСТЬ", + "securityReadReceipts": "Галочки «Прочитано»", + "securityAltKeyboard": "Альтернативная клавиатура", + "securityUnsafeFiles": "Принимать опасные файлы", + "securityAudioTranscription": "Транскрибация аудио", + "securityBlacklistTitle": "Чёрный список", + "securityBlacklistNotification": "Чёрный список: {count} контактов", + + "passwordEntryWrongPassword": "Неверный пароль", + "passwordEntryConfirmTitle": "Подтвердите пароль", + "passwordEntryCurrentPasswordHint": "Текущий пароль", + "passwordEntryContinue": "Продолжить", + "passwordEntryNotSetTitle": "Пароль не установлен", + "passwordEntry2faSubtitle": "Двухфакторная аутентификация", + "passwordEntrySetupAction": "Установить пароль", + "passwordEntryGateMessage": "Введите пароль для входа, чтобы управлять защитой", + "passwordEntryGenericPasswordHint": "Пароль", + "passwordEntrySetTitle": "Пароль установлен", + "passwordEntryHintPrefix": "Подсказка: {hint}", + "passwordEntryChangePasswordAction": "Изменить пароль", + "passwordEntryChangeEmailAction": "Изменить почту", + "passwordEntryDeleteAction": "Удалить пароль", + "passwordEntryMinPasswordError": "Пароль должен быть минимум 6 символов", + "passwordEntryMismatchError": "Пароли не совпадают", + "passwordEntryInvalidEmailError": "Введите корректный email", + "passwordEntryInvalidCodeError": "Введите 6-значный код", + "passwordEntrySetupTitle": "Установка пароля", + "passwordEntryStepPassword": "Пароль", + "passwordEntryStepHint": "Подсказка", + "passwordEntryStepEmail": "Почта", + "passwordEntryStepCode": "Код", + "passwordEntryChoosePassword": "Придумайте пароль", + "passwordEntryMinCharsHint": "Минимум 6 символов", + "passwordEntryEnterPasswordHint": "Введите пароль", + "passwordEntryEnterAgain": "Введите пароль ещё раз", + "passwordEntryRepeatHint": "Повторите пароль", + "passwordEntryHintForPassword": "Подсказка для пароля", + "passwordEntryOptional": "Необязательно", + "passwordEntryHintFieldHint": "Введите подсказку (необязательно)", + "passwordEntryLinkEmail": "Привяжите email", + "passwordEntryEmailPurpose": "Для восстановления пароля. Необязательно", + "passwordEntryEmailHintOptional": "example@mail.ru (необязательно)", + "passwordEntryEnterCode": "Введите код", + "passwordEntryCodeSentTo": "Код отправлен на {email}", + "passwordEntryChangedNotif": "Пароль изменён", + "passwordEntryNewPassword": "Новый пароль", + "passwordEntryNewPasswordHint": "Введите новый пароль", + "passwordEntryRepeatNewPasswordHint": "Повторите новый пароль", + "passwordEntryEmailChangedNotif": "Почта изменена", + "passwordEntryNewEmail": "Новая почта", + "passwordEntryEmailHint": "example@mail.ru", + "passwordEntryRemovedNotif": "Пароль удалён", + "passwordEntryRemoveTitle": "Удаление пароля", + "passwordEntryRemoveWarning": "Внимание! После удаления пароля защита вашего аккаунта ослабнет.", + "cloudStorageNoActiveProfile": "Нет активного профиля", + "cloudStorageSetupFailed": "Не удалось создать среду", + "cloudStorageTitle": "Облачное хранилище", + "cloudStorageNotConfiguredTitle": "Среда для облачного хранилища не настроена", + "cloudStorageNotConfiguredSubtitle": "Начнем? Это быстро.", + "cloudStorageStart": "Начать", + "cloudStorageUploadingPercent": "Загрузка {percent}%", + "cloudStorageStartUploadHint": "Начните загрузку для прогресс-бара", + "cloudStorageEmptyTitle": "Облачных файлов пока нет...", + "cloudStorageEmptySubtitle": "Добавите?", + "cloudStorageUpload": "Загрузить", + "cloudStorageFromFile": "С файла", + "cloudStorageById": "По ID", + "cloudStorageFileIdLabel": "ID файла", + "cloudStorageSizeLabel": "Размер", + "cloudStorageNoLinkYet": "Ссылки пока нет. Создайте.", + "cloudStorageLinkExpiresIn": "Ссылка истечет {time}", + "cloudStorageLinkCopied": "Ссылка скопирована", + "cloudStorageInvalidId": "Неверный ID", + "cloudStorageSendError": "Ошибка отправки", + "cloudStorageSendByIdTitle": "Отправить по ID", + "cloudStorageSend": "Отправить", + "digitalIdGosuslugiLinkUnavailable": "Привязка Госуслуг недоступна на этой платформе. Сделайте это в приложении на телефоне.", + "digitalIdGosuslugiLinkFailed": "Не удалось получить ссылку Госуслуг", + "digitalIdGosuslugiTitle": "Госуслуги", + "digitalIdDocsUnavailable": "Документы пока недоступны. Попробуйте позже.", + "digitalIdTitle": "Цифровой ID", + "digitalIdNotConfiguredTitle": "Цифровой ID не настроен", + "digitalIdLinkGosuslugiHint": "Привяжите аккаунт Госуслуг, чтобы документы появились в Цифровом ID. Номер телефона в MAX должен совпадать с номером в профиле Госуслуг.", + "digitalIdLinkOrRefreshHint": "Привяжите Госуслуги, чтобы получить доступ к документам, или обновите страницу, если уже настраивали Цифровой ID.", + "digitalIdLoadDocuments": "Загрузить документы", + "digitalIdLinkGosuslugiButton": "Привязать Госуслуги", + "digitalIdGosuslugiProfileFallback": "Профиль Госуслуг", + "digitalIdBirthDate": "Дата рождения: {date}", + "digitalIdPersonalDataTitle": "Личные данные", + "digitalIdSnilsLabel": "СНИЛС", + "digitalIdInnLabel": "ИНН", + "digitalIdBirthPlaceLabel": "Место рождения", + "digitalIdRegistrationAddressLabel": "Адрес регистрации", + "digitalIdDocumentsTitle": "Документы", + "digitalIdDocSeries": "серия {series}", + "digitalIdDocNumber": "№ {number}", + "digitalIdPassesTitle": "Пропуска", + "digitalIdCardInn": "ИНН {inn}", + "digitalIdBiometryConfigured": "Биометрия настроена на этом устройстве", + "digitalIdBiometryNotConfigured": "Биометрия на этом устройстве не настроена", + "digitalIdDocPassport": "Паспорт РФ", + "digitalIdDocOms": "Полис ОМС", + "digitalIdDocDriverLicense": "Водительское удостоверение", + "digitalIdDocVehicleSts": "СТС", + "digitalIdDocChildBirthCert": "Свидетельство о рождении", + "digitalIdDocPensionCert": "Пенсионное удостоверение", + "digitalIdDocDisabledCert": "Справка об инвалидности", + "digitalIdDocLargeFamilyCert": "Удостоверение многодетной семьи", + "digitalIdDocStudentTicket": "Студенческий билет", + "digitalIdDocChildInn": "ИНН ребёнка", + "digitalIdDocChildOms": "Полис ОМС ребёнка", + "attachSheetGallery": "Галерея", + "attachSheetPoll": "Опрос", + "attachSheetCameraComingSoon": "Камера скоро появится", + "attachSheetSendFileTitle": "Отправить файл", + "attachSheetSendFileSubtitle": "Документ, архив или любой другой файл", + "attachSheetChooseFileButton": "Выбрать файл", + "attachSheetShareLocationTitle": "Поделиться геопозицией", + "attachSheetShareLocationSubtitle": "Отправить ваше текущее местоположение", + "attachSheetSendLocationButton": "Отправить геопозицию", + "attachSheetCreatePoll": "Создать опрос", + "attachSheetCreatePollSubtitle": "Вопрос с вариантами ответа", + "attachSheetNoImagesFound": "Изображений не найдено", + "attachSheetLimitedAccessInfo": "Доступны не все фото", + "attachSheetSectionInProgress": "Раздел в разработке", + "attachSheetNoGalleryAccessTitle": "Нет доступа к галерее", + "attachSheetNoGalleryAccessSubtitle": "Разрешите доступ к фото, чтобы выбрать их отсюда", + "attachSheetAllow": "Разрешить", + "attachSheetSettings": "Настройки", + "attachSheetAddCaptionHint": "Добавить подпись...", + "attachSheetCamera": "Камера", + "photoEditorApplyFailed": "Не удалось применить", + "photoEditorFlipTooltip": "Отразить", + "photoEditorRotateTooltip": "Повернуть", + "photoEditorCancel": "ОТМЕНА", + "photoEditorReset": "СБРОС", + "photoEditorDone": "ГОТОВО", + "photoEditorTextDialogTitle": "Текст", + "photoEditorTextDialogHint": "Введите текст", + "photoEditorOk": "ОК", + "photoEditorApplyChangesFailed": "Не удалось применить изменения", + "photoEditorClearAll": "Очистить всё", + "photoEditorAddText": "Добавить текст", + "photoEditorTabDraw": "РИСУНОК", + "photoEditorTabStickers": "СТИКЕРЫ", + "photoEditorTabText": "ТЕКСТ", + "photoEditorChannelAll": "Все", + "photoEditorChannelRed": "Красный", + "photoEditorChannelGreen": "Зелёный", + "photoEditorChannelBlue": "Синий", + "photoEditorEnhance": "Улучшение", + "photoEditorExposure": "Экспозиция", + "photoEditorContrast": "Контраст", + "photoEditorSaturation": "Насыщенность", + "photoEditorWarmth": "Тёплость", + "photoEditorVignette": "Виньетка", + "photoEditorBlurOff": "Откл.", + "photoEditorBlurRadial": "Радиальное", + "photoEditorBlurLinear": "Линейное", + "fontSettingsInvalidInput": "Введите ссылку или название шрифта", + "fontSettingsFontNotFound": "Шрифт «{name}» не найден или нет сети", + "fontSettingsFontAdded": "Шрифт «{name}» добавлен", + "fontSettingsFontRemoved": "Шрифт «{name}» удалён", + "fontSettingsAddFontTitle": "Добавить шрифт", + "fontSettingsAddFontDescription": "Вставьте ссылку Google Fonts или название шрифта", + "fontSettingsAddFontConfirm": "Добавить", + "fontSettingsTitle": "Шрифты", + "fontSettingsSectionFont": "Шрифт", + "fontSettingsLoading": "Загрузка…", + "fontSettingsSectionFontSize": "Размер шрифта", + "fontSettingsPreviewLabel": "ПРЕДПРОСМОТР", + "fontSettingsReset": "Сбросить" } diff --git a/lib/main.dart b/lib/main.dart index 7479c92..dc82e4b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,6 +13,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'backend/api.dart'; import 'core/cache/info_cache.dart'; +import 'core/utils/logger.dart'; import 'core/cache/self_presence.dart'; import 'core/storage/app_instance.dart'; import 'core/storage/draft_store.dart'; @@ -120,7 +121,7 @@ void main() async { await ContactsModule.primeCacheFromDb(activeAccountId); } attachInfoCacheApi(api); - ChatsModule.attachGlobalPushHandlers(api); + chats.attachGlobalPushHandlers(api); unawaited(DeepLinkService.instance.init()); final packageInfoFuture = PackageInfo.fromPlatform(); @@ -176,24 +177,26 @@ void main() async { unawaited(CustomFontService.preloadCached()); } final initialAccentSeed = await accentFuture; - AppBubbleShape.current.value = await bubbleShapeFuture; - AppBubbleBehavior.current.value = await bubbleBehaviorFuture; - AppCacheExtent.current.value = await cacheExtentFuture; - AppThemeModeConfig.current.value = await themeModeFuture; - AppAmoled.current.value = await amoledFuture; - AppPillGradient.current.value = await pillGradientFuture; - AppVisualStyle.current.value = await visualStyleFuture; - AppChatChrome.current.value = await chatChromeFuture; - AppThemeSchedule.current.value = await themeScheduleFuture; - AppMessageActionsStyle.current.value = await messageActionsFuture; - AppSwipeBackDesktop.current.value = await swipeBackFuture; - AppPranks.current.value = await pranksFuture; - AppStories.current.value = await storiesFuture; - AppCommands.current.value = await commandsFuture; - AppLinkPreview.current.value = await linkPreviewFuture; - AppMediaCacheLimit.current.value = await cacheLimitFuture; - AppDigitalIdNative.current.value = await digitalIdNativeFuture; - AppShowExtraInfo.current.value = await showExtraInfoFuture; + await Future.wait([ + bubbleShapeFuture, + bubbleBehaviorFuture, + cacheExtentFuture, + themeModeFuture, + amoledFuture, + pillGradientFuture, + visualStyleFuture, + chatChromeFuture, + themeScheduleFuture, + messageActionsFuture, + swipeBackFuture, + pranksFuture, + storiesFuture, + commandsFuture, + linkPreviewFuture, + cacheLimitFuture, + digitalIdNativeFuture, + showExtraInfoFuture, + ]); await trafficCaptureFuture; await debugLogFuture; runApp( @@ -303,7 +306,9 @@ class KometAppState extends State await accountModule.login(accountId: accountId, token: token); } } - } catch (_) {} + } catch (e) { + logger.w('reconnect login failed: $e'); + } }); _loginStatusSub = accountModule.loginStatusStream.listen((status) async { @@ -419,7 +424,9 @@ class KometAppState extends State if (call == null || _incomingRouteActive || !_shellReady) return; final navState = KometApp.navigatorKey.currentState; if (navState == null) { - WidgetsBinding.instance.addPostFrameCallback((_) => _presentIncomingCall()); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _presentIncomingCall(), + ); return; } _incomingRouteActive = true; @@ -435,9 +442,9 @@ class KometAppState extends State ), ) .whenComplete(() { - _incomingRouteActive = false; - if (identical(_pendingIncoming, call)) _pendingIncoming = null; - }); + _incomingRouteActive = false; + if (identical(_pendingIncoming, call)) _pendingIncoming = null; + }); } @override @@ -469,9 +476,11 @@ class KometAppState extends State state == AppLifecycleState.hidden || state == AppLifecycleState.detached) { DebugSessionLog.instance.flushNow(); + SelfCheckService.instance.pause(); } if (state != AppLifecycleState.resumed) return; api.wakeUp(); + SelfCheckService.instance.resume(); CallBridge.instance.checkInitialCall(); if (AppThemeModeConfig.current.value != AppThemeMode.schedule) return; _rescheduleSwitch(); diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index 13d202f..b40d021 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import '../core/utils/parse.dart'; + enum AttachmentType { photo, video, @@ -13,6 +15,8 @@ enum AttachmentType { share, call, inlineKeyboard, + forward, + unknown, } String? decodeAttachPreview(dynamic raw) { @@ -128,7 +132,6 @@ class VideoAttachment extends MessageAttachment { final int? duration; final int? size; - /// 0 — обычное видео, 1 — видеосообщение-кружок. final int? videoType; bool get isNote => videoType == 1; @@ -307,7 +310,8 @@ class StickerAttachment extends MessageAttachment { previewData: decodeAttachPreview(map['previewData']), baseUrl: (map['url'] ?? map['baseUrl'])?.toString(), stickerId: map['stickerId']?.toString(), - stickerPackId: map['setId']?.toString() ?? map['stickerPackId']?.toString(), + stickerPackId: + map['setId']?.toString() ?? map['stickerPackId']?.toString(), lottieUrl: map['lottieUrl']?.toString(), width: map['width'] as int?, height: map['height'] as int?, @@ -358,7 +362,9 @@ class ContactAttachment extends MessageAttachment { lastName: map['lastName']?.toString(), phoneNumber: map['phoneNumber']?.toString(), photoUrl: map['photoUrl']?.toString(), - contactId: map['contactId'] is int ? map['contactId'] as int : int.tryParse(map['contactId']?.toString() ?? ''), + contactId: map['contactId'] is int + ? map['contactId'] as int + : int.tryParse(map['contactId']?.toString() ?? ''), name: map['name']?.toString(), ); } @@ -448,8 +454,10 @@ class ControlAttachment extends MessageAttachment { baseUrl: map['baseUrl']?.toString(), event: map['event']?.toString(), title: title, - userIds: (map['userIds'] as List?)?.map((e) => e is int ? e : int.tryParse(e?.toString() ?? '') ?? 0).toList(), - userId: map['userId'] is int ? map['userId'] as int : int.tryParse(map['userId']?.toString() ?? ''), + userIds: map['userIds'] is List ? parseIntList(map['userIds']) : null, + userId: map['userId'] is int + ? map['userId'] as int + : int.tryParse(map['userId']?.toString() ?? ''), ); } @@ -469,10 +477,8 @@ class PollAttachment extends MessageAttachment { final int pollId; final String? title; - const PollAttachment({ - required this.pollId, - this.title, - }) : super(type: AttachmentType.poll); + const PollAttachment({required this.pollId, this.title}) + : super(type: AttachmentType.poll); factory PollAttachment.fromMap(Map map) { final id = map['pollId'] ?? map['id']; @@ -522,10 +528,7 @@ class CallAttachment extends MessageAttachment { hangupType: map['hangupType']?.toString(), conversationId: map['conversationId']?.toString(), joinLink: map['joinLink']?.toString(), - contactIds: (map['contactIds'] as List?) - ?.map((e) => e is int ? e : int.tryParse(e?.toString() ?? '') ?? 0) - .toList() ?? - const [], + contactIds: parseIntList(map['contactIds']), ); } @@ -631,10 +634,8 @@ class InlineKeyboardAttachment extends MessageAttachment { final String? callbackId; final List> rows; - const InlineKeyboardAttachment({ - this.callbackId, - required this.rows, - }) : super(type: AttachmentType.inlineKeyboard); + const InlineKeyboardAttachment({this.callbackId, required this.rows}) + : super(type: AttachmentType.inlineKeyboard); bool get isEmpty => rows.every((row) => row.isEmpty); @@ -667,9 +668,7 @@ class InlineKeyboardAttachment extends MessageAttachment { '_type': 'INLINE_KEYBOARD', if (callbackId != null) 'callbackId': callbackId, 'keyboard': { - 'buttons': rows - .map((row) => row.map((b) => b.toMap()).toList()) - .toList(), + 'buttons': rows.map((row) => row.map((b) => b.toMap()).toList()).toList(), }, }; } @@ -695,7 +694,7 @@ class ForwardedMessageAttachment extends MessageAttachment { this.originalChatId, this.originalAttachments, this.originalContact, - }) : super(type: AttachmentType.photo); + }) : super(type: AttachmentType.forward); factory ForwardedMessageAttachment.fromMap(Map map) { final linkRaw = map['link']; @@ -764,7 +763,7 @@ class ForwardedMessageAttachment extends MessageAttachment { class UnknownAttachment extends MessageAttachment { final Map rawData; - const UnknownAttachment(this.rawData) : super(type: AttachmentType.photo); + const UnknownAttachment(this.rawData) : super(type: AttachmentType.unknown); @override Map toMap() => rawData; diff --git a/lib/models/chat_info.dart b/lib/models/chat_info.dart new file mode 100644 index 0000000..0c4ff35 --- /dev/null +++ b/lib/models/chat_info.dart @@ -0,0 +1,40 @@ +class ChatInfo { + final Map raw; + final List participantIds; + final Set adminIds; + final int? owner; + + const ChatInfo({ + required this.raw, + required this.participantIds, + required this.adminIds, + required this.owner, + }); + + factory ChatInfo.fromMap(Map map) { + return ChatInfo( + raw: map, + participantIds: _idKeys(map['participants']), + adminIds: _idKeys(map['adminParticipants']).toSet(), + owner: map['owner'] as int?, + ); + } + + bool isAdmin(int id) => adminIds.contains(id); + bool isOwner(int id) => owner != null && id == owner; + + int? get participantsCount => raw['participantsCount'] as int?; + int? get blockedParticipantsCount => raw['blockedParticipantsCount'] as int?; + String? get link => raw['link'] as String?; + String? get description => raw['description'] as String?; + + static List _idKeys(Object? source) { + if (source is! Map) return const []; + final out = []; + for (final key in source.keys) { + final id = key is int ? key : int.tryParse(key.toString()); + if (id != null) out.add(id); + } + return out; + } +} diff --git a/lib/models/contact_info.dart b/lib/models/contact_info.dart new file mode 100644 index 0000000..4e02ac4 --- /dev/null +++ b/lib/models/contact_info.dart @@ -0,0 +1,71 @@ +class ContactName { + final String? type; + final String? name; + final String? firstName; + final String? lastName; + + const ContactName({this.type, this.name, this.firstName, this.lastName}); + + factory ContactName.fromMap(Map map) => ContactName( + type: map['type']?.toString(), + name: map['name']?.toString(), + firstName: map['firstName']?.toString(), + lastName: map['lastName']?.toString(), + ); + + String? get label { + final n = name; + if (n != null && n.trim().isNotEmpty) return n.trim(); + final combined = [firstName, lastName] + .where((s) => s != null && s.trim().isNotEmpty) + .map((s) => s!.trim()) + .join(' '); + return combined.isEmpty ? null : combined; + } +} + +class ContactInfo { + final Map raw; + final List names; + + const ContactInfo({required this.raw, required this.names}); + + factory ContactInfo.fromMap(Map map) { + final rawNames = map['names']; + final names = []; + if (rawNames is List) { + for (final n in rawNames) { + if (n is Map) names.add(ContactName.fromMap(n)); + } + } + return ContactInfo(raw: map, names: names); + } + + String? get displayName { + String? firstLabel; + for (final n in names) { + final label = n.label; + if (label == null) continue; + firstLabel ??= label; + if (n.type == 'ONEME') return label; + } + return firstLabel; + } + + String? get firstName { + for (final n in names) { + final f = n.firstName; + if (f != null && f.trim().isNotEmpty) return f.trim(); + } + return null; + } + + String? get avatarUrl => raw['baseUrl'] as String?; + + List get options { + final o = raw['options']; + return o is List ? o.whereType().toList() : const []; + } + + int? get id => raw['id'] as int?; +} diff --git a/lib/models/poll.dart b/lib/models/poll.dart index a4e5945..1bf46fe 100644 --- a/lib/models/poll.dart +++ b/lib/models/poll.dart @@ -56,22 +56,50 @@ class Poll { } Poll withStateMap(Map stateMap) { - return Poll.fromServerMap({ - 'pollId': pollId, - 'title': title, - 'settings': settings, - 'version': version, - 'answers': [ - for (final a in answers) {'answerId': a.answerId, 'text': a.text}, - ], - 'state': stateMap, - }); + return _buildFromState( + pollId: pollId, + title: title, + settings: settings, + version: version, + answerIdsAndTexts: [for (final a in answers) (a.answerId, a.text)], + stateMap: stateMap, + ); } factory Poll.fromServerMap(Map map) { final state = map['state']; final stateMap = state is Map ? state : const {}; + final answerIdsAndTexts = <(int, String)>[]; + final rawAnswers = map['answers']; + if (rawAnswers is List) { + for (final a in rawAnswers) { + if (a is! Map) continue; + answerIdsAndTexts.add(( + a['answerId'] as int? ?? 0, + a['text']?.toString() ?? '', + )); + } + } + + return _buildFromState( + pollId: map['pollId'] as int? ?? 0, + title: map['title']?.toString() ?? '', + settings: map['settings'] as int? ?? 0, + version: map['version'] as int? ?? 0, + answerIdsAndTexts: answerIdsAndTexts, + stateMap: stateMap, + ); + } + + static Poll _buildFromState({ + required int pollId, + required String title, + required int settings, + required int version, + required List<(int, String)> answerIdsAndTexts, + required Map stateMap, + }) { final resultsById = {}; final result = stateMap['result']; if (result is List) { @@ -83,33 +111,30 @@ class Poll { } final answers = []; - final rawAnswers = map['answers']; - if (rawAnswers is List) { - for (final a in rawAnswers) { - if (a is! Map) continue; - final id = a['answerId'] as int? ?? 0; - final res = resultsById[id]; - answers.add(PollAnswer( + for (final (id, text) in answerIdsAndTexts) { + final res = resultsById[id]; + answers.add( + PollAnswer( answerId: id, - text: a['text']?.toString() ?? '', + text: text, voteCount: (res?['voteCount'] as num?)?.toInt() ?? 0, rate: (res?['rate'] as num?)?.toDouble() ?? 0, votes: _parseVoterIds(res?['votes']), mine: ((res?['options'] as num?)?.toInt() ?? 0) & 0x1 != 0, - )); - } + ), + ); } return Poll( - pollId: map['pollId'] as int? ?? 0, - title: map['title']?.toString() ?? '', - settings: map['settings'] as int? ?? 0, - version: map['version'] as int? ?? 0, + pollId: pollId, + title: title, + settings: settings, + version: version, total: (stateMap['total'] as num?)?.toInt() ?? 0, answers: answers, voterPreviewIds: (stateMap['voterPreviewIds'] as List?)?.whereType().toList() ?? - const [], + const [], ); } } diff --git a/lib/models/reaction_info.dart b/lib/models/reaction_info.dart new file mode 100644 index 0000000..7b22de4 --- /dev/null +++ b/lib/models/reaction_info.dart @@ -0,0 +1,45 @@ +class ReactionCounter { + final String reaction; + final int count; + const ReactionCounter({required this.reaction, required this.count}); +} + +class ReactionInfo { + final List counters; + final String? yourReaction; + final int totalCount; + + const ReactionInfo({ + this.counters = const [], + this.yourReaction, + this.totalCount = 0, + }); + + static ReactionInfo? fromMap(Map? map) { + if (map == null) return null; + final rawCounters = map['counters']; + if (rawCounters is! List || rawCounters.isEmpty) return null; + final counters = []; + for (final c in rawCounters) { + if (c is! Map) continue; + final reaction = c['reaction']?.toString(); + if (reaction == null || reaction.isEmpty) continue; + final rawCount = c['count']; + counters.add( + ReactionCounter( + reaction: reaction, + count: rawCount is int ? rawCount : 0, + ), + ); + } + if (counters.isEmpty) return null; + final total = map['totalCount']; + return ReactionInfo( + counters: counters, + yourReaction: map['yourReaction']?.toString(), + totalCount: total is int ? total : 0, + ); + } + + bool get isEmpty => counters.isEmpty; +}